Technical Documentation: yc-software/qm
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Overview
Most agents are designed like personal assistants. You can make one work for a whole
company, but it quickly gets complex. QM is designed for startups. Employees each get
their own isolated workspace, and can also collaborate with the agent in channels, group messages, and projects.
Each person and each room has its own scoped memory, files, keychain view, permissions,
crons, web apps, and durable sandbox.
It's built with open source in mind. Pick your own harness and model and switch between
them. Pi and Claude Code both drive the same core, and Pi runs models from many
providers, so a deployment isn't tied to any single vendor.
Setup
Tell your coding agent of choice Let's deploy https://github.com/yc-software/qm. From here, it should follow the deployment guide in this repo.
You can also try out a 3rd-party hosted version of QM here.
If you're an infra provider interested in offering a hosted version of QM, feel free to reach out.
Features
- Personal and shared scopes. People customize the agent to be theirs, and still
work with it collaboratively in Slack channels and projects. - Slack and web. The same identity and configuration carries between Slack and the
web app. - Admin control. Set org-level configuration, security and sharing postures. Choose which
harnesses and models are available. - Web apps. Spin up custom internal apps and publish them to the right people.
- Shared skills. Skills are scope-owned and shareable by grant, with admin-gated
promotion to the whole org and skill packs imported from git repositories. - Background work. Crons, watches, and inbound webhooks work while you're away.
What you can do with it
- Search internal notes, email, documents, databases, and the web together
- Build internal apps, publish them to the right people, and keep their data current
- Learn your writing voice from past sends, then triage your inbox on a schedule —
labels and reply drafts included - Work in an existing repository: run tests, open PRs, monitor CI, check system logs
- Track a project in a shared channel and post updates and follow-ups
Architecture
flowchart LR
DB[("Postgres<br/>sessions · memory · queue")]
subgraph CORE["Headless core"]
API["API · identity · policy · scheduler"]
LOOP["Agent loop<br/>(Pi, Claude Code)"]
API <--> LOOP
end
SBX["Per-scope sandbox<br/>files · tools · logged-in services"]
DB <--> API
LOOP <--> SBXFor durability, set DATABASE_URL and SESSION_STORE=postgres — without it, sessions
live in process memory and vanish on restart. To exercise a branch against a real model and real Postgres, runnpm run dev-instance:web for web/admin or npm run dev-instance:slack for Slack.
Use npm run dev-instance:both when testing both surfaces together. Barenpm run dev-instance defaults to web for new instances and preserves the surface
on reload. Switch an existing instance with an explicit surface command.
How it works
Every turn runs through a central core, which can use a variety of models and harnesses
to generate the response. A Postgres persistence layer holds user data, session history,
and other durable state. The agent has a small, fixed tool surface; one of those tools isexecute, which runs commands in the scope's own isolated sandbox — its durable computer,
where installed tools stay installed. The web UI and admin panel share one service; the portal and optional built-in
auth broker share another. These modules communicate with core over its HTTP API.
See combined services for configuration and migration;
Slack is an optional in-process plugin that core starts
and supervises through a direct service client.
The core runs TypeScript directly on Node and uses Fastify for HTTP. The Slack plugin
uses Bolt; the web UI builds with Vite and renders with Lit.
The core itself is generic. Everything specific to one company — org config, custom tools
and skills, sandbox image, infrastructure — lives in a deployment directory that theqm CLI validates and deploys. Every substrate (harness, session
store, sandbox, memory) sits behind an interface. Memory can also be routed by scope to
external providers while retaining the built-in notebook.
Security and secrets
QM's approach follows local coding agents like OpenCode, Codex, and Claude Code: the
agent acts as the person it's working for, with their credentials and permissions, and
everything it does is audited. An org picks one security posture, which narrower scopes
can only tighten:
- Strict — every harness tool call pauses for human approval, except the two
no-effect turn enders. - Auto (default) — blocks private-network access.
- Dangerous — no tool approval gates, and content screening only observes.
Content screening is a deployment setting, securityScreen.mode: off (default),observe, or enforce, with securityScreen.classifier choosing the built-inmodel or an external proxy. When on, it classifies external content under every
posture. observe only records verdicts in the audit log. enforce quarantines
flagged content pending release approval, except under Dangerous, which caps
screening at observe.
The predeclared command policy — approval rules and hard denials for things like
recursive deletes or destructive SQL — applies in every posture, Dangerous included.
Sharing posture is independent:
- Isolated (default) — resources stay in their scope unless explicitly shared.
- Open — on a live authenticated internal human turn, the speaker's opted-in personal
files, artifacts, skills, and memory may be read in an opted-in shared room.
SECURITY.md has the threat model, the operator assumptions, and the
known limitations.
Deploy it for your org
Create an organization-owned deployment repository that depends on @yc-software/qm:
npm exec --yes --package=@yc-software/qm@latest -- \
qm init . --org <slug> --target <fly-or-aws>
npm installInitialization materializes a deployment skill for an agent and walks through
infrastructure, web sign-in, connector credentials, optional Slack access, deployment,
and live verification — no source checkout required. Each deployment runs in the
operator's own cloud account; initialization does not generate or enable deployment CI,
and this repository has no production deployment workflow. Seedeployment.md for the details.
Contributing
We take contributions as human-written text, not code — seeCONTRIBUTING.md. Describe the change you'd like informally in a.txt or .md file in adrs/, and if we're aligned we'll handle the
implementation. Report vulnerabilities privately — see SECURITY.md,
not a public issue.
Customize your instance
Choose how you want to customize QM:
- Config, tools, skills, and services: use the deployment repository above. It
pins@yc-software/qmand uses that release's runtime images; no source copy is needed. - Changes to QM itself: keep your own source fork, public or private. You may
modify any part of core, including the runtime, plugins, CLI, docs, and CI.
Contributing those changes upstream is optional.
Create a source fork
For a private source fork, create a standalone private repository, outside GitHub's
fork network. Seed only main and explicitly set it as the default branch:
gh repo create <org>/qm-private --private
git clone --single-branch --branch main --no-tags [email protected]:yc-software/qm qm-private
git -C qm-private remote rename origin upstream
git -C qm-private remote add origin [email protected]:<org>/qm-private
git -C qm-private push -u origin main
gh repo edit <org>/qm-private --default-branch mainDo not seed with git push --mirror: it copies unrelated upstream branches and tags,
leaves default-branch selection implicit, and can delete destination-only refs on later
pushes. The upstream remote supplies source updates without copying those refs to your
repository. For a public source fork, GitHub's Fork button is also an option. A GitHub
fork of a public repository cannot be private; keep private work outside that network.
Review inherited workflows before enabling Actions or adding credentials. Choose the CI
checks you want, and disable or adapt upstream release and publishing workflows for your
own package and image registries. Copying the source does not configure production
deployment CI.
Customize and run your source
Keep deployment configuration, tools, skills, plugin images, and infrastructure indeploy/layers/<org>/ in a private source fork, or in a separate private deployment
repository when your source is public. Never commit secrets. Seedeploy/layers/README.md for initialization and layout.
Keep deployment data separate from core code, but change core wherever your desired
behavior requires it.
From the source checkout, install dependencies with npm ci and use the in-tree CLI.
After completing the provider setup in deployment.md, build and
deploy your modified services explicitly:
node cli/bin/qm.ts check --config <deployment-dir>/qm.config.jsonc
node cli/bin/qm.ts plan --config <deployment-dir>/qm.config.jsonc --build-from .
node cli/bin/qm.ts up --config <deployment-dir>/qm.config.jsonc --build-from .
node cli/bin/qm.ts check --config <deployment-dir>/qm.config.jsonc --liveUse this checkout's CLI when changing the CLI itself. Without --build-from, the
normal deployment path selects published images, so editing source alone does not
change the deployed runtime. If you publish custom images instead, configure their
immutable references through imageOverrides. Follow the provider guide for sandbox
image builds; service builds do not replace that step.
Keep it current
For a source fork, update-qm merges upstream changes while preserving intentional
local behavior. Land sync PRs with their merge ancestry intact, never squash or rebase
them. Conflicts are expected maintenance work, not a requirement to discard
customizations. Use upstream-pr only when you want to contribute a generic change;
it prepares a clean upstream branch without private deployment data or history.
For a package deployment, upgrade the exact @yc-software/qm dependency and lockfile,
review contract changes and generated assets, then validate and deploy. There is no
upstream source history to merge.
Going deeper
docs/getting-started.md— first run, end to endcli/README.md— theqmCLI and the deployment directory contractdocs/deploy-directory.md— the deployment directory in fulldocs/principal-links.md— one person, several sign-ins: linking principalsdocs/porter.md— running qm on Porterdocs/superserve.md— using Superserve for agent sandboxes.env.example— every knob, documented in placedocs/swarms.md— durable agent pools, scoped messages, and blank Modal workersdocs/model-gateway.md— discover and route models through a gatewayplugins/— the surfaces (Slack, web UI, admin, portal)
License
Except where otherwise noted, QM is available under the MIT License.
Overview (`cli/README.md`)
`qm`
The standalone deployment CLI for QM. The normative directory schema,
security guarantees, target behavior, and lifecycle are indocs/deploy-directory.md. qm init materializes
the agent-consumable package runbook into the deployment repository.
npm exec --yes --package=@yc-software/qm@latest -- \
qm init . --org acme --target aws
npm install
npm exec qm -- check
npm exec qm -- infra render
npm exec qm -- doctor
npm exec qm -- infra build-image
npm exec qm -- plan
npm exec qm -- up --yes
npm exec qm -- check --liveThis package is published to npm as @yc-software/qm, with npm provenance attesting the
building workflow. A release is one dispatch of .github/workflows/release.yml frommain: it signs and pushes the first-party images, publishes the package pinning their
digests, and then tags v<version> and creates the GitHub release with the resolved
digests attached. Each release picks its own version: a patch bump past the latest released version, orcli/package.json's version when a PR raised it higher (for a minor or major bump); a
tag that already exists stops the release rather than moving. The checked-in image manifest is a sentinel that
a deployment overrides with real digests. The packed-artifact test exercises the consumer
path locally.
The CLI deploys long-running QM services; it is not the runtime. Docker runs
them locally, Fly runs them as Fly apps with Fly Machines for agent computers, and AWS
runs digest-pinned tasks on ECS Fargate (amd64 by default for first-party services) with Lambda MicroVM agent computers.
When upgrading an AWS source deployment that previously relied on the ARM64 default,
set each existing workload's architecture: "arm64" explicitly until rebuilding
its image for another platform. Secret rotation refuses a task-platform change
before uploading secrets. New release candidates record their workload architectures;
legacy source-built first-party candidates without that record require an explicit
architecture matching the image, or regeneration with the current CLI. Candidate
deployment and migration reject platform mismatches before accessing AWS.
Deployment directory
qm.config.jsonc
package.json
package-lock.json
deployment.md
.codex/skills/deploy-qm/
.env.example
.env
slack-app-manifest.yml
slack-sso-manifest.yml
sandbox/
tools/<id>/tool.json
tools/<id>/<binary>
skills/<id>/SKILL.md
Dockerfile
plugins/<name>/Dockerfile
infra/qm.config.jsonc is committed and contains no secret values. .env is ignored.package.json pins the CLI package at the exact version that scaffolded the
directory — contract: 1 is only the compatibility floor — so every checkout
resolves the same interpreter; upgrade the pin deliberately.cd into it and the DEPLOY commands act on it; --config / --env-file / --sandbox-dir relocate
a piece (e.g. several deployments sharing one sandbox/). check validates the config,
computed secret names, tools, skills, and plugins without network access; up, plan, andsandbox build run the same checks first. doctor verifies external prerequisites read-only.plan renders the deployment; AWS mutation requires up --yes.
For a single-host Docker deployment, sandbox.backend: "local" runs each agent
computer in its own container. qm up builds the local runtime from the CLI's
pinned sandbox base, mounts the host Docker socket into trusted core, and connects
core to each sandbox's private network. An explicit sandbox.image uses that
runnable local image instead.
On AWS, up verifies under the deploy lease that RDS point-in-time recovery
is current (its LatestRestorableTime must lag by at mostQM_AWS_DB_MAX_RESTORE_LAG_MS, default 10 minutes) and records the pre-deploy
timestamp in the deployment manifest it precedes. rollback restores code and
configuration only, so it prints that timestamp as the matching data restore
point (aws rds restore-db-instance-to-point-in-time);aws.predeployDbSnapshot: false opts out.
On AWS, up waits for every rolling service to reach COMPLETED with a single
deployment, polling until QM_AWS_ROLLOUT_DEADLINE_MS (default 20 minutes); a
timeout rolls the changed services back. ECS stops the old tasks once they exit
or their stopTimeout elapses (core drains for SHUTDOWN_DRAIN_MS, 10 s by
default), and a turn cut off there resumes on the new tasks. Core images older
than this release protect their task while a turn runs, so the first qm up
that replaces them cannot finish before their longest in-flight turn ends
(protection expires after 60 minutes at most); run that one deploy with a
longer deadline or when no long turn is in flight. The candidate migration task
and the private live-session canary also wait up to QM_AWS_ROLLOUT_DEADLINE_MS
(default 30 minutes for them); a task still running then is left to finish, and a
migration that outlasts it fails up before any service changes.
AWS deployments can opt into durable background ownership withaws.backgroundWorkControl: true. The CLI allocates a uniqueBACKGROUND_DEPLOYMENT_ID for each replacement core deployment and records it in
the deployment manifest. A no-op deployment and automatic ECS task replacement keep
that identity; an explicit core restart allocates a new one. Pending preparation
is persisted before ECS changes, and an ambiguous previous deployment must be
reconciled before another identity can be allocated.
Control requests require both CORE_SIGNING_SECRET and a distinctDEPLOYMENT_CONTROL_SECRET of at least 32 characters. The control secret is
restricted to core. One shared record names the deployment that owns background
work; a core process with a configured identity admits work only while that
record names its deployment, and BACKGROUND_WORK_ENABLED is ignored there.
The exported awsBackgroundWorkBootState reads the exact manifest core task to
return its recorded boot flag and optional deployment identity. It returnsundefined only without a recorded core task, and rejects missing or ambiguous
boot flags or identities.
The exported awsBackgroundWorkStatus(config, configDir, candidatePath?) proves
the stack's exact healthy core task set and reads the owner record through the
stack's own API, returning the responder's ownerDeploymentId, setAt, setBy,
and whether the responding process is currently active. The exportedawsSetBackgroundWork(config, configDir, enabled, candidatePath?, expected?) sets
the owner record to the stack's identity (or clears it when enabled is false)
without restarting ECS tasks, then polls until every core task of the stack has
reported matching activity. Passing expected: { ownerDeploymentId } refuses the change
if the owner differs from what the caller last observed. A demotion refuses to
clear a different deployment's ownership.
The exported awsBackgroundWorkCapacity(config, configDir, candidatePath?) proves
that an inactive controlled stack is currently reusable. It requires another
owner, stable native deployments and exact task inventories for every workload,
and resolved deployment preparation. It never changes deployment state. The
result binds the manifest and deployment identities, the owner, workload task
definitions, native deployment IDs, and task ARNs. A release coordinator can
combine both snapshots with immutable candidate provenance and compare them
again under its production lock before any mutation. This is a point-in-time
check, not a reservation: intervening maintenance or task replacement
invalidates the proof and must block promotion.
Live checks use the owning deployment's authenticated canary endpoint to verify a
real model reply, session persistence, generated title, error records, session
cleanup, and database catalog health. The CLI confirms the stack owns background
work and is active before and after the check, and requires a final success bound
to the request and responding process; heartbeats alone do not count. An uncertain
result never triggers an automatic replay or fallback. Legacy deployments retain
the Fargate canary, and a controlled stack that does not own background work uses
that same path.
Replacing or rolling back a controlled stack's core tasks requires that the stack
is not the current owner: hand ownership to the other stack first. Processes that
lose ownership stop claiming within their poll interval (at most their ten-second
validity window) and finish admitted work under their leases; ECS task
replacement then drains them through SIGTERM. qm down is not guarded: if the
record still names a stack that has no tasks, set the live stack active to
recover.
Core secret uploads defer activation to a subsequent staged up --restart core.
The generic upload path refuses changes to either control credential while a
controlled deployment is recorded, because replacing credentials before coordinating
all running processes would break ownership control. Legacy AWS deployments keep
the task replacement path when ownership control is not configured.
Batch operators can set QM_DEPLOY_PROGRESS_FILE to a new absolute file path andQM_DEPLOY_PROGRESS_TOKEN to a unique attempt identifier for candidate up --yes.
After all forward service updates have been submitted, the CLI atomically creates
a private JSON receipt with phase: "monitoring", token, orgId, and targets
(the selected workload-to-task-definition mapping). It then continues health
checks, manifest recording, and rollback under the deployment lease. This receipt
only permits the batch runner to release a submission slot; the CLI exit status
still determines success. Use a fresh path and token for every attempt. Missing
receipts must keep the submission slot occupied until the CLI exits.
sandbox build is a local validation build of the sandbox layer image. At runtime
sandboxes boot their platform's stock image; tools and skills arrive through the
deployment-layer sync, which every ordinary up performs.
Content screening is off by default. Set securityScreen: { "mode": "observe" }
to record classifier verdicts without acting on them, or "enforce" to quarantine
flagged content. The classifier is the built-in model unless "classifier": "proxy"
names an external screener with a provider label and HTTPS endpoint; route its token
through secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN.
Commands
init [dir] [--org id] [--target docker|fly|aws]
check [--json] [--live]
doctor
infra render|build-image|delete-image|delete-task-definitions
conformance [dir] [--static]
plan
up [--yes] [--build-from[=repo]] [--image-label label]
slack render
outputs [--json]
admin-login [--email [email protected]]
proof scope-key <scope-id>
secrets push [--from file]
status
logs [service] [-f] [--tail n]
down [--purge]
rollback [--to revision-or-sha]
sandbox build [--from image] [--tag tag] [--dry-run]Administrator login without email
After deployment, run qm admin-login to print a single-use login URL valid for
five minutes. Open it and confirm the displayed administrator email. The command
uses the deployment's existing PORTAL_SESSION_SECRET and the email inADMIN_GRANTS; if several admins are configured, select one with --email.
QM checks that the selected account still has org_admin access when the link
is redeemed. The command creates no account or role grant.
Run it from the deployment directory with its .env, or use --config and--env-file. Inside a running deployment without a config file, supplyPORTAL_PUBLIC_URL, PORTAL_SESSION_SECRET, and ADMIN_GRANTS through its
environment. The CLI prints only the URL, which is a temporary login credential;
do not publish it or put it in shared logs.
qm setup offers email setup separately. Skip it to use administrator login
without Resend or SMTP. For ordinary users before email is ready, the broker can
accept passwords: hash one with node plugins/auth/src/hash-password.ts [email protected] and store the output with qm secrets set AUTH_PASSWORD_USERS.
This is meant for onboarding; switch to email links or an identity provider
afterwards. To enable ordinary email login later, rerun qm setup
and configure the selected transport's complete credential set and sender,
then push secrets and redeploy. Missing email credentials disable email sign-in;
QM and qm admin-login remain available, even if a sender is still configured.
Deployment commands accept --config, --env-file, and --sandbox-dir;admin-login uses only --config and --env-file. dev remains
the contributor worktree loop and is separate from the portable deployment contract.
Package contract
The @yc-software/qm/contract export is the supported programmatic surface for
conformance tests. It exposes the contract version, parsing/rendering
functions, and provider ids without registering arbitrary runtime plugins.
Incompatible directory
changes increment the contract major; optional fields may be added within a
major.
Its one runtime dependency is Nodemailer, which qm check uses to verify SMTP credentials. It shells out to Docker with Buildx, Flyctl,
the AWS CLI, and Git. Terraform is operator-run against the module generated by init.
Overview (`deploy/README.md`)
Deployment templates
The files under deploy/ are service-image and Fly configuration templates used
by the qm CLI. They are not a production deployment and contain no cloud
account, workspace, or organization credentials.
The one exception is layers/, which is empty in qm itself. A private
fork of this repository keeps its organization's deployment there, and that material
never travels back upstream.
For a new hosted instance, follow the repository-rootdeployment.md workflow. It:
- asks whether to deploy into the operator's Fly.io organization or AWS account;
- creates the organization's deployment directory under
layers/with itsqm.config.jsonc; - configures verified-email web onboarding before optional Slack;
- returns the exact web, admin connector, user connection, and Slack app URLs;
- deploys package-selected immutable images into the operator's provider account; and
- requires live health, browser chat, connector visibility, and optional Slack
verification before completion.
The supported command sequence is generated by qm init and the selected
provider reference under .codex/skills/deploy-qm/references/.
Do not deploy the example names in these templates directly.
Topology
The public portal is the only Internet-facing service. It authenticates users
over OIDC and proxies the private Web UI and admin surfaces. Core, Postgres, and
agent computers stay private. The optional Slack surface runs inside core over
outbound Socket Mode.
By default the OIDC provider is the auth service qm deploys for itself: a
sign-in broker that emails a one-time link. It is private like every other
non-portal service — the portal republishes only its two browser-facing routes
under /idp/, so the issuer and the sign-in pages live on the portal's own
origin while the token, userinfo, and JWKS calls stay on the private network.
The self-hoster supplies an admin address, a verified sender, and a Resend key
or SMTP credentials; the CLI generates the broker's signing key and the portal's
client credentials and wires both sides. Core receives the same RESEND_API_KEY
and AUTH_EMAIL_FROM, which let admins email invitations to external users from
the admin Users tab or by chatting with QM; both are optional on core, and
without them the invitation is still created and the sign-in link is shared by
hand. Dropping "auth" from services hands
sign-in back to an external identity provider, which then has to register the
exact /auth/callback redirect.
Connector OAuth clients and the optional Slack bot token pair are entered at
the authenticated admin connector URL returned by qm outputs. Secrets are
encrypted in durable storage and are never committed to a deployment directory.
The agent advertises only connectors whose admin configuration is enabled.
cli/templates/fly/auth.toml declares an [http_service] like every other surface, but the
CLI always deploys it --flycast --no-public-ips; on AWS the ALB assertions refuse to
attach it to a load balancer. Never fly launch that file by hand — a broker with a
public IP exposes its token endpoint and lets a caller forge the rate-limit client
address.
Fixtures
stacks/ contains account-neutral contract fixtures. The checked-incli/templates/fly/<service>.toml files are templates from which the CLI derives
deployment-specific copies under the ignored deploy/stacks/.generated/
directory.
Overview (`desktop/README.md`)
QM desktop
An Electron client for an existing QM web deployment. The welcome screen connects to your workspace; the app then runs the same web UI with its own persistent session.
cd desktop
npm ci
npm startEnter your QM web URL on first launch. HTTPS is required except for localhost development. Change instances with QM → Change Instance… (Cmd+, on macOS). You can also set QM_DESKTOP_URL when starting the app.
QM_DESKTOP_URL=http://localhost:3000 npm start
npm run packagePackaging writes a native app for the current platform into desktop/dist/, registering the qm-desktop URL scheme. Use the packaged app for browser sign-in. The macOS build is an unsigned local prototype, without automatic updates in development. It connects to a running server; it does not bundle the QM backend or provide offline agent execution.
Browser sign-in
When sign-in is needed, the app opens /auth/desktop in your default browser. Sign in there as usual, confirm your account, and choose Open QM Desktop. The browser returns a two-minute, single-use code bound to the app's proof key and random state. The app redeems it over the instance connection and stores the session in its isolated persistent cookie partition. Closing the app or starting another connection cancels unfinished sign-in requests; it does not sign out sessions whose cookie exchange already completed. The portal must include the desktop sign-in routes; older deployments need an update.
The portal preserves the browser session's identity, original authentication time, and expiration. Redemption uses the existing durable core claim store to prevent replay across portal instances and deployments. The desktop proof key exists only for the disposable, in-progress sign-in attempt and is never sent to the browser. Passwords and identity-provider cookies stay in your browser.
On macOS, the window controls sit inside the sidebar header. With a single session open, drag its title bar to move the window; tab dragging remains available in multi-pane layouts. Hold Command to reveal shortcuts for the first nine sessions in expanded sidebar groups, then press 1–9 to switch. Windows and Linux use Control. Collapsed groups and a hidden sidebar are excluded.
Remote content is sandboxed with no Node.js access. An isolated desktop preload adds window styling and session shortcuts with one sender-validated operation for opening a same-instance setup page in the browser. Off-origin links open in your browser. Microphone, camera, and notification permissions are disabled in this prototype.
The welcome screen uses Becalmed off Halfway Rock (Fitz Henry Lane, 1860), sourced from Ève Bouffard’s QM brand board. National Gallery of Art collection record.
Connection setup and previews
Slack installation and personal app authorization start in your browser before QM creates the connection attempt. Finish setup there, then return to the desktop app; it refreshes connection status on focus. Settings contains both personal Slack linking and the app picker. This keeps provider callbacks, installation POSTs, cookies, and tab-local authorization state in one browser.
Same-instance links opened in a new tab use a separate sandboxed window sharing the instance session. External links open in the browser. View → Back and Forward navigate the focused window. Closing the main window closes its previews.
Computer use
On macOS the app can see and drive running Mac apps through desk, a Rust helper bundled at QM.app/Contents/Resources/desk. npm run package builds it with Cargo first, so install Rust (rustup) on the packaging Mac. Because QM launches it, grant QM Accessibility (and Screen Recording for screenshots) in System Settings > Privacy & Security, then relaunch QM. In development, npm start uses desk/target/release/desk; override with QM_DESK_BIN.
The workspace preload exposes window.qmDesktop.computer.call. The desktop tool accepts JavaScript with top-level await and preserves bindings within the initiating turn. A hidden, sandboxed Electron renderer evaluates code without Node.js or network access and forwards computer API requests to desk serve one at a time (see desk/PROTOCOL.md). Each request uses the originating turn's owner and absolute call deadline. The same sender check as browser handoff applies. All installed apps are available subject to macOS permissions.
The first call returns API documentation. Start with var app = await computer.getApp("Calculator"), then use app.getAXState(), app.click(id), or app.getScreenshot() in later calls. Accessibility observations default to changes since the previous state; {disableDiffing: true} returns the full tree. Await every operation; background callbacks persist within the turn and should not schedule computer actions. Reset, cancellation, and timeout destroy JavaScript state. Results contain at most four resized JPEG screenshots within an 800 KB base64 budget.
Before each click, scroll or drag, a transparent click-through overlay flies an agent cursor to the target point and desk waits for it to land. Set QM_DESK_CURSOR=0 to skip the overlay.
Mac release
npm run package builds the unsigned local app. For a signed and notarized build, install a Developer ID Application certificate in the Mac keychain and sign in to that team in Xcode Settings → Accounts. Then run:
QM_MAC_SIGN_IDENTITY="Developer ID Application: Your Organization (TEAMID)" npm run package:releaseThe release command signs the app, submits an archive using the signed-in Xcode account, waits for notarization, and verifies the stapled ticket and Gatekeeper assessment. It produces the notarized ZIP, SHA256SUMS.txt, and RELEASES.json in desktop/dist. Credentials and certificates stay outside this repository. Builds target the current machine's architecture.
Packaged Apple Silicon builds check the desktop-only feed on startup and every five minutes. Updates download in the background and restart QM automatically without a confirmation dialog. Restart waits while a composer has a pending submission, file processing, staged attachments, or an open paste editor. Older servers without the update-readiness handshake defer automatic restarts until their web UI is upgraded. Open non-web previews, such as PDFs, also defer restart until closed. Interaction pauses briefly while the app saves restart state. Before restarting, QM flushes saved text drafts and pane layout, saves the current instance page, and flushes its persistent browser session. Server-side agent work continues during restart. QM → Check for Updates triggers an immediate check. Development, Intel, Windows, and Linux builds do not use this Mac feed. Versions 0.1.0 and 0.1.1 require one manual upgrade to receive the updater.
Publish the verified ZIP and checksum as desktop-v
npm run test:electron exercises actual Electron preview windows, POST popups, session sharing, sandboxing, and browser handoff against a temporary local server.
New chats started in desktop have a stable desktop tool. Send a request such as “Use Calculator to compute 17 × 23.” Only human-initiated turns submitted from the desktop app can execute it. Automatic openers, background automation, delegated turns, and retries cannot use computer access. The app must stay open and connected for that turn; switching chats is fine. Continuing the chat in a browser retains the tool definition but returns an unavailable result if it is called. Existing browser-created chats do not gain computer access: start a new desktop chat. Chats from the earlier computer-use trial retain their old tool schema and must also be replaced with a new chat.
Computer requests are bound to the submitting desktop turn. Only one turn can control the computer at a time; other turns receive a busy result until it finishes. A closed window, disconnected stream, stopped run, or helper deadline cancels native execution; reconnecting does not replay actions. After an interrupted action, observe the app before retrying because it may have partially completed. Screenshots return as image content, without base64 duplicated in model-visible text.
Guided computer access
On macOS, missing computer permissions open a local setup window before the workspace.
It checks Accessibility and Screen Recording automatically, opens their System Settings
panes, and provides the installed app as a draggable icon with a Finder fallback.
Choose Enable computer access to request the native prompts, then enable this app
in both settings lists. Continue to workspace becomes available when both checks
pass and restarts the native helper to pick up the grants. Not now skips setup;
QM → Computer Access… reopens it. A permission failure during a task reopens setup
and reports the failure without replaying the action.
For local macOS builds, set QM_MAC_SIGN_IDENTITY to a consistent signing certificate
to keep the application identity stable across updates. Ad-hoc builds can leave stale
macOS permission entries: remove the old entry, add the current app, and enable it again.
Overview (`fly/README.md`)
Sandbox base image
fly/Dockerfile builds the shared sandbox base image: a minimal generic toolset
(the coding-agent CLIs, AWS CLI v2, the baked Python venv at /opt/agent-venv, and the
optional agentic browser engine behind the INSTALL_BROWSER_ENGINE build arg). The
directory name is historical — it predates the retirement of the Fly Machines sandbox
backend — and stays because the Dockerfile is consumed by paths that pin it by name.
Where the image is used:
- Signed releases.
release-package.ymlbuilds and signs it asghcr.io/<org>/qm-sandbox-basealongside the service images. - Local docker sandboxes.
scripts/local-sandbox-build.shbuilds it asqm-sandbox-base:dev, then stackslocal/Dockerfileon top to produceqm-sandbox-local:latestforSANDBOX_BACKEND=local. - Deployment layer builds. A deployment's
sandbox/Dockerfilemay build FROM a
published base viaqm sandbox build(a local validation build).
Runtime sandbox backends (sprites, smolmachines, e2b, modal) do not boot this
image. Sprites and smolmachines boot their platform's stock image. E2B boots the
template named by E2B_TEMPLATE_ID (build and publish it fromdeploy/e2b/e2b.Dockerfile) or, when unset, E2B's stock base template. Modal bootsMODAL_IMAGE or, when unset, a default assembled at runtime from a digest-pinnednode:24-slim with the sandbox toolset apt-installed on top
(src/sandbox/modal-client.ts). The deployment layer's tool descriptors and skills
arrive through the deployment-layer sync.
Deployment-specific tools are NOT baked here — a deployment stacks them via its
sandbox layer (qm sandbox build over <deploy dir>/sandbox/). fly/tools/x-api is
copied into the image by the Dockerfile and its file list feeds the local sandbox
image fingerprint (src/sandbox/local-sandbox.ts).
Overview (`deploy/helm/README.md`)
QM Helm chart
This chart runs core, web-ui (including /admin), portal (including /idp auth),
and the optional egress proxy. services.auth and services.admin configure
embedded modules, not separate Deployments or Services.
Images
There is deliberately no default image tag. Build all enabled services from the
same checked-out QM revision that supplies this chart, or independently verify a
release's images include the combined topology and core /readyz endpoint. Old
standalone auth/admin images are not compatible. The chart version is not an
application image version.
For example, from the repository root, with a registry you own and are signed into:
export REGISTRY=registry.example.com/your-project/qm
export TAG=$(git rev-parse HEAD)
for service in core web-ui portal egress-proxy; do
docker buildx build --platform linux/amd64 --push \
--build-arg GIT_SHA="$TAG" \
-f "deploy/$service/Dockerfile" -t "$REGISTRY/$service:$TAG" .
done
cat > images.yaml <<EOF_IMAGES
image:
repository: $REGISTRY
tag: "$TAG"
EOF_IMAGESUse the architecture of your cluster. A commit tag identifies the build only if
that checkout is clean; use a unique tag when testing uncommitted changes. For
immutable deployments, set each enabled service's imageRef to its full registry
reference, such as registry.example.com/your-project/qm/core@sha256:<digest>.imageRef takes precedence over repository/tag composition. Otherwiseservices.<name>.tag overrides image.tag. Configure imagePullSecrets for a
private registry. Neither this chart nor these examples publish an official release.
Cluster setup
Bring these before installing:
- Kubernetes, Helm 3, a reachable durable Postgres database, and backups.
- A supported sandbox backend and its credentials. Kubernetes hosting does not
configure sandbox provisioning or app hosting automatically. - DNS and an ingress controller. For TLS, provision the referenced certificate or
install cert-manager with the configured ClusterIssuer. - The signing/encryption secrets, portal session secret, first administrator grant,
sign-in allowlist, auth client/signing configuration and email transport described
in the deployment reference andsrc/deployment/secret-schema.ts.
Store credentials in a Kubernetes Secret and use envFrom, or supply secretEnv
through a protected values file. Helm stores secretEnv values in its release
history. Never commit these values. With envFrom, explicitly provide bothAUTH_ALLOWED_EMAILS and OIDC_ALLOWED_EMAILS (or both corresponding domain
variables); Helm cannot inspect externally supplied Secret values. When envFrom
is present, the chart does not derive OIDC allowlist environment entries fromenv or services.auth.env, since those entries would override a potentially
narrower policy in an external Secret. Set OIDC explicitly in values or the external
Secret, including when mixing auth values with envFrom. Without envFrom, auth
allowlists in env or services.auth.env propagate unless explicit OIDC values
in env or secretEnv override them. Auth allowlists in secretEnv are aliased
inside the chart-managed Secret; later envFrom sources retain their precedence.
Only portal should normally have public ingress. ingress.service accepts an
enabled portal, core or web-ui, not embedded modules or the TCP egress proxy.
Exposing core or web-ui is an operator choice, not required for the portal setup.
A minimal Kubernetes configuration fragment, not a complete installation:
publicUrl: https://qm.example.com
imagePullSecrets:
- name: registry-credentials
envFrom:
- secretRef:
name: qm-runtime
ingress:
enabled: true
service: portal
hosts: [qm.example.com]
className: nginx
clusterIssuer: letsencrypt-prod
services:
core:
replicas: 2
env:
SESSION_STORE: postgres
SNAPSHOT_STORE: s3
TRANSFER_STORE: s3
S3_BUCKET: qm-example-data
S3_REGION: us-west-2This fragment expects DATABASE_URL, a remote SANDBOX_BACKEND and S3 credentials
in qm-runtime; see
Core data and rollout behavior. After supplying
runtime config and images.yaml, render and inspect before applying:
helm lint deploy/helm -f images.yaml -f cluster.yaml
helm template qm deploy/helm -f images.yaml -f cluster.yaml
helm upgrade --install qm deploy/helm --namespace qm --create-namespace \
-f images.yaml -f cluster.yaml --wait --timeout 10mValidate the complete installation: test your database, ingress/TLS,
sign-in, real turns, sandbox provider and backup/restore in your target cluster.
Core data and rollout behavior
Core keeps durable state in two places: Postgres (DATABASE_URL) and an object
store for file bytes. Choose one of two layouts.
Shared storage (recommended, supports multiple replicas). Set DATABASE_URL
with SESSION_STORE=postgres, SNAPSHOT_STORE=s3, TRANSFER_STORE=s3, S3_BUCKET
and S3_REGION (optionally S3_PREFIX) on core, use a remote SANDBOX_BACKEND,
and leave services.core.persistence disabled. Sessions and runs then live in
Postgres, and uploaded artifacts, session shares, published-app source archives and
sandbox file transfers go to the bucket, so replicas share that state and pods can
be replaced with rolling updates. Production core refuses to start withoutDATABASE_URL and SESSION_STORE=postgres, or with RUN_STORE=memory. S3
credentials come from the standard AWS chain, such as a pod IAM role bound throughserviceAccount.create=true with serviceAccount.annotations, orAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY in the runtime Secret. Any S3-compatible
store works outside AWS: set AWS_ENDPOINT_URL_S3 to its endpoint (for example
Cloudflare R2, Tigris or Google Cloud Storage with HMAC keys) and S3_REGION to
what it expects, such as auto for R2. /data is then
pod-local, and a few things still live only on the replica that created them:
agent workspace files outside artifacts/ (including files shared from there) and
pending ChatGPT device logins. The chart sets no session affinity, so these can be
unavailable from another replica and are lost on pod replacement. Switching an
existing volume-backed install to S3 does not migrate bytes already under /data;
copy /data/docstore/ to the bucket under S3_PREFIX and /data/session-shares/
under <S3_PREFIX>session-shares/ first, or existing uploads and shares become
unreadable.
Local volume (single replica). Without an object store, core writes file bytes
under /data, and they are lost when the pod is replaced even with Postgres
configured. services.core.persistence.enabled=true mounts a ReadWriteOnce PVC at/data, sets DATA_DIR=/data and uses group 1000 for the Node image's volume
permissions. Storage drivers must support fsGroup, or an existing volume must
already be writable by UID/GID 1000.
Persistent core supports zero or one replica, with Recreate deployment strategy
so upgrades do not mount the same data concurrently. Expect downtime on upgrades;
shared-storage multi-replica operation over a volume is not supported. Use the
object-store layout for high availability. replicas: 0 scales down without
discarding the claim.
The chart-created <fullname>-core-data claim has helm.sh/resource-policy: keep;
it is retained on uninstall or when persistence is removed. It is not a backup.
Record the actual claim name before uninstalling. Reinstall withservices.core.persistence.existingClaim=<name> and persistence enabled to reuse
it, rather than trying to create the retained claim again. Existing claims are
never created or deleted by this chart. Delete retained data manually only when
you intend to discard it. A null storageClass selects the cluster default; ""
requests no storage class. Existing-claim size and class are managed externally.
services.<name>.port controls the process, Service and probes; conflictingPORT values are rejected. The egress-proxy image's Envoy listener is fixed at
48080, so other proxy ports are rejected rather than rendered as broken Services.
Core liveness uses healthPath: /healthz; readiness usesreadinessPath: /readyz. Its startup probe allows five minutes for initialization
before liveness begins. Override startupProbe fields as needed, or set it to null
to disable. Helm merges probe maps, so set startupProbe.httpGet: null when
switching to an exec or tcpSocket handler. When overriding endpoint paths,
update the startup probe too. Database unavailability should remove core from
endpoints, not restart it through liveness.
Regression tests
Install Helm 3 on PATH (or set HELM_BIN) and run:
node --test test/helm-chart.test.tsThese tests render real manifests and parse them with Helm's YAML parser. Missing
Helm is a local skip and a CI failure. They do not replace live Kubernetes tests.
Overview (`deploy/layers/README.md`)
Organization layers
A private source fork can keep its deployment material under deploy/layers/<org>/.
Core code may change independently to implement the organization's desired behavior.
Public source checkouts keep private deployment material in a separate private
repository. Package deployments need only that deployment repository, with no source
fork. See the README for both paths.
Upstream qm keeps only this shared README here. Organization layers never travel
upstream; upstream-pr checks that boundary when a contribution is requested.
Creating a layer
node cli/bin/qm.ts init deploy/layers/<org> --org <slug> --target <fly-or-aws>qm init writes the deployment config, the secret-name example, the sandbox and provider
scaffolding, an operator runbook, and a per-directory .gitignore that keeps .env values
and Terraform state out of Git. Generate the layer rather than hand-building it so that.gitignore comes with it; the root .gitignore covers the same files as a backstop.
The result, described in full in docs/deploy-directory.md:
deploy/layers/<org>/
qm.config.jsonc the deployment config; committed, no secret values
.gitignore scaffolded; keeps .env and tfstate out of Git
.env.example computed secret names, never values
.env local secret values; never committed
sandbox/ org tools and skills for agent computers
plugins/<name>/ org-specific service images
infra/ provider infrastructure and tfvars, on AWS targets
slack-app-manifest.yml generated bot manifest
deployment.md operator runbookPoint the CLI at a layer with --config:
node cli/bin/qm.ts check --config deploy/layers/<org>/qm.config.jsoncFor a separate deployment directory, substitute its path in both commands.
Deploy modified services with the explicit --build-from workflow in the README.
Run the CLI from the tree as shown. npm exec qm does not work in a source checkout
because the workspace symlink points at cli/, which is unbuilt.
Nearby directories
deploy/stacks/ holds account-neutral contract fixtures used to test the Fly backend, anddeploy/<service>/ holds the service image and Fly templates the CLI renders from. Neither
is a place for organization material.
The rule
Nothing under deploy/layers/ may reach upstream qm: not the config, not the sandbox
tools, not the infrastructure coordinates, and not the names of systems or people that
appear inside them. Secrets never enter Git at all, in this directory or any other. They
belong in the provider's encrypted secret store, with local values only in the gitignored.env.
Overview (`deploy/stacks/README.md`)
Fly deployment fixtures
The directories here are account-neutral contract fixtures for the qm
Fly backend. They are not production deployments and CI does not deploy them.
To self-host qm, start from the repository-rootdeployment.md. It creates the organization's deployment directory
under ../layers/, asks for the operator's Fly organization or AWS
account before mutation, and generates the optional bot manifest. It generates a
separate Slack SSO manifest only when Slack is selected as the OIDC provider.
The packaged cli/templates/fly/<service>.toml files are the service templates.
The CLI derives per-deployment copies under the ignoreddeploy/stacks/.generated/ directory. Secrets belong in the provider's encrypted
secret store and never in these fixtures.
Overview (`desktop/desk/README.md`)
desk
A small Rust program that lets an agent see and drive Mac apps: list running apps, read a window's accessibility tree as numbered elements (plus an optional screenshot), and run batches of clicks, typing, keys, scrolls and drags. It speaks JSON lines on stdio; see PROTOCOL.md. It copies the shape of Codex's Computer Use: AX tree first, indexed targets, events posted to the target pid. No Codex code or prompt text is used.
The QM desktop app bundles the release binary at QM.app/Contents/Resources/desk and spawns desk serve on first use, so macOS attributes Accessibility and Screen Recording to QM. npm run package in desktop/ builds it with Cargo first.
Status: exercised in the signed macOS desktop trial for permission setup, app observation, and Calculator interaction.
Build and try it alone
cargo build --release
./target/release/desk permissions --prompt
open -a Calculator
./target/release/desk apps
./target/release/desk observe Calculator --png /tmp/calc.png
./target/release/desk act Calculator '[{"type":"click","target":{"index":12}}]'Run alone, macOS grants permissions to the terminal that launches desk, so switch your terminal on under System Settings > Privacy & Security > Accessibility and Screen Recording, then restart it.
App listing shows regular Mac apps. Explicit names, bundle IDs, and full application paths can also target accessory apps with an accessible, visible window. Background-only processes are excluded; observation and control remain subject to macOS permissions and app support.
Platform considerations
- Background input is experimental. Desk sends target-only synthetic activation and input without raising the app, but some apps may ignore it.
{"type":"raise"}is an explicit foreground action. Live Mac verification is still required. - Screenshots. On macOS 14+ they use ScreenCaptureKit's
SCScreenshotManager. The app's own menus, popovers and sheets in front of the target window are composited in, and the image grows to include any part outside the window, across displays if needed;framesays which screen area it covers. macOS 12.3–13 usesCGWindowListCreateImagefor the main window only; menu/panel composition requires macOS 14+. desk links ScreenCaptureKit, so it needs macOS 12.3 or later.pixels_per_pointstill needs checking on a Retina screen. _AXUIElementGetWindow(private, widely used) maps the AX window to a window id for capture and event targeting.- Clipboard.
pastereplaces the general pasteboard, presses Cmd+V and restores the previous contents only once the focused field visibly contains the pasted text. When it cannot confirm that (canvas or terminal views without readable text, password fields, slow apps), it leaves the pasted text on the clipboard and says so, rather than risk the app pasting the user's clipboard. Clipboard data is available before Cmd+V is sent, so the receiving app does not need a callback from desk to read it. - Keyboard layout.
type_textreads the current layout withTISCopyCurrentKeyboardLayoutInputSourceandUCKeyTranslateon desk's main thread (TIS calls are main-thread only on recent macOS). - Accessibility enablement. On first observation, desk requests
AXManualAccessibilityand usesAXEnhancedUserInterfaceonly if an empty web area still needs it. Prior settings are restored on normal exit. - Speed. Element attributes are read in batches. Actions and settability are cached until that app posts an accessibility notification; frames, values and children are always read fresh. Offscreen pruning and a 6,000-element read cap bound each walk.
- Universal binary:
cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin, thenlipo.
Overview (`plugins/admin/README.md`)
Admin plugin
A minimal admin surface for the qm — the operator/admin plane from
spec §14, delivered as an added plugin (like the Slack plugin). It is a separate
process that talks to the core only over the admin governance API; the core has
zero dependency on it. Don't run it and nothing about the core changes.
You reach it through the portal (real SSO); the surface trusts the portal-synthesizedadmin=<sub> cookie as identity and asks the core whether that principal is an admin
(GET /api/whoami → core GET /v1/admin/whoami → canAdminister). It holds no admin id list
of its own. Pick a scope, then either edit governance (command policy, SOUL, egress),
manage users (the org-wide Users tab), or read the observability views —
History, Files, Live, Errors, Audit, Skills, Crons, Deployments, Volumes.
The Users tab (org-wide, org_admin-only) lists everyone who has
used the agent (from session metadata — no content) with admin status joined, plus the
authoritative grant list, and lets an org_admin promote a principal to org_admin
or revoke — every mutation attributed and audited, the last org_admin protected.
Invite teammate adds someone by email with Member or Org admin access. The invite form grants access without an
expiration date. Teammates appear in Users before their
first session. Addresses already admitted through the domain or allow-list may receive a
sign-in invitation too. Re-inviting an admin never removes their admin role.
Invitations are durable and listed with their access status. Revoke ends access immediately.
Invitation email uses Resend when configured. Teammate invitations contain a single-use sign-in link valid for 24 hours. If delivery is unavailable or fails, Admin shows the same link with a copy control after the invitation is created. Redemption checks current membership and claims the token through durable storage; revocation or a new invitation invalidates earlier links. Company access displays the web email domain and the local Slack allow-list; these are deployment settings, not editable Admin policy.
The existing external-user API still requires an expiry and rejects org members.
Teammate invitations and revocations are restricted to the Admin surface.
(org_admin is the only supported role for now; team_admin was removed — team-scoped admin
observability is future work. See src/admin/admin-service.ts.) History (newest sessions, org-wide or narrowed
to one scope, by origin, drilling into transcripts with per-turn model-context breakdowns), Files
(workspace contents), and Live (ongoing/recent runs) are top-down content views: an
org-scope query spans the whole org; a narrower scope is limited to that scope. Every
action is authorized in the core and audited.
Run
PORT=8080 ORG_ID=acme npm start
cd plugins/admin
CORE_API_URL=http://localhost:8080 CORE_ORG_ID=acme PORT=8090 npm startNo separate build command is needed. The Node 24+ server bundles the admin Lit modules once at startup and embeds them in the existing CSP-hashed script. The backend uses node:http and native TypeScript, with optional Sentry error reporting.
The admin tabs render through the Lit modules in ui/, with ui/admin.ts as their shared entry point. They use the existing light-DOM elements, classes, and styles without layout wrappers. Settings drafts, validation, dirty state, and save feedback render from state; list views own filtering, pagination, and editor state. Stable row keys preserve focus. Shared table, card, and list templates live in ui/shared.ts. Specialized safe Markdown, XML, and tool-output formatters remain shared adapters in the shell.
The shared admin controller retains routing, API calls, and change-review dialogs. Successful saves commit the submitted snapshot, preserving newer edits made while a request was in flight. Scope and render generations prevent stale requests from replacing a newer page. Related settings refresh independently so changing one section cannot discard neighboring drafts. Run npm test and npm run typecheck from this directory after changing the UI.
Env: CORE_API_URL (default http://localhost:8080), CORE_ORG_ID (default acme),PORT (default 8090), CORE_SIGNING_SECRET (required outside isolated development), andINBOX_USERS (the same comma-separated principal allowlist used by Inbox and Calendar). The
portal also supplies a short-lived x-portal-identity token, which this surface forwards to core.
There is no ADMIN_PRINCIPALS — admin identity + role + scope live solely in the core's
durable, mutable admin_grants store, and this surface derives admin status from it via/api/whoami. ADMIN_GRANTS (env) is now only the one-time seed for an empty store; after
that, admins are promoted/revoked at runtime through the Users tab (a redeploy never clobbers
runtime grants).
How it stays safe
- The browser never holds an admin credential. The portal supplies the verified identity in a
short-lived signed header; the compatibility cookie alone is not accepted when auth is configured.
Core verifies the token and decides admin-ness on every action. - All authority is enforced in the core, not here (spec §14): the core authorizes
every read and write againstadmin_grants(canAdminister) and refuses scopes you don't
administer (403).GET /api/whoamireports that same grant state (it grants no new power). - Content reads are scope-authorized and audited. Transcript, model-request, file, memory,
keychain, and other sensitive bodies are served only to admins of the owning scope, and every
read lands in the audit log.
Endpoints it serves
GET / (UI) · GET /healthz · GET /api/me + GET /api/whoami (identity + derived admin
status) · POST /api/logout ·GET /api/scopes/:scopeId + PUT /api/scopes/:scopeId/:resource
(command-policy|soul|egress) ·GET /api/{errors|audit|crons|deployments|skills|sessions|runs|files}?scope= ·GET /api/sessions/:id?scope= (transcript) · GET /api/sessions/:id/llm?scope=
(captured model requests) · GET /api/files/read?id= + GET /api/files/download?id= (document content) ·GET /api/retention (org-wide) · GET /api/users (org-wide; roster + grants) ·POST /api/grants (promote) · DELETE /api/grants/:principalId?scope=&role= (revoke) ·POST /api/external-users (invite) · DELETE /api/external-users/:email (revoke) — all
proxied to the core's /v1/admin/… with the admin actor injected. Grant and external-user
mutation is org_admin-only (enforced in the core, not the surface).
Slack setup
Hosted Add to Slack is shown only when core reports installAvailable, enabled byQM_SLACK_SERVICE_URL and its matching QM_SLACK_SERVICE_TOKEN. Public deployments
without that service lead with the preconfigured Slack app manifest and token form.
Connected hosted apps offer Re-add to Slack; custom apps must be disconnected before
switching to the hosted app. Both paths retain the custom app setup instructions.
Overview (`plugins/auth/README.md`)
auth — the built-in sign-in broker
An OIDC authorization server that speaks exactly the subsetplugins/portal consumes. Instead of an
external identity provider, people prove who they are by opening a one-time link
emailed to an allowed address.
Endpoints
| Route | Reached by | Notes |
|---|---|---|
GET /authorize |
browser, via the portal at /idp/authorize |
validates the request and renders the email form |
POST /authorize |
browser, via the portal | with email configured, answers with the same confirmation page, then emails a link out of band |
GET /verify |
browser, via the portal at /idp/verify |
consumes the link and redirects to the portal's /auth/callback with a code |
POST /token |
portal, over the private network | HTTP Basic client auth, authorization-code grant, PKCE S256 |
GET /userinfo |
portal, over the private network | Bearer access token, verified statelessly |
GET /.well-known/jwks.json |
portal, over the private network | the ES256 public key |
GET /.well-known/openid-configuration |
operators | discovery, for debugging |
GET /healthz |
the platform | liveness |
The broker is never published directly. The portal republishes only the three
browser-facing routes under AUTH_BROKER_PREFIX (/idp by default), which is
why the issuer is https:///idp and the sign-in pages share the portal's
origin, cookies, and CSP.
Durability
Nothing about a sign-in lives in this process. The sign-in link, the
authorization code, and the access token are self-contained JWTs sealed with
purpose-separated keys derived from AUTH_TOKEN_SECRET; the id_token is signed
with the P-256 key in AUTH_SIGNING_JWK. Single use — of both the link and the
code — and the send rate limits are claimed through core's Postgres-backedReplayDedupe over the chassis signed core client, so a restart, a blue-green
deploy, or a second instance cannot resurrect a spent link. If core cannot record
a claim the broker fails closed and refuses the sign-in.
Configuration
Values below are set by qm from the deployment config and the secret store.
Authentication keys and an email allowlist or domain remain required. Email
delivery is optional; missing or incomplete email configuration disables delivery.
Fully supplied email configuration is validated at boot.
| Variable | Source |
|---|---|
AUTH_ISSUER, AUTH_CLIENT_ID, AUTH_REDIRECT_URI |
derived from publicUrl |
AUTH_CLIENT_SECRET, AUTH_TOKEN_SECRET, AUTH_SIGNING_JWK |
generated by qm setup |
AUTH_ALLOWED_EMAILS, AUTH_ALLOWED_EMAIL_DOMAIN |
the operator's admin address or domain |
AUTH_PASSWORD_USERS |
optional :<hash> entries for password sign-in (below) |
AUTH_PASSWORD_LIMIT_PER_EMAIL, AUTH_PASSWORD_LIMIT_PER_IP |
optional, attempts per AUTH_SEND_WINDOW_S (defaults 10 and 30) |
AUTH_EMAIL_FROM |
the operator's verified sender |
AUTH_BRAND_NAME |
botName in the deployment config; the Admin page's live branding, when set, takes precedence on rendered pages and emails |
AUTH_EMAIL_TRANSPORT and the chosen transport's variables (below) |
the operator's email provider |
AUTH_LINK_TTL_S, AUTH_CODE_TTL_S, AUTH_ACCESS_TTL_S, AUTH_REQUEST_TTL_S |
optional, capped |
AUTH_SEND_WINDOW_S, AUTH_SEND_LIMIT_PER_EMAIL, AUTH_SEND_LIMIT_PER_IP |
optional |
CORE_API_URL, CORE_ORG_ID, CORE_SIGNING_SECRET |
the chassis core block |
The signing key is single, not a set: rotating it means redeploying, and links
minted by the previous key stop verifying at that moment.
Password sign-in for getting started
A fresh deployment can reach its first sign-in without Resend, SMTP, or an
identity provider. Set AUTH_PASSWORD_USERS to comma-separated :<hash>
entries and the sign-in page offers an email-and-password form beside (or
instead of) the email-link form. A successful password sign-in creates the same
remembered browser session and the same authorization code as an email link, so
admin bootstrap, allowlists, and everything downstream behave identically. Each
address must still be permitted by AUTH_ALLOWED_EMAILS,AUTH_ALLOWED_EMAIL_DOMAIN, or an invitation.
Generate a hash from a checkout (the password is read from a hidden prompt, or
from stdin when piped, and must be at least 12 characters):
node plugins/auth/src/hash-password.ts [email protected]Then, from the deployment directory:
qm secrets set AUTH_PASSWORD_USERS '[email protected]:scrypt$15$8$1$...'Only scrypt hashes are stored; a plaintext password in the list refuses boot.
Passwords are compared in constant time, and attempts are rate limited per
account and per client address through the same durable claims as link sends,
so a core outage fails closed. Password sign-in is meant for onboarding. Once
the deployment is running, configure an email transport or an external identity
provider and unset AUTH_PASSWORD_USERS; the page says as much.
Invited external users
An address an org admin has invited as an external user (Admin → Users, or by
asking the agent) may sign in until its expiry even though it is on neitherAUTH_ALLOWED_EMAILS nor AUTH_ALLOWED_EMAIL_DOMAIN. The env list is checked
first and settles the answer on its own; only an address it does not cover is
looked up in core over the signed core client (GET /v1/auth/broker/email-allowed), at every step — when the link is requested,
when it is opened, and when the code is exchanged — so a revoked or expired
invitation stops working at once. A lookup that fails or times out counts as not
allowed. One of the two env variables is still required at boot.
Email transport
AUTH_EMAIL_TRANSPORT selects Resend (the default) or SMTP. WhenAUTH_EMAIL_FROM or any of the selected transport's credentials are absent, the
broker starts without email delivery. The sign-in page and submissions return
503 with “Email delivery isn't configured”; no form is offered and no email is
claimed to have been sent. Previously issued links and codes keep their normal
expiration and single-use checks.
To enable email sign-in, configure the selected transport's credentials andAUTH_EMAIL_FROM, then restart. The sender must be verified and may be written
as Name <[email protected]>. Supplying only part of the configuration leaves
email delivery disabled. Fully supplied but invalid configuration is refused at boot.
| Transport | Variables | Notes |
|---|---|---|
resend |
RESEND_API_KEY |
A key with send access from https://resend.com/api-keys. The sending domain must be verified under Domains, which needs DNS records; an unverified domain fails at delivery, not at boot. |
smtp |
SMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD, and optionally SMTP_PORT, SMTP_TLS |
Any relay. SMTP_PORT defaults to 587. SMTP_TLS defaults to implicit on port 465 and starttls otherwise; none is refused in production, and a relay that does not advertise STARTTLS is refused rather than sent credentials in cleartext. |
qm doctor proves the Resend key is accepted, or that the SMTP relay is
reachable and answers. Neither proves deliverability — the first real sign-in
link does that.
Known trade-offs
The sign-in link carries its token in the URL fragment, which browsers never
put on the wire, so it reaches no access log, no proxy, and no Referer. The
confirmation page moves it from location.hash into the form and callshistory.replaceState, so it does not linger in the address bar or the history
entry either; the value is held in sessionStorage for the life of the tab so a
reload still works. That last step needs JavaScript — the page says so, and the
link can be re-requested if a mail gateway strips the fragment.
The per-mailbox send budget is keyed on the mailbox and the requesting client
address, so a stranger cannot exhaust a known user's budget and lock them out;
the per-address budget is what bounds a single source. Both are durable claims,
so they survive restarts, and both are keyed by an HMAC underAUTH_TOKEN_SECRET so another plugin holding the shared core signing secret
cannot compute — and pre-claim — a chosen mailbox's slots.
Remembered browsers
Verifying an email link creates a durable broker session in core's Postgres store.
The browser receives an HttpOnly, Secure, SameSite=Lax cookie scoped to the issuer
path (/idp behind the portal). Core stores only a hash of the random token; the
cookie also carries a broker-only MAC, so another plugin's core signing credential
cannot manufacture an email sign-in. Rotating AUTH_TOKEN_SECRET invalidates all
remembered cookies.
AUTH_SESSION_IDLE_S defaults to 30 days and AUTH_SESSION_ABSOLUTE_S defaults
to 90 days. Both are whole seconds; the idle limit must not exceed the absolute
limit, and the absolute limit cannot exceed 90 days. Reauthorization slides the
idle expiry without moving the original authentication time or absolute expiry.
The broker rechecks email eligibility before issuing and exchanging a code.
prompt=login and max_age=0 always require a fresh email. A positive max_age
compares against the original email authentication time, also returned asauth_time in the ID token. prompt=none returns login_required when silent
reauthentication is unavailable. Invalid prompt and max-age values fail closed.
The portal forwards only the broker cookie to /idp and preserves all broker
Set-Cookie headers. Ordinary POST /auth/logout clears both local cookies.POST /auth/logout?everywhere=1 additionally revokes all remembered browsers for
the authenticated email principal. The signed core endpointPOST /v1/auth/broker/sessions/revoke accepts { "email": "[email protected]" }
from that user's portal identity or an authorized administrator. These operations
prevent future silent reauthentication; already-issued stateless portal sessions
remain valid until their normal expiry.
When hosted by a portal with trusted OIDC and an explicit provider label configured,
the embedded broker receives
its trusted provider label and shows an alternate sign-in link throughout the
email flow. The link starts a fresh trusted login through the portal, without
submitting an email or linking the two identities. Standalone brokers omit it.
Overview (`plugins/onboarding/README.md`)
Onboarding plugin
Adds a memory-first onboarding skill: it connects the user's accounts, reads a light
snapshot of their real work, and proposes concrete automations — then persists the
profile in memory.
The core discovers plugin skills from plugins/*/skills by default and installs them
into the org skill catalog at startup. The onboarding skill is therefore visible in a
user's DM as /onboarding, and the core injects a pending-onboarding prompt when the
personal memory notebook does not contain a completion or dismissal marker for the
current version.
Source Of Truth
Onboarding state is readable memory, not a separate database table:
## Onboarding
- Onboarding: completed v2 on 2026-06-09.
- I write in direct, straightforward, concise sentences.completed v2 suppresses the pending prompt. dismissed v2 also suppresses it until
the onboarding version changes — bumping the version re-triggers onboarding so an
improved flow reaches users who finished an earlier one.
SOUL updates are optional and should only hold compact first-person operating
instructions.
Overview (`plugins/portal/README.md`)
Portal — the public SSO front door
The portal is the one publicly-reachable app in the stack. It replaces the per-surfacefly proxy tunnels (one per app, each on its own local port) with a single signed-in URL that
fronts every surface. It does provider-neutral OIDC sign-in (Google Workspace
is the email-first deployment default) and reverse-proxies — over Fly's private 6PN — to the
surfaces, which all stay private (no public [http_service] of their own):
| Path | → upstream | Notes |
|---|---|---|
/* (root) |
-web-ui |
Pi web UI SPA, root-mounted (/web-ui/* 308-redirects to root for old links) |
/admin/* |
-admin |
governance — admin access derived from the core (canAdminister) |
User deployments are never served on this authenticated origin; they use the dedicated apps domain.
Set PORTAL_APPS_DOMAIN to a child domain of the company portal, with matching
core DEPLOY_APPS_DOMAIN, gateway/session secrets, wildcard DNS and TLS. App
hostnames can point at the same portal ingress: portal forwards them to core's
deployment gateway before its own routes. Core authorizes each app request and
removes gateway cookies before forwarding to the app. Each app has a separate
browser origin; cross-origin browser writes are rejected. Keep the session cookie
domain within the company, never a parent shared with other companies.
For companies using the additional trusted OIDC entry, set coreDEPLOY_APPS_LOGIN_PATH=/auth/trusted/login so app sign-in uses that identity
provider. The default remains /auth/login.
It is a thin node:http server (native TS type-stripping), like the other
surfaces, and it does not import the core.
How a request flows
- Sign in —
GET /auth/loginstarts an OIDC Authorization-Code flow with PKCE(S256) +state+nonce, all sealed into a short-lived signedportal_oidc_tmpcookie. - Callback —
GET /auth/callbackexchanges the code over the TLS back-channel
(confidential client,client_secret_basic+ the PKCE verifier), then binds the id_token
signature and payload (nonce/aud/iss/sub/timestamps and, for Slack, theteam_id
workspace pin) against the configured HTTPS JWKS, then reads
the subject from userinfo. The verifiedsubis the core principal id. It mints a
signedportal_sessioncookie ({sub, org, auth, exp}, HMAC, 7-day sliding lifetime with a
30-day absolute maximum by default). - Proxy — every other path requires a valid session. The portal picks the upstream by the
exact first path segment, strips the prefix, and proxies to the private upstream,
synthesizing the surface cookie for compatibility and attaching a short-lived signed portal
identity. Surfaces pass that identity to core, which verifies it before any user-scoped action.
Before proxying, the portal asks core for the session subject's canonical principal
(GET /v1/principals/:id/canonical, cached for a minute). A sign-in that an org admin has
linked to another identity therefore acts as that person's canonical principal; seedocs/principal-links.md.
Additional trusted entry (PoC)
An optional OIDC entry route at /auth/trusted/login operates alongside the
existing /auth/login provider. Configure PORTAL_TRUSTED_OIDC as JSON withissuer, authEndpoint, tokenEndpoint, userinfoEndpoint, jwksUri, andclientId; supply its distinct PORTAL_TRUSTED_OIDC_CLIENT_SECRET separately.
Register the exact ${PORTAL_PUBLIC_URL}/auth/trusted/callback URL at that provider.
Endpoints must use HTTPS in production. This is an operator-configured trust
relationship: the additional provider must enforce the destination company's
admission policy before issuing tokens. Primary-provider email and workspace
restrictions do not apply to this separate route.
The route verifies OIDC signature, issuer, audience, nonce, PKCE and subject binding,
and consumes login state through core's durable replay store. Principals are scoped
to the issuer and subject, without automatic linking to email or Slack identities.
No administrator role is assigned by default. To provision administrators on this
entry path, set PORTAL_TRUSTED_OIDC_ADMIN=1 on portal and set core'sTRUSTED_OIDC_ADMIN_ISSUER to the exact trusted issuer. This requires a distinctPORTAL_IDENTITY_SECRET and durable replay storage. After verified provider login,
portal submits a short-lived, single-use, purpose-bound assertion; core creates a
normal durable organization-admin grant before portal issues the session. Primary
login and impersonation cannot request this promotion. Deactivated users stay blocked.
Existing sessions need a fresh trusted sign-in. Revoking a grant removes admin access
until the next successful trusted sign-in; disabling provisioning prevents future
grants but does not remove existing ones. Company login and Slack integration retain their
existing configuration. Deployment CLI secret wiring, account linking, and live
qualification are pending; this is not a released deployment feature.
When PORTAL_TRUSTED_OIDC_LABEL is explicitly set, successful trusted sign-in
makes the portal remember the configured issuer in a
host-only, HttpOnly browser preference for one year. Ordinary/auth/login requests then return to the trusted route, including after logout
or session expiry. Session renewal and logout also remember existing trusted
sessions. A successful primary or operator sign-in clears the preference. The
preference only selects a provider; it grants no identity or administrator access.
A new browser or cleared cookies still uses the primary provider by default.
Use /auth/login?provider=primary to explicitly select the primary provider; the
trusted sign-in error page offers this alternative.
Operator admin login without email
Run qm admin-login with the deployment's configuration and secrets to generate
a private admin login URL. Open it and confirm the displayed email. The URL
expires after five minutes and works once. With multiple email-basedADMIN_GRANTS administrators, use qm admin-login --email [email protected].
The command can also run with PORTAL_PUBLIC_URL, PORTAL_SESSION_SECRET, andADMIN_GRANTS in its environment, without a deployment config file.
GET /auth/admin-login only renders the confirmation page. Its script moves the
token out of the URL fragment into the form and clears the fragment. Nothing
signs in until the user submits that same-origin form. POST /auth/admin-login
verifies the token's purpose, portal origin, signature and lifetime, checks the
account's current admin status, and consumes its unique ID through core's
durable Postgres replay store. It then issues the ordinary portal session and
clears any old impersonation cookie. No account or role is created.
This is an operator capability: keep deployment secrets and generated URLs
private. It does not prove an email inbox, replace another user's login, or
depend on a hosting provider. Leave the built-in broker's sender and selected
email credentials unset to run without email; configure them later and restart
to enable ordinary email sign-in. Existing external OIDC login is unchanged.
Live integration checks require a running core, Postgres, admin, portal and
built-in auth broker with email unset. Set QM_ADMIN_LOGIN_ENV_FILE to a private
JSON file containing that deployment's environment and runnode --test test/integration/admin-login.test.ts from the repository root. Use a
disposable test deployment: the test redeems real links for its configured admin.
Security model (the parts that must be right)
- Identity comes from verified OIDC or an operator-issued admin link. The browser cannot assert an unsigned identity. The
upstream request is built from scratch — an allowlist of safe headers
(content-type/accept/user-agent/…) plus the one synthesized cookie. The client's
headers and cookies are never forwarded, so a browser can't smuggle a forgedadmin=/x-as-principalinto a 6PN-trusting upstream, and there's no CL/TE desync surface
(node:httpframes the piped body itself). - Routing decides once. The router rejects pathologically-encoded targets (
%2f,%5c,%2e%2e,\,//, NUL → 400), selects the upstream by exact segment, and derives the/admingate from that _selected_ key — never a secondstartsWiththat could disagree
(tier-escape guard). - Admin is derived from ONE source — the core's
admin_grants. The portal asks the admin
surface (GET /api/whoamiover 6PN, no signing secret), which asks the core
(GET /v1/admin/whoami→canAdminister). The portal caches the boolean ~60s and fails
closed (admin surface down ⇒ no Admin link on the landing,/admin403).canAdministeris
re-checked per action at the core — the live boundary. There is no portal/admin id list
anymore; the only place an admin is named is the core'sADMIN_GRANTS. - Non-admin users need no per-id config.
PORTAL_EXPECTED_TEAM_IDpins the workspace, so any
verified member is a valid user (WEB_UI_PRINCIPALSempty = any verified principal). - CSRF / open-redirect. Every non-GET requires a same-origin
Origin;returnTois reduced
to a same-origin path (rejects//evil,/\evil,https:/evil,%2f%2f/%5c). The one
exception is an origin listed inPORTAL_API_ALLOWED_ORIGINS, and only on the chat API (below). - Embedding the chat from another app.
PORTAL_API_ALLOWED_ORIGINSlists the exact
origins (comma-separated, barehttps://host[:port], no wildcards) that may call the web
surface's chat API from their own pages with the user's portal session:/me, sending a turn,
following, cancelling and withdrawing runs, listing and reading sessions and their approvals,
deciding an approval, uploading attachment blobs, and downloading files. Those routes answer CORS preflights
for listed origins and grant them credentialed reads and writes. Every other route keeps the
same-origin rule, and unlisted origins get nothing. The session cookie isSameSite=Lax, so a
browser only sends it on these calls when the embedding app is on the same site as the portal
(for exampleinternal.example.combesideqm.example.com). Listing an origin trusts every
page on it to act as any signed-in user who visits it, so list only first-party apps. - Secret hygiene. In production the portal refuses to boot unless
PORTAL_SESSION_SECRET,PORTAL_IDENTITY_SECRET, andOIDC_CLIENT_SECRETare set and distinct from core ingress auth.
Public and OIDC endpoints must use HTTPS. Session and temporary cookies use domain-separated keys.
Documented trade-offs & residual risks (v1)
- Surface isolation. The private surface hop carries a signed portal identity; core verifies
it independently, so a synthesized cookie alone confers no user authority. User deployments
stay on a dedicated apps hostname and are never proxied through the portal or admin origin. - Local logout.
POST /auth/logoutclears the portal cookies and returnsredirectTo; browsers follow it to/auth/signed-out, a terminal page that
never redirects to the provider. Sign-out is local to this portal: it does not
contact the OIDC provider or end the provider session, so choosing Sign in
may complete silently through an existing provider session. Sign out of the
provider directly to end it everywhere. Built-in broker and anonymous logout
retain their existing flows. Local-development logout also stops on the
signed-out page until the user chooses Sign in. - Stateless session limits. Clearing browser cookies does not revoke a copied
portal session beforeexp; the core'scanAdminister(re-read per request)
remains the live admin revocation path.
Playground mode
PORTAL_PLAYGROUND=1 turns the deployment into a public try-it instance: an
unauthenticated browser navigation (a GET that accepts HTML) mints an anonymous
principal (playground-<random>), seals it into the ordinary portal_session
cookie, and continues — so each visitor's sessions, files, memory, and sandbox are
pinned to their browser through the same scoping that isolates real teammates.
Non-HTML requests without a session still get 401, so the SPA's API calls ride
the cookie from the first page load and bare curl never mints.
What playground mode does not change: /auth/login still runs the full OIDC
flow (that's how the one admin signs in — production still demands the usual OIDC
config), /admin refuses anonymous sessions outright, and admin identity remains
the core's ADMIN_GRANTS. Signing out of an anonymous session just clears the
cookie; the next visit starts a fresh playground identity.
Minting is rate-limited per client address through the core's Postgres-backed
single-use claim store (the same one the sign-in broker uses), so restarts and
blue-green deploys can't reset it; if the core can't record the claim the portal
fails closed and answers 429. PORTAL_PLAYGROUND_MINTS_PER_IP (default 30, at
most 64 — the core grants at most 64 claim slots per request) perPORTAL_PLAYGROUND_MINT_WINDOW_S (default 3600, at most 86400 — the core's
claim horizon); the portal refuses to boot outside those ranges rather than
silently serving 429 to everyone. IPv6 clients are bucketed per /64, not per
address, so a visitor with a routed prefix can't rotate through fresh budgets.
The client address comes from clientIpOf — on Fly that's fly-client-ip;
elsewhere set PORTAL_XFF_TRUSTED_HOPS when a reverse proxy fronts the portal,
or every visitor (and every crawler that accepts HTML) shares the socket
address's one bucket.
Because playground authority must never leave this origin, the portal refuses
to boot with PORTAL_PLAYGROUND alongside PORTAL_COOKIE_DOMAIN, an apps
domain (PORTAL_APPS_DOMAIN / DEPLOY_APPS_DOMAIN), or an explicitPORTAL_DEPLOYMENTS_ENABLED=1 — a domain-wide cookie or the deployment proxy
would hand anonymous sessions to surfaces that never see the anon flag. The
deployment proxy (/d/<app>/), on by default for signed-in portals, turns
itself off under the playground, and anonymous sessions are refused it at
request time too. Outside the playground, PORTAL_APPS_DOMAIN defaults toDEPLOY_APPS_DOMAIN, and PORTAL_COOKIE_DOMAIN to the portal host itself when
the apps domain sits directly under it (apps.), so one core-side
variable configures both processes. Any other layout needs an explicitPORTAL_COOKIE_DOMAIN — deriving a shared parent by guesswork risks landing on
a public suffix browsers refuse. Anonymous sessions are also refused the /connect/* and/drop/* flows, so a visitor can't attach real OAuth tokens or dropped secrets
to a throwaway principal that a cleared cookie orphans.
The anon flag lives only in the portal's session cookie — it does not cross
the portal identity boundary. To the core, a playground visitor is an ordinary
internal principal of the deployment's org: they can run turns, use their
sandbox, create crons, and reach anything granted or published at org: scope,
including org-granted credentials. That is the design — visitors are members of
the playground org — so a playground must be its own deployment with nothing
sensitive at org scope: no org-wide credential grants, no real connector
credentials, no company data. A cleared cookie mints a fresh principal, so pair
this with the core's real brakes: BUDGET_USD_PER_WINDOW,ORG_BUDGET_USD_PER_WINDOW, RATE_LIMIT_PER_WINDOW, and a single pinned model
via the admin base-model / webui-models resources. Nothing
garbage-collects an abandoned visitor's scope yet.
Env
Non-secret ([env]): PORT (8097 local / 8080 image), PORTAL_PUBLIC_URL, CORE_API_URL,CORE_ORG_ID, WEB_UI_UPSTREAM, ADMIN_UPSTREAM,OIDC_AUTH_ENDPOINT / OIDC_TOKEN_ENDPOINT / OIDC_USERINFO_ENDPOINT / OIDC_ISSUER /OIDC_JWKS_URI / OIDC_SCOPES / OIDC_CLIENT_ID, PORTAL_EXPECTED_TEAM_ID,PORTAL_SESSION_TTL_S, PORTAL_SESSION_MAX_TTL_S. PORTAL_SESSION_MAX_TTL_S caps a session's total life from authentication; it defaults to the larger of 30 days and PORTAL_SESSION_TTL_S, and boot fails if it is set below the TTL.
There is no PORTAL_ADMIN_PRINCIPALS — admin
access is derived from the core (see the security model above).PORTAL_API_ALLOWED_ORIGINS (default empty) opens the chat API to other first-party origins;
see the security model. Boot fails on an entry that is not a bare https origin (plain http only for localhost).
For local development only, PORTAL_LOCAL_AUTH_BYPASS=1 mints a local session asPORTAL_DEV_PRINCIPAL without contacting OIDC. The portal refuses this in production
and only accepts it when PORTAL_PUBLIC_URL is loopback.
Identity: OIDC_PRINCIPAL_CLAIM — email (default; the org-canonical id: the
verified work email, lowercased; sign-in fails unless the IdP marks the email verified) or sub
(the IdP's opaque subject, e.g. the Slack U… id — only for deployments still keyed on Slack ids).OIDC_ALLOWED_EMAIL_DOMAIN — with email, additionally reject any account outside this domain
(checked against the email suffix and Google's hd claim).
An address those rules reject still signs in when an org admin has invited it as an
external user: the callback asks core over the signed core client
(GET /v1/auth/broker/email-allowed) and accepts an active invitation, hd notwithstanding.
An address the env rules permit never triggers the lookup; core unreachable means not allowed.
Deployments keyed on sub never consult it, so external users cannot sign in there.
Google Workspace SSO with the email principal
The OIDC client is generic, so Google is pure config. One-time setup:
- In a Google Cloud project under the org's Workspace: APIs & Services → Credentials →
Create OAuth client ID (type "Web application"), authorized redirect URIhttps:///auth/callback. On the consent screen choose Internal (members
of the Workspace only). - Point the portal at Google:(unsettext
OIDC_AUTH_ENDPOINT=https://accounts.google.com/o/oauth2/v2/auth OIDC_TOKEN_ENDPOINT=https://oauth2.googleapis.com/token OIDC_USERINFO_ENDPOINT=https://openidconnect.googleapis.com/v1/userinfo OIDC_ISSUER=https://accounts.google.com OIDC_JWKS_URI=https://www.googleapis.com/oauth2/v3/certs OIDC_SCOPES="openid email profile" OIDC_CLIENT_ID=<client id> OIDC_ALLOWED_EMAIL_DOMAIN=<org domain, e.g. example.com>PORTAL_EXPECTED_TEAM_ID— it's a Slack-OIDC concept). - The Slack plugin keys on emails by default too (bot scope
users:read.email, already in the
repo manifest — reinstall the app if it predates the scope), so Slack turns and web logins
resolve to the same principal. - A deployment whose state predates email keying (Slack U… ids in the DB) must be re-keyed
once:scripts/migrate-principals-to-email.mjs(dry-run by default; see its header).
Update the core'sADMIN_GRANTSto emails in the same change.
Secrets: OIDC_CLIENT_SECRET, PORTAL_SESSION_SECRET, and PORTAL_IDENTITY_SECRET; each must be
distinct from CORE_SIGNING_SECRET.
Run / test
npm start
npm run typecheck
npm testSee deploy/README.md → Portal for the public bring-up (IPs + cert, DNS, the Slack OIDC
app, and secrets) and cli/templates/fly/portal.toml.
With embedded auth, trusted OIDC, and PORTAL_TRUSTED_OIDC_LABEL explicitly set,
all email sign-in pages offer a link
to the trusted provider, including the email-unavailable and link-sent pages.
The label is the provider display name. Without it, the email page and primary
login routing retain their existing behavior. Switching keeps the destination
from the signed email login transaction.
The link is available without a remembered browser preference.
The proxy also signs the original authenticated subject as authenticatedAs. Core verifies that it still belongs to the canonical person, rejecting stale claims after unlinking. Trusted-entry failures offer retry of that provider without an alternate email sign-in link; invitation authentication remains unchanged.
The portal always issues portal_session_x, a SameSite=None twin of the
session cookie that lets opted-in apps be framed by named origins. The cores
serving the portal must include f75cfd1c (#1503), which stripsportal_session_x before forwarding requests to deployed apps. Once this portal
is deployed, do not roll core back below f75cfd1c on its own: an older core
forwards that cookie's session bearer to app code, and cookies already issued
stay in browsers until they expire (PORTAL_SESSION_TTL_S, 7 days by default).
Overview (`plugins/web-ui/README.md`)
Web UI plugin
An end-user web surface with a custom chat shell, connected to the
platform core. It still uses Pi's Agent state machine and selected Pi web utilities
for markdown, attachment loading, and model metadata, but the visible conversation UI is
owned by this plugin. Two processes:
- A zero-dep
node:httpserver (server/index.ts) — holds the signed-in principal
in anHttpOnlycookie, injects it as the turn's actor, and proxies a small set of/api/*routes to the core (chat turns, unified history, and webhook management —
see below). It never imports the core and never sends a model/API key to the browser. - A Vite-bundled front-end (
src/) — a custom Lit shell + transcript + composer.
Pi'sAgentLLM-call boundary (streamFn) is swapped for a bridge to the core.
ItPOSTs the turn (POST /v1/turns?async=1), then watches the in-flight reply over
SSE (GET /api/runs/:id/events, relayed from the core'spartial) so tokens are
pushed as they arrive instead of discovered on the next poll tick. It falls back to
pollingGET /api/runs/:idwhere SSE can't be established (old/proxy-hostile
environments). The real agent loop (model + the three primitives + memory + audit) runs
server-side in the core's sandbox; the browser is a thin chat client.
Behind the portal. When fronted by plugins/portal (the public SSO front door), this SPA
is served at the portal root — the default WEB_UI_BASE=/ build is the one the portal fronts
(the old /web-ui/ prefix is gone; the portal 308-redirects /web-ui/* to root for stale
links). The front-end joins its base via import.meta.env.BASE_URL (the withBase() helper incore-bridge.ts), so direct/standalone access and npm run dev behave identically.
Core binds run reads and signals to the portal-verified actor;
no run bearer is exposed to browser code or placed in a URL. The active-run resume index
(/api/runs/active) is per-process best-effort with a durable core fallback for personal threads.
npm install --prefix ../admin
npm install
npm run build
npm run serve
npm startDev (HMR): run npm run serve in one terminal and npm run dev in another — Vite serves
the front-end on :5173 and proxies /signin, /me, /api/* to the node server.
Env (see .env.example): CORE_API_URL (default http://localhost:8080),CORE_ORG_ID (default acme), PORT (default 8096), WEB_UI_PUBLIC_URL,WEB_UI_PRINCIPALS (csv allowlist; empty = any id, dev only),
and CORE_SIGNING_SECRET (same value as the core when source-auth is enabled).
Suggested activities
Suggested activity generation is on by default when the configured harness supports
it. Set SUGGESTED_ACTIVITIES_ENABLED=false on core to disable generation. Optionally
set WEB_UI_SUGGESTED_ACTIVITIES on web to a JSON array for fixed fallback starters;
unset it as well to hide suggestions entirely. No deployment-specific activity content
is bundled into the public application.
[
{
"id": "weekly-brief",
"title": "Wake up to a fresh briefing",
"prompt": "Let's set up a recurring briefing on the topics I follow.",
"icon": "schedule"
},
{
"id": "project-app",
"title": "Build a home for my projects",
"prompt": "Let's build a private app to keep track of my projects.",
"icon": "app"
}
]The first three entries appear above the empty personal-chat composer with colored
icons, without a heading or expansion link. Selecting an entry fills and focuses
an editable draft; it never submits a turn. Suggestions fade and collapse while a draft
or attachment is present and do not appear in existing chats, shared contexts,
or compact pane views. Collapsed suggestions are inert and hidden from assistive
technology; reduced-motion preferences disable the transition. Dark mode uses
subdued blue-gray suggestion text. Drafts use the normal persistence path.
Each entry requires a unique lowercase alphanumeric/hyphen id (up to 64
characters), title (up to 65 characters), prompt (up to 1,200 characters), andicon (one emoji or yc for the orange YC mark; legacy schedule, app, deck,people, calendar, and book values also render as emoji). Configuration
accepts up to 12 entries and 20,000 characters. Invalid configuration fails startup
without printing its contents. Restart the web service after changing it.
The authenticated /me response supplies the configured fallback and whether generation is enabled.
Fixed starters are organization-wide; keep them free of personal activity or credentials.
When enabled, opening a new personal chat enrolls the user in an ordinary personal
cron named Refresh my suggested activities. Its first run starts immediately;
subsequent runs happen around 2am in the browser's timezone, with the minute
staggered by user. The cron runner uses the normal owner-scoped session, runtime
selection, memory, history, tools, and authorized data access. It has no delivery
destination. The standing task in src/suggestions/activities.ts asks it to research
relevant context, avoid mutations or notifications, and return three validated
activity objects. Draft prompts use a natural, collaborative voice, such as "Let's...",
with relevant facts and uncertainties as neutral context, without attributing knowledge,
feelings, or beliefs to the user. They preserve explicit user
preferences and scope while leaving the approach to the responding agent. It does not
create a separate reduced-context model call.
The UI reads the latest valid result from the cron's completed personal session,
including responses larger than the truncated fire-log preview. It displays the
previous result while a refresh runs and briefly polls for the first/new result;
opening another chat does not normally invoke a model. Failed initial generations
can retry after five minutes. Existing cron queueing, run persistence, authorization,
fire history, and failure handling apply.
Cadence is reevaluated hourly. Ten or more user messages in sampled recent private
conversations within 24 hours increases refreshes to every four hours; otherwise it
returns to nightly. Accounts with no observed conversation activity or suggestion
visits for 30 days are paused until activity returns. The owner can pause, delete,
or edit the cron; custom task text and schedules are preserved. The global disable
flag pauses managed jobs. Background work must also be enabled.
Set SUGGESTED_ACTIVITIES_CONTEXT on core for rollout guidance (up to 8,000
characters). Describe the organization’s workflows and available tools there;
optionally provide fallback starters on web. Guidance updates propagate to
unmodified managed tasks. Public QM has no organization-specific suggestion context
by default. Suggestions are private to their owner; generated sessions use the
same scoped access controls as other personal work.
On a phone
Below 860px the same build behaves like an app rather than a shrunken desktop:
- Drawer, not rail. The sidebar slides over the content from a floating menu button (or an
edge swipe); a leftward swipe or a tap on the scrim closes it. Every top bar reserves the
button's column so nothing renders under it. - Bottom sheets. Popover menus — composer settings, a session's ⋯, the user menu, the
per-session tools — render as sheets with a backdrop; tap outside or swipe down to dismiss. - Compact composer. Attach · input · settings · send on one row; model, harness, effort, and
Fast live in the settings sheet. Inputs are 16px so iOS never zooms on focus, and the layout
tracks the visual viewport so the composer stays above the on-screen keyboard. - Touch targets. Message actions, file chips, selects, drawer rows, approvals, and back
links are ≥44px; hover tooltips are suppressed on hoverless devices. - Split canvas stays on the desk. A phone never mounts the split layout and leaves the
persisted desktop layout untouched. - Installable.
manifest.webmanifest(named after the org's brand label), home-screen
icons, Apple meta, and theme colors — "Add to Home Screen" opens standalone at/.
The phone-class breakpoint is one constant (src/viewport.ts, PHONE_MAX_WIDTH), shared by
the CSS media queries, the composer, and the split canvas.
What you get
- Custom chat UI — a first-party conversation surface with a left history rail, centered
transcript, bottom composer, inline model selector (the models core reports as
serviceable for the approved harnesses),
explicit effort selector (low|medium|high|xhigh|max|ultracode|auto), Fast mode
toggle, attachments, streaming partials, and a theme picker. Settings → Theme takes
light, dark, or system, or an imported palette: drop in an iTerm2.itermcolorspreset or
a VS Code color theme.jsonand the app repaints from it (background, text, sidebar,
buttons, links, text selection, status badges, and code highlighting, mapped from the
terminal's ANSI colours or the theme's token scopes). The palette is kept per browser
alongside the light/dark choice.
The UI drives Pi'sAgentwith a customstreamFn(src/core-bridge.ts) instead of
mounting Pi's stockAgentInterface. - Slash-command skill picker — type
/at the start of the composer to browse the
skills available to you (B6): icon · name · description · scope, with the
typed letters emboldened. Arrow/Tab/Enter to choose (it inserts/<name>), Esc/click-out to
dismiss. The list is the signed-in principal's visible skills — the same set the agent gets
materialized into a DM turn — fetched once per session via the server's/api/skillsproxy
(→ coreGET /v1/skills?principalId=, resolved byapp.listVisibleSkills). The skill body
never reaches the browser; only name/description/scope do. - Functional selectors — the chosen model + effort + Fast mode ride along on each turn
(POST /v1/turnsmodel/thinkingLevel/fastMode); the Pi harness applies them
server-side, bypassing Pi's stale thinking-level clamp for newer effort values and falling
back to its default on any unknown value. Attachments are forwarded asIncomingAttachments
and materialized into the agent's sandbox inbox (same path as Slack file shares). - Unified history — every session you participated in (DMs, channels, web), via
GET /v1/sessions?principalId=. The same person sees the same history here and in Slack
(surface-independence, spec B6). - Continue web DMs — threads originated here (
threadRef=web:…) are continuable. - Contexts (the Contexts sidebar view) — one card per scope the signed-in person can
talk to the agent in: theirpersonal:scope plus every channel/group context (each context
is a separate workspace — own files, own memory).GET /api/contexts→ coreGET /v1/contexts?principalId=(personal + channels the pre-pushed directory membership
places the principal in + any shared scope their own sessions place them in — assembled
entirely from core-local data, never a live Slack call). Opening a context shows its conversations and
a New chat that starts a web conversation inside that shared workspace: the turn carriesscopeId(+channelNamefor the session label), the server maps it ontoconversation {kind: channel|group, channelRef}, and core re-authorizes membership
(pre-pushed channel membership, or prior participation in the scope's sessions — deliberately
stricter than the cron-create "any internal may post to a public channel" rule: a post is
visible to the channel, mounting its workspace from the web is not). The web-ui never vouches for
membership — it only shapes the claim. Such chats are continuable like any web thread and show
a floating pill naming the shared context; replies stay on the web (nothing is posted to
Slack). - Deep links — the address bar always identifies the open conversation (
?session=)
or view (?view=…), kept in sync viareplaceState; each conversation's ⋯ menu has a
Copy link. Opening a link while signed out keeps the query string through sign-in, so
shared links land in the right conversation (subject to the recipient's own access). - Read Slack & group/channel sessions — they render read-only (transcript only, no
composer). One-way projection: a web reply would be invisible to Slack participants, so
contributing to those Slack threads from the web isn't allowed yet (spec B6) — start a fresh
web chat in the same context from the Contexts page instead. In the sidebar these read-only rows sit
visibly recessed (a shade darker/dimmer) and carry the Slack mark plus the channel they live in
(for example,#engineering) — the channel name is captured from the surface onto the session record
(Session.channelName, plumbedconversation.channelName→getOrCreateByThread) and surfaced
viaGET /v1/sessions, so you recognize where a conversation happened at a glance. - Webhook management (the Webhooks sidebar view) — register, list, and disable your
own incoming webhooks (spec §7). Each registration is created withowner = createdBy = you
in yourpersonal:<you>scope (identity comes from the cookie, never the request body —
the same trust model as/api/turn). The server proxies three routes:POST /api/webhooks→ corePOST /v1/webhooks; relays core's response — the webhook, the
absolute public ingress URL (core builds it from its public base — the portal in prod),
and the signing secret once (auto-generated if you leave it blank; never shown again).GET /api/webhooks→ coreGET /v1/webhooks, then filtered toowner === you(core's
source-auth list is operator-wide; secrets are already elided by the core).POST /api/webhooks/:id/disable→ ownership is verified here first (core's operator
disable has no ownership check), mirroring the run-ownership gate, then proxied.
The inbound ingress (POST /v1/webhooks/incoming/:id) is served by the core receiver,
reached in prod through the portal's one unauthenticated passthrough (the core is not
publicly exposed); senders sign with their own per-webhook secret, which is the auth on that
path. Dev single-host posts to the core directly.
- Cron management (the Crons sidebar view) — create, list, run-now, enable/disable, and
delete your own scheduled tasks (spec §7), same trust model as webhooks: created withowner = createdBy = youin yourpersonal:<you>scope (identity from the cookie, never the
body), list filtered toowner === you, and every per-cron route ownership-gated here first
(core's source-auth routes are operator-wide). A cron is either a task (a prompt the agent
re-runs at each fire) or a message (literal text relayed as-is — requires a destination,
since a relay with nowhere to deliver is a no-op), on aneveryMsinterval and/or a one-timefirstFireAt(which may not be in the past). The server enforces a 1-minute interval floor; the
scheduler itself runs in the core. - Files / Connectors / Deploys (sidebar views — management lives here in one place):
- Files — the doc store (spec §19
artifacts(kind=file); §3 "files & sharing =
Google Docs"): oneGET /api/filescall (→ coreGET /v1/files?viewer=) lists files you
created/uploaded (owned) + files shared with you, recency-sorted, with Open/Download
(GET /api/files/:id/content, streamed binary). Image files (image/*) show an inline
thumbnail rendered straight from that same/contentstream. Backed by a durable, owner-scoped registry —
NOT a transcript scan. Delivered/uploaded files are owned at the initiator's personal scope so
they surface here, and auto-shared with the conversation (ADR-0003 D2/D4): a public-channel file
gets anorg:read grant so every member sees it under "Shared with you"; private-channel
per-member grants are sequencing-gated (off until enabled), so those stay owner-only until then. - Connectors — per-provider OAuth status with Connect / Reconnect / Disconnect. The server
proxiesGET /api/connectors→ core/v1/connectors/oauth/status,POST /api/connectors/:p/start
→ core/v1/connectors/oauth/:p/start(redirect URI = this surface's/connectors/oauth/:p/callback), andPOST /api/connectors/revoke→ core/v1/connectors/oauth/revoke. The callback exchanges the code server-side (tokens never reach
the browser) and bounces back into the SPA via a base-relative redirect. - Deploys —
GET /api/deployments→ core/v1/deployments, grouped into manageable,
shared, and archived views. Detail and restore routes expose authorized metadata and bring an
archived version back online; running apps open through the surface's signed deployment proxy.
- Files — the doc store (spec §19
Notes
- No browser-side model keys. The real agent loop (model +
the three primitives + memory + audit) runs server-side in the core's sandbox. The model
picker only expresses a preference the core honors within its policy floor — keys, egress,
and tools stay server-side. - Pi's client-side artifacts / JavaScript REPL are not enabled (tools run server-side).
- The custom transcript renders from Pi
Agentlifecycle events andwaitForIdle(), so
in-place streaming mutations are reflected without depending on Pi's stock chat renderer.
This is a surface plugin: it carries its own front-end deps (Vite, lit, pi-web-ui) and
runs as a separate process. The zero-runtime-dep core is untouched.
Chat connection chips
Chat authorization links share the connector service logos. Composio links use recognized
service names in their Markdown labels; unknown or ambiguous names keep a generic icon.
The destination URL and authorization behavior do not depend on the inferred logo.
Gmail, Google Calendar, Google Drive, and Google Sheets artwork comes from
Simple Icons v16.0.0
(CC0) and is bundled locally with the existing connector SVG artwork.
Cohort welcome and app picker
Set WEB_UI_WELCOME_COHORT=F26 on the web surface to show the cohort welcome in a new user's empty chat. It replaces the automatic first agent turn for that deployment; ordinary chat starts when the user sends a message. The greeting uses the signed-in display name. The welcome remains above the messages in the earliest personal web conversation, including when reopened. It is selected from persisted session creation times. The champagne and soft flutter sequence replays on refresh only before the first message and respects reduced motion.
The picker reads Composio's live catalog in usage order, omits apps that need no authorization, and searches the complete paginated catalog. Known services use local logos; remaining catalog logos use Composio's logo host. Selecting an app submits to the authenticated web surface and opens the provider's authorization link directly. Consent remains on the provider page. Slack is excluded from this picker. A dedicated Connect Slack card in onboarding and Settings authorizes the signed-in person’s Slack tools through Composio and links their verified Slack workspace identity to their existing web account. Installing the company bot remains a separate administrator action.
The core bridge accepts a verified portal identity and uses either that person's own COMPOSIO_API_KEY keychain entry or an enabled org service credential granted to them. Secrets never enter the browser. The agent skill reads /v1/composio/identity to use the same organization/person identity as the picker. A company project key retains Composio's existing project-wide access boundary; the identity selects accounts and does not isolate them from other holders of that key.
Local connection-return preview
Open http://localhost:8138/?connectionDemo=1 on the local dev instance. This loopback-only UI mode replaces app authorization with a provider simulation offering approval, cancellation, and failure. It makes a full navigation round trip with a callback URL, attempt nonce, status, and connected-account ID. The simulated verifier checks its own record rather than trusting status=success in the URL.
Session storage retains the account-scoped attempt for twenty minutes, picker query, expanded state, scroll position, and simulated connections. Returning skips the welcome animation, verifies the simulated result, clears callback parameters, and restores the picker. Reset clears the preview's simulated connections. No provider authorization, tokens, or actual connected accounts are changed by this mode.
Real authorization supplies a callback URL on the configured public origin and returns to the same conversation. A twenty-minute, user-bound session-storage attempt retains the account ID, originating widget, search, expanded state, and scroll position. The server lists only the authenticated actor’s active connected accounts; the browser verifies the expected account before showing success and removes callback parameters. Connected apps are refreshed on page load and window focus. Returning skips the welcome animation. Reply widgets become available after the reply is persisted. The loopback preview remains a separate simulation and does not connect real accounts.
The welcome uses the organization's configured branding orgName, falling back to “your company” when it is unavailable.
Setup widgets in agent replies
In web chat, an assistant reply can include ::connect-apps{} as a standalone paragraph to render the reusable app picker. The separate ::add-to-slack{} directive renders the Slack setup action for administrators; include both to show both. It omits the welcome and animation and uses the signed-in viewer’s authorization routes. The Composio skill teaches this response for requests to connect apps or reopen setup. Code blocks, quotations, and inline examples remain ordinary text. The directive persists in the transcript and renders again when reopened. Connected-account status retains the same limitations as the onboarding picker and local return-flow preview.
Optional product analytics
Set POSTHOG_API_KEY to a PostHog project ingestion token to enable browser
analytics. POSTHOG_HOST defaults to https://us.i.posthog.com and must be an
HTTPS origin. The authenticated /me response supplies this public configuration;
the portal serves the same web application, so it needs no separate SDK.
Events are explicit pageviews by navigation view, accepted message_sent events,
and session_started for the first user message in a chat. Company grouping usesCORE_ORG_ID; user identities combine company and authenticated principal. Browser
analytics is disabled during impersonation. Autocapture, replay, exception capture,
performance capture and feature flags are disabled. Event properties exclude chat
content, URLs, query strings, titles and referrers. Delivery is best effort.
Set the same variables on core to capture app_published after a successful new
application or version deployment. Publications use the application's creator and
company, matching browser identity. No key means no analytics requests.
Core also captures response_completed and response_failed when a human turn
reaches its final run state, including when a separate worker executes it.completion_boundary=run means processing finished, not confirmed delivery to the
user. Successful silent or reaction-only results count as completed processing.result_status distinguishes those results; surface identifies web or Slack.
Automation, automatic openers, impersonated turns, stopped turns, refusals, queued
results and pending approvals do not emit outcomes. Retries emit only at the final
run state. Stable insert IDs support deduplication. These best-effort events are
not a complete reliability ledger and contain no response text or raw errors.
Optional browser error reporting
Set SENTRY_BROWSER_DSN on the web server to enable browser error reporting.
Use a public HTTPS DSN without a secret key, such ashttps://[email protected]/1. Backend SENTRY_DSN is never exposed or
used as a browser fallback. The authenticated /me response supplies the public
DSN and an optional SENTRY_RELEASE (or GIT_SHA). The server adds only the DSN
origin to the web application's connection policy.
Reporting starts after authentication and stops on sign-out or an authentication
failure. It is disabled during impersonation and when the browser DSN is unset.
It collects uncaught errors, unhandled promise rejections, and errors the client
catches and handles (tagged with an error_code naming the catch site and grouped
by it). Events carry the full error message, cause chain, stack, release, org and
signed-in user. Delivery is best effort.
Browser performance timing
Set SENTRY_BROWSER_TRACES_SAMPLE_RATE (0 to 1, default 0) alongside SENTRY_BROWSER_DSN to sample
browser timings; 0.1 is a reasonable start. Each timing is sampled independently at that rate
and carries its own random trace id. A page reports one pageload transaction (time to first
byte, DOM content loaded, load, first and largest contentful paint, and a page attribute for
the application view) and one http.client transaction per same-origin request
made through the web client's shared fetch helper, measured to response headers and named by a
fixed /api/<resource> allowlist (GET /api/sessions/*) with the HTTP status. At most 200
timings are sent per page. The full page/request URL, including query strings, is retained as a
span attribute. Transactions, child spans, descriptions, attributes, and any attached request or
user context are sent without scrubbing. Timings stop with error reporting on sign-out,
authentication failure, and impersonation.
Personal AI accounts
Open Settings → AI access, or use the account label beside the model picker.
Choose Company, Claude, or ChatGPT / Codex. Sign in with your subscription
or use the secondary API-key option; connecting automatically selects that account. Company access switches back without disconnecting
personal credentials. The choice is durable per person and applies to their human
chat turns on the web and in Slack; background tasks retain company access.
A submitted turn keeps its account choice, so switching affects new turns. Personal
access failures do not retry on company credentials. Messages using a different
account queue separately instead of steering an existing run; an explicit steer
across accounts is refused. Organizations that already require individual accounts
continue to require them.
The standalone ::link-slack-account{} directive offers personal Slack account linking in a web reply. It shows the account card or linked status, requires the company bot to be installed first, and does not include the app picker. ::add-to-slack{} remains the company installation trigger.
Overview (`src/slack/README.md`)
Slack surface (Socket Mode)
The Slack surface, run in-process with the agent core: core boots it when
Slack tokens are present in its env and hands it a direct client into core's
services. Socket Mode means no public URL, ingress, domain, or TLS — it opens
an outbound WebSocket to Slack, so you can run it from a laptop or any box with
internet.
Slack ⇄ (WebSocket) slack surface (in core) ── direct calls ──▶ core services (Pi)1. Create the Slack app (one paste)
- Go to https://api.slack.com/apps → Create New App → From a manifest.
- Pick your workspace, paste
manifest.json, create. - Install to Workspace (OAuth & Permissions → Install). Copy the
Bot User OAuth Token →xoxb-…→SLACK_BOT_TOKEN. - Basic Information → App-Level Tokens → Generate Token and Scopes: add the
connections:writescope, generate. Copyxapp-…→SLACK_APP_TOKEN.
(Socket Mode is already enabled by the manifest. No request URLs to configure.)
Slack Agents (top-bar pin)
The manifest enables Slack's Agents & AI Apps
feature (agent_view) so users can pin the app to the Slack top bar and open it anywhere.
Conversations in the resulting split pane are ordinary DM thread messages and flow through
the normal DM machinery unchanged. Thread titles, context passing and suggested prompts
are not implemented. Activity cards are experimental and off by default.
Two caveats:
- Paid plan required. Slack only serves the AI-apps experience on paid plans (the
Developer Program sandbox also works). - Existing installs must update the manifest and reinstall to pick up the
assistant:writescope and theassistant_thread_startedandassistant_thread_context_changedevents (Slack requires both to enable the feature;
the plugin acknowledges them as no-ops). Installs that don't update keep working
exactly as before.
Optional activity cards
In Admin → Customize → Feature flags, enable Slack loading indicator (experimental)
for selected people or conversation scopes. It is off by default. The Slack app must
support task_card
blocks and have chat:write.
After QM commits to answering, one card is posted in the existing reply thread and
updated in place: Working, Waiting to resume, Background work, Monitoring,
or No active work. This describes recorded activity, not success, failure or delivery.
Quiet ambient turns and unthreaded DMs do not create cards. Cards are visible to everyone
in their thread; own status cards and edits are excluded from conversation history.
Waiting cards survive turns and restarts. Only this conversation's registered background
jobs and enabled, unexpired process monitors count, not recurring crons or agent prose.
Unwatched exits become visible when checked, reconciled during sandbox setup, or expired.
Commands and monitor instructions are never rendered. After more than five minutes,
including waits, a subtle Follow via QM Web link opens the exact web conversation
with normal authorization. Without a configured web URL, the link is omitted.
A minute sweep refreshes activity and retries transient failures. Durable receipts and
per-thread leases preserve card identity across workers; expired run leases are shown as
waiting, not working. Slack has no conditional writes: late updates are repaired, and an
ambiguous initial post can duplicate if Slack does not deduplicate client_msg_id.
An outage can leave a stale card until recovery. Status calls have a separate five-second
timeout and never delay reply delivery.
Once no activity remains, the card settles without a spinner and is no longer tracked.
Later work creates a fresh card. Disabling the flag settles existing cards without
stopping work or deleting messages. Native session status and Stop events are not used.
Running locally alongside another developer? Each dev needs their OWN app.
See Local dev with multiple developers
below — set the manifestname/display_nametoagent-<yourname>before
creating, so you don't share a bot identity.
2. Run
One process — core boots the Slack surface itself when the tokens are in its env:
cd ~/Programming/qm
nvm use
HARNESS=pi ORG_ID=acme ANTHROPIC_API_KEY=… \
SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… \
npm start(OPENAI_API_KEY or OPENROUTER_API_KEY works in place of ANTHROPIC_API_KEY; the base
model follows whichever provider you configure.)
(Or put the tokens in the repo-root .env — npm start loads it vianode --env-file-if-exists.) It logs connected as @agent … when live; without
the tokens, core simply runs without Slack.
3. Use it
- DM the bot anything → it replies in the DM (one continuous session per DM). While it's
working on your reply you'll see a ⚙ Working… status message; once the agent starts
producing text, the reply streams in place by editing that same message — so the wait is
never blank and there's never a duplicate post. - @mention it in a channel → it replies in a thread (one session per thread). After
a real Slack mention in that thread, just keep talking in the thread — no need to
re-@mention. The bot then listens
to every reply and decides whether to chime in like a thoughtful coworker (a "turn
detection" step in the core): it answers a question or a request aimed at it, but stays out
of a back-and-forth between other people. When it decides to stay quiet it posts nothing at
all (no spinner, no "I'll stay out of it"). An explicit @mention always gets a reply, and a
new top-level message still needs an @mention to start a fresh thread. - Share a file with it (DM or @mention with an attachment) → it downloads the file
and the agent can read/process it; ask it to send a file and it uploads the produced
file back into the thread. Bytes travel out-of-band: the plugin streams them to the
core's blob endpoint (POST /v1/blobs) and the turn carries only a lightweightblobId,
so files up to ~1 GB (Slack's own per-file limit), up to 10 per message, ride the same
path — no more squeezing base64 into the 1 MB turn body. Outbound files are fetched back
from the core (GET /v1/blobs/:id) and uploaded viafiles.uploadV2. Only Slack-hosted
files are ingested — externally-hosted ("remote") files are refused, since fetching an
off-Slack URL from the plugin host would be an SSRF / token-leak vector. - Replies render natively. The agent emits standard Markdown; the plugin translates it
to Slack "mrkdwn" before posting (toSlackMrkdwninlib.ts) so**bold**,[links](url),# headers, and- bulletsshow as Slack styling instead of raw markup. - The agent can react with emoji — a Slack-only feature with two paths.
For explicit reaction requests inside a normal assistant turn, the plugin teaches the agent the
reply convention via a surface instruction in the turn'sgatewayContext(REACTION_INSTRUCTION):
include a directive like[[react: eyes white_check_mark]]anywhere in the reply. The reply can be
just the directive with no other text — the agent then reacts silently, the way a human drops a 👍
without typing anything (no "(no response)" placeholder, and the streamed status is torn down
cleanly rather than flashing an empty bubble). The plugin parses the directive out of the reply
(extractReactionsinlib.ts), removes it from the text, and applies the emoji
withreactions.addto the message that triggered the turn — or, when the directive carries an@target ([[react: tada @ ~abc123]]), to that specific earlier message instead. So the
agent can resolve a phrase like "react to Alice's last message," each channel turn
reconstructs the conversation as structured roled turns (renderConversationView, fed by a
best-effortconversations.history/repliesfetch that reuses thechannels:history/groups:historyscopes); every reaction-eligible message carries a short, stable id in
brackets like[~abc123]— the message's Slacktspacked into base36 (encodeTs), written
once on that message's turn. The agent copies that~idinto the directive and the plugin
decodes it straight back to thets(decodeTs— no lookup map), validated against the set of
message ts it actually showed this turn so a stale/forged id is dropped rather than reacting to the
wrong message. The id is short (~10 chars vs a 17-char raw timestamp) and STABLE (it always points
at the same message, so persisting it never drifts,
unlike the old positional[N]handle). A raw@ <ts>still works for back-compat. In-thread
replies are tagged(reply)so the agent sees thread structure, and the parent of any listed reply
is pulled in alongside it (recentWindow/arrangeForDisplay).
The bot token never leaves the plugin. Names are normalized against the
complete Slack emoji set —:eyes:,eyes, or a literal char all work, including the ~1900
standard emoji, ZWJ sequences (🧑💻→technologist), the❤️/❤variation-selector forms,
and skin tones (👍🏽→+1::skin-tone-4). That table (src/emoji-map.ts) is built at module load
from theemoji-datasourcepackage
(iamcal/emoji-data, the dataset Slack itself uses) — bump the dependency to refresh it. Names are
deduped and capped; a per-emoji failure (or a missing
scope) is logged, never breaking the reply. The directive is also stripped from streamed partials
so it never flashes while the reply types out.
Separately, unprompted channel-thread messages can be acknowledged before the assistant turn runs:
Slack passesgatewayContext.reactionGuidance, the core detector may returnstatus: "react"with
emoji names, and the plugin applies those to the triggering message without posting a reply. - The agent also HEARS reactions — if someone reacts to a message, it can see it. With the
reactions:readscope and thereaction_added/reaction_removedevents, a reaction the bot has
a stake in is surfaced to the agent: a reaction on its own message, a reaction in a DM
with it, or one on a thread root it's following (shouldSurfaceReactioninlib.ts). Everything else — a stray reaction in a channel it merely sits in —
is ignored, so a busy channel never floods the agent. A relevant reaction is delivered as an
unprompted turn (exactly like a thread-follow message): the core runs turn detection, so a
passing 👍 stays silent while a ✅ on the bot's "shall I deploy?" gets acted on. The reacted message
is read once viareactions.getso the agent sees its text and its full current emoji set —
e.g. "Alice reacted with :white_check_mark: on your message: …(it now has :white_check_mark: :eyes:)"
(buildReactionTurnText). The bot's own reactions never loop back, and the same emoji the agent can
read on recent messages are appended to the reaction-targeting list (buildRecentMessages), so it
can read the slackmojis there too. The reactor is classified and the channel audience is computed
exactly as for a message — so a reaction in a Slack-Connect / guest-containing context never makes
the bot respond — and that full audience is resolved (stake → dedup → coalesce → membership) before
any message content is read, so a non-internal channel's content is never fetched. A burst of reactions on
one message is collapsed to a single turn while one is in flight (reactionsInFlightguard), so an
emoji-pile can't wake the agent once per emoji. (Bounded by design: a reaction on someone else's non-root reply
inside a followed thread isn't surfaced — the message thread-follow path owns that conversation.) - Dangerous commands ask for approval in Slack. When a tool call hits a
require_approvalcommand-policy rule, the bot posts buttons for Allow once,
Allow session, Allow always, and Deny. Session/always approvals are exact-command
grants; deny rules still cannot be bypassed. This works whether the turn paused awaiting
approval (pending_approval) or finished with a reply but skipped the gated command
(collect-modeok+pendingApprovals, what the real Pi harness does) — in the latter case
the buttons are posted alongside the reply. Clicking re-runs the turn with the command approved. - Hand-offs are plain DMs. When channel work needs another member's own logins, files or
memory, the agent follows thehand-offskill: in Open conversations it uses the requester's own
access; otherwise it sends that person a DM throughPOST /v1/reachwith the full request, and
says so only after reach confirms who it matched. - In an externally-shared (Slack Connect) channel, or any channel/mpim whose audience
includes a guest or external member, it stays silent in-channel and replies privately
(ephemeral) — and uploads no file there — internal-only (spec §9). Group DMs (mpim) aren't
handled yet (it says so out-of-band rather than mis-scoping them).
Re-install after upgrading to file sharing. The
files:read/files:writescopes
were added tomanifest.json; update the app's scopes (or re-create from the manifest)
and reinstall to the workspace so Slack grants them, then restart the plugin.
Re-install after upgrading to emoji reactions. The
reactions:writescope was added tomanifest.json. Update the app from the manifest and reinstall to the workspace so Slack
grants it, then restart the plugin — otherwise the agent's reactions are silently dropped
(logged as a missing-scope hint).
Re-install after upgrading to inbound reactions. The
reactions:readscope and thereaction_added/reaction_removedevents were added tomanifest.json. Update the app from
the manifest and reinstall to the workspace (re-subscribing the events), then restart the
plugin — otherwise the bot won't receive reactions others add, andreactions.getreads fail.
Re-install after upgrading to workspace emoji. The
emoji:readscope lets the
acknowledgment picker include custom workspace emoji. Update the app from the manifest and
reinstall to the workspace, then restart the plugin.
Re-install after upgrading to thread-follow. The
channels:history/groups:history
scopes and themessage.channels/message.groupsevents were added tomanifest.json.
Update the app from the manifest and reinstall to the workspace (and re-subscribe the
events), then restart the plugin — otherwise the bot won't see non-mention thread replies.
Re-install after upgrading to approval buttons. The manifest now enables Slack
interactivity so Block Kit button clicks are delivered as Socket Modeblock_actions
payloads. No public request URL is needed, but the app must be updated from the manifest
and reinstalled.
Re-install after upgrading to group-DM thread-follow. The
message.mpimevent and thempim:write/mpim:historyscopes were added tomanifest.json. Update the app from the
manifest and reinstall to the workspace (re-subscribing the events), then restart the
plugin — otherwise the bot won't see non-mention messages in group DMs (mpims), so it only
ever responds there when explicitly @-mentioned.
Local dev with multiple developers
Slack Socket Mode load-balances events across every client connected to the same
app. If two developers both run this plugin against one shared app, Slack delivers
each incoming message to only one of them (round-robin) — so you each lose ~half
your messages, and a message can land on a teammate whose core is down. More config
won't fix it; the collision is the shared app identity.
Fix: one Slack app per developer. Separate apps = separate bot identities, and
Slack delivers each app's events only to that app's own connection — zero contention.
Each developer, once:
- Create your own app from
manifest.json(step 1 above),
but first change the manifest'snametoAgent (yourname)andbot_user.display_name
toagent-yourname. Install it; grab your ownxoxb-…+xapp-…. - Put your tokens in the repo-root
.env(gitignored — see.env.example).
Pick a distinct corePORTper dev if you share a machine. npm startat the repo root. In Slack, talk to your bot:@agent-yourname/ DM it.
Nothing shared, nothing to coordinate. The repo's manifest.json is the template;
your per-dev name + tokens live only in your local app and your gitignored .env.
How it maps to the core
| Slack | Core |
|---|---|
app_mention / message.im |
POST /v1/turns |
message.channels / message.groups in a thread where the bot has a stake (it posted or was really @mentioned) |
POST /v1/turns with unprompted: true (+ serialized conversation context) → core runs turn detection; status: "silent" ⇒ post nothing; status: "react" ⇒ add reactions and post nothing |
reaction_added / reaction_removed on a message the bot has a stake in (DM / own message / followed root) |
reactions.get to read the message + its emoji → POST /v1/turns with unprompted: true → core runs turn detection; status: "silent" ⇒ post nothing; status: "react" ⇒ add reactions and post nothing |
user id + users.info (guest/restricted/other-team → guest) |
actor + isExternalGuest |
conversations.info.is_ext_shared |
audience contains a non-internal member → core refuses |
| DM channel | threadRef = dm:<channel> (one continuous session per DM — channel-scoped; the assistant thread_ts only routes the reply + native typing status, never session identity) |
channel thread_ts (root) |
threadRef = ch:<channel>:<root> (one session per thread) |
TurnResult.reply |
posted in-thread; unprompted refused (security_quarantine included) → silent, addressed refused → requester-only (ephemeral in channels) except addressed security_quarantine → fixed in-thread acknowledgement; pending_approval (or ok + pendingApprovals, collect-mode) → Block Kit approval buttons |
Block Kit approval button (block_actions) |
POST /v1/turns with approval (once / session / always / deny) |
Pending /v1/surface-context request (hanging long-poll, source-authed) |
The agent's mid-turn context pull: core resolved the target (current conversation via the capability's opaque destination, or a directory-resolved public channel) and parked the request; the plugin enforces visibility (public, bot-member, not externally shared — or the current conversation itself, even private), fetches history with before paging + match filtering, and POSTs the messages back (buildContextWindow). The default injected window is small (MAX_RECENT_MESSAGES, env SLACK_RECENT_MESSAGES); context beyond it is pull, not push |
Notes / next
- The failed-turn admin link needs no Slack config: when a turn comes back
refused
with a real failure, the core supplies the admin transcript deep link (TurnResult.adminUrl,
built from the session org's portal) and the plugin appends it asFull error:. Absent when
there's nothing to link (boundary refusals, no portal configured). - Channel audience is enumerated per-member:
computeChannelAudience(lib.ts) resolves
the full member list (with a Slack-Connect / guest external marker, and an actor-only
fallback when membership is unreadable) so the core's audience-floor is fine-grained.
History page size
SLACK_HISTORY_LIMIT optionally sets the live history/replies page size and defaultread_thread result count for workspace-owned apps (integer 1–200). Without it,
owned apps request 200-message pages and return up to 100 messages by default.
Shared-app installations use 15 for both defaults regardless of this setting.
An explicit tool count overrides the result count, but not the live page size.
Stored mirror reads keep their existing window; shadow comparisons match the live page size.
Mirror qualification
SLACK_CONTEXT_SOURCE controls conversation context and default Slack tool reads:
live(default) keeps Slack history as the source, including automatic channel thread expansion.shadowreturns the same live context while comparing stored events in the background. It logsslack_mirror_shadowcounts for missing messages, text differences, and missing thread parents. It does not log message text or channel identifiers, backfill history, or wait for comparison before returning context. The live page is snapshotted before callers add trigger context, and shadow uses the same stored-message selection as mirror mode. Thread selection matches Slack’s first page, including its limitation that newer replies beyond that page may be absent. Channel roots use the newest page, and thread expansion truncation is tracked separately from root-page truncation. An extra stored row distinguishes exactly full pages from truncated pages; it does not prove complete Slack coverage. Counts cover raw message fields, not complete rendered context or author/subtype fidelity. At most one comparison runs per Slack client; concurrent reads are not all sampled.mirrorexplicitly enables stored context and mirror search, with the documented partial coverage and bounded live fallback. Keep this opt-in until shadow evidence and development qualification are satisfactory.
Ingestion continues in every mode. Shadow mismatch counts describe the sampled live window, not complete channel coverage. Compare channel and thread samples, edits/deletes, and normal agent turns before enabling mirror reads. No automatic mode promotion occurs.
Slack mirror records preserve provider subtypes so stored context uses the same system-message filtering as live context. A missing stored subtype means legacy or unobserved metadata; an empty subtype means a verified ordinary message. Partial event updates preserve omitted subtype and thread metadata. Mirror-mode history fallback shares event content normalization and prefers fresh Slack duplicates; live and shadow reads never backfill their comparison source.
Overview (`superserve/templates/README.md`)
Agent template
qm-agent.ts builds the qm-agent-<release> template used by the Superserve
sandbox backend.
Contents
The template uses ubuntu:24.04 on linux/amd64 and includes:
- Shell and file utilities, Git, curl, jq, and SSH.
- Node and npm, Python with a virtual environment at
/opt/agent-venv. - Coding-agent CLIs,
gh,aws, andx-api.
Tool versions are pinned in the build script. Downloaded Node and CLI archives
are checksum-verified. Python and pip wrappers select the virtual environment
regardless of the exec environment's PATH. Commands run as root; QM sets HOME
to SUPERSERVE_HOME_DIR (default /root) and uses <home>/workspace.
Deployment tools and skills are installed during provisioning. The optional
browser engine is not included.
The default Superserve sandbox has 8 vCPUs, 16384 MiB (16 GiB) memory, and
32768 MiB (32 GiB) disk. The disk includes the OS and toolchain as well as workspace files,
dependencies, package caches, and build output. Override these with --vcpu,--memory-mib, and --disk-mib when building. CPU, memory, and disk capacity are set
on the template, not at sandbox creation.
Build
export SUPERSERVE_API_KEY=your-superserve-api-key
node superserve/templates/qm-agent.ts --release 0.1.0 --wait--release names the template; use the release tag your deployment runs.--wait streams logs until the build completes. Without it, the script returns
after queuing the build. Use --base-url or SUPERSERVE_BASE_URL to override the
API endpoint.
A ready template with the same name is reused. Failed builds are replaced;--force also replaces a ready template. Names are unique within a team.
Set SUPERSERVE_TEMPLATE=qm-agent-<release> on core after the build succeeds.
The backend requires a ready template with this toolchain.
Rebuilding under the same name does not update existing sandboxes. ChangingSUPERSERVE_TEMPLATE to a different name replaces them and deletes their resident
files. Export needed files first; see updates and retention.
Increase sandbox capacity
Existing templates and sandboxes keep their original capacity when these defaults
change. Passing --vcpu, --memory-mib, or --disk-mib with an already-ready
template name also reuses that template without resizing it.
To roll out larger sandboxes:
- Pause ongoing jobs, commit and push source changes to Git, and export other
needed resident files before replacing sandboxes. - Build under a new template name, for example:
node superserve/templates/qm-agent.ts --release 0.1.0-8cpu-16ram-32disk --vcpu 8 --memory-mib 16384 --disk-mib 32768 --wait. - Verify it with
node superserve/templates/verify-qm-agent.ts --template qm-agent-0.1.0-8cpu-16ram-32disk. - Set
SUPERSERVE_TEMPLATE=qm-agent-0.1.0-8cpu-16ram-32diskon core and deploy. This replaces
existing scope sandboxes on use and deletes their old resident files; it does
not resize them in place. Restore exported work into the new sandboxes. - Check
nproc,free -h, anddf -h /root/workspacein a new sandbox, then
rerun representative builds and full typechecking before resuming heavy jobs.
Use your deployment's release tag in place of 0.1.0. A distinct suffix keeps the
old template available during verification. --force rebuilds a same-name
template but does not upgrade existing sandboxes. Confirm the requested shape is supported and allow
for the increased CPU, memory, and storage in provider quotas and costs before
rollout.
Verify
node superserve/templates/verify-qm-agent.ts --release 0.1.0The verifier creates a sandbox, checks its tools, workspace, Python environment,
and command timeout behavior, then deletes it. It exits nonzero if verification
or cleanup fails. --template selects a template directly instead of by release.
--keep retains the sandbox for inspection and schedules deletion one hour after
it pauses. Otherwise, automatic deletion on pause backs up explicit cleanup,
including when the verifier is interrupted.
Overview (`test/live-slack/README.md`)
Live Slack E2E (example)
Post-merge live scenarios against example.slack.com. The workflow
(.github/workflows/live-e2e.yml) boots the merged commit as a real instance on the GHA
runner — core (SESSION_STORE=memory) + Slack plugin in Socket Mode on a CI-reserved
pool app (qm dev --ci) — then run.ts drives the catalog inscenarios.ts posting as a real example user (the "QA Human" account), so every
message takes the exact path a teammate's would: classification, lazy directory push,
threading, acknowledgments, tool execution, and file delivery.
Layout
run.ts— runner: lane scheduling (parallel lane fans out, the DM lane is serial
because a DM is one session —dm:<channel>in src/slack), one retry (retried
pass = flaky, still green), results + failing-session transcript dumps toout/.scenarios.ts— the catalog. Prompts embedctx.marker()(anci-<runId>-<scenario>token) so assertions grep for unforgeable strings and
teardown can sweep crons/channels by marker.harness.ts— per-scenarioCtx(fresh channels, final-reply detection, LLM judge).slack.ts/core.ts— fetch-based Slack Web API + source-auth-signed core admin
clients (transcripts via/v1/admin/sessions, crons via/v1/admin/crons).
A bot reply is accepted after it has stopped changing for 5s and satisfies the
scenario's content or attachment assertion. Legacy status text is ignored.
The long-task scenario requires a bot reaction or an acknowledgment before the
substantive answer. The audience scenario accepts an answer at the channel top
level or in the original thread; the threading scenario still requires a thread reply.
The performance scenario runs alone and requires successful command execution
before evaluating its timing budgets.
Run locally
Bring up a dev instance (/dev-instance up) or node cli/bin/qm.ts dev --ci up, then:
SLACK_QA_USER_TOKEN=xoxp-… SLACK_BOT_TOKEN=xoxb-… \
CORE_API_URL=http://localhost:8181 CORE_SIGNING_SECRET=… ANTHROPIC_API_KEY=… \
LIVE_E2E_FILTER=@smoke node test/live-slack/run.tsLIVE_E2E_FILTER takes a name substring, @tag, or all (@smoke = the two-scenario
subset; sandbox-tagged scenarios are auto-skipped unless SPRITES_TOKEN is set).LIVE_E2E_SHARD=n/m splits the selected catalog deterministically for a CI matrix.
Set LIVE_E2E_OBSERVATIONAL=1 to report completed catalog failures (and alert when
configured) without a nonzero exit; runner, setup, event-pump, and required-alert failures
remain blocking.
Arga twin backend (no shared test workspace, no pool app, fully parallel)
With an Arga API key the same catalog runs against an isolated Slack digital-twin
workspace (docs.argalabs.com) instead of a shared test workspace: its own bot,
QA user, and minted alice/bob/carol actors per run — no shared workspace, no slot
serialization, no hand-provisioned accounts, and no Slack rate budget shared across shards.
The twin-e2e matrix job in the workflow provisions one twin per shard.
Mechanics: arga-provision.ts up provisions the twin, seeds users/tokens
(arga.ts:seedTwinUsers), pre-creates the reach target channel, and emits the boot env —SLACK_API_URL (the plugin's Web API seam), SLACK_EVENTS_MODE=http +SLACK_SIGNING_SECRET (the plugin listens for signed Events-API POSTs instead of Socket
Mode, which the twin doesn't speak), and the minted QA/actor tokens. run.ts then starts
an event pump (arga.ts:startEventPump) that polls the twin's /admin/events log and
delivers each recorded event — signed, per-channel FIFO — to the instance's receiver. The
pump also papers over two twin fidelity gaps: it stamps the missing channel_type and
synthesizes the app_mention twin of a bot-mentioning message event (real Slack sends
both; the plugin only dispatches channel mentions from app_mention).
Run locally (Free-tier keys cap the twin at 10 minutes — enough for @smoke):
export ARGA_API_KEY=arga_sk_…
eval "$(node test/live-slack/arga-provision.ts up | grep '^export ')"
node cli/bin/qm.ts dev --ci up
LIVE_E2E_FILTER=@smoke node test/live-slack/run.ts
node cli/bin/qm.ts dev --ci down; node test/live-slack/arga-provision.ts downTwin-only scenarios (scenarios-twin.ts, tag @twin) exercise what a live workspace
can't: event redelivery (dedupe/idempotency), mid-session scope revocation, and per-method
rate limiting. Scenarios that mutate workspace-global twin config use the exclusive lane
(run alone, after the other lanes) and restore what they touched.
App access requests (`@deploy-access`)
scenarios-deploy-access.ts drives the published-app access flow end to end on a twin:
a stranger hits an app's subdomain, clicks Request access, the owner gets a DM card with
Approve/Decline, a click (a signed block_actions POST to the receiver, as Slack would
send) grants view access and DMs the requester, and the requester then reaches the app
through the gateway. They carry the apps-gateway capability tag and skip unless the
instance has the gateway configured:
export DEPLOY_APPS_DOMAIN=apps.e2e.test AWS_DEPLOY_GATE_SECRET=$(openssl rand -hex 16) \
DEPLOY_APPS_SESSION_SECRET=$(openssl rand -hex 16) DEPLOY_APPS_LOGIN_URL=http://localhost:8181The default docker deploy provider needs a docker on PATH; a shim that runs each
published entrypoint as a host process is enough (the scenarios publish a one-filenode server.mjs).
Tiers, triggers, quarantine
- Tiers via tags.
@coreis the fast high-signal subset run on every push to main;
the full catalog (all) runs on PR-label + on-demand.@multiuser= the multi-user
channel scenarios;@sandbox= needs Fly;@smoke= the tiny two-scenario subset.
Capability tags gate where a scenario can run:twinneeds a twin backend, andno-twinis its inverse — a capability the twin cannot serve, skipped on twin runs
(with the reason) so it can't produce a red that says nothing about the product. - Triggers. push→main runs
@core; add thelive-e2elabel to a PR to run the
full catalog against that branch;workflow_dispatchtakes afilter+ aslot(1–3)
so an on-demand feature-push run doesn't queue behind main's. Slots 2/3 needLIVE_E2E_SLACK_{BOT,APP}_TOKEN_{2,3}secrets (extra CI apps); slot 1 uses the existing pair. - Quarantine. tag a known-flaky scenario
quarantine: it still runs and shows in the
gallery/summary (with a chip), but never counts toward the pass/fail signal (exit code +#ci-alerts) while it's being stabilized. "Don't accept flaky" — fix or quarantine, don't
let a red scenario erode trust in the suite.
Rollout state: CI is required. Catalog failures alert #ci-alerts and fail the job; twin
failures alert through the real CI Slack app. Observational mode is available only for explicit
local runner invocations; the GitHub workflow does not enable it.
Growing the catalog: scripts/mine-slack-scenarios.ts reads a core Postgres, clusters
recent multi-user channel sessions by shape, and emits a digest to hand-author new
scenarios from (curation over an auto-compiler, on purpose). Run it in the core box (see the
top-of-file usage). The multi-user set lives in scenarios-multiuser.ts.
One-time example setup
- CI pool app — create a Slack app from the same manifest as the local pool apps
(src/slack/README.md), named "Agent (ci1)", Socket Mode on. Do not reuse
a localpoolN.envapp: a CI run stealing a slot mid-debug would be miserable.
→ secretsLIVE_E2E_SLACK_BOT_TOKEN(xoxb),LIVE_E2E_SLACK_APP_TOKEN(xapp). - QA Human — a real example member account (full member, not guest — guests classify
differently). Create a second tiny app "QA Driver" with user token scopeschat:write, channels:read, channels:write, channels:history, groups:history, im:history, im:write, reactions:write, files:read, files:write, users:readand install
it as that account → secretLIVE_E2E_SLACK_QA_USER_TOKEN(xoxp). (files:write
lets the QA user upload the selfie in thephoto-identityscenario; reinstall the app
if you added it after first install.) - Channels — create
#ci-target(public; invite the ci1 bot and QA Human; repo
varLIVE_E2E_TARGET_CHANNEL= its channel id) and#ci-alerts(invite the ci1
bot; repo varLIVE_E2E_ALERT_CHANNEL= its id). Failures on main post there. - Remaining secrets —
LIVE_E2E_ANTHROPIC_API_KEY,LIVE_E2E_CORE_SIGNING_SECRET
(any random string; the runner instance is loopback-only), and the existingSPRITES_TOKEN(sandbox turns) andFLY_API_TOKEN(the cloudflared self-API tunnel).
Runs are serialized per Slack app by the workflow's concurrency group — that is the
lease; there is no poolN.env machinery in CI.
Fake-human actors (multi-user scenarios)
Multi-user channel scenarios need several real example member accounts, each with its own
user token, so a scenario reads like a play: ch.as(bob).mention(...),ch.as(carol).threadReply(root, ...). A scenario declares the actors it needs
(actors: ["bob","carol"]); the runner skips it unless every declared actor has a
token, and invites only those actors to its channel. At boot each actor posts one
warm-up message in a scratch channel (the real lazy Slack→core directory push) and the
runner polls the admin directory until it classifies — an actor that never resolves is
dropped and its scenarios skip, so you never get a mysterious empty-directory miss mid-run.
Provisioning (~1 hr for five, one-time; the install step is dashboard-manual):
- Five member accounts via Gmail plus-addressing —
[email protected]
…+slackqa5, all landing in your inbox. Invite each to example as a full member
(not guest — guests classify differently). Name them alice … eve. - User tokens — reuse the "QA Driver" app manifest (same user scopes as the QA Human
above) and install it once per account while signed in as that account; each install
yields that member'sxoxptoken. - Configure — either
LIVE_E2E_ACTOR_TOKENS='{"alice":"xoxp-…","bob":"xoxp-…",…}'
or one var per actor:LIVE_E2E_ACTOR_TOKEN_ALICE=xoxp-…. Names are lowercased. - Verify before a run:
node test/live-slack/actors-verify.tsprints each actor's
handle + user id, or the auth error.
Screenshots (pixel-truth, local, best-effort)
The gallery reconstructs message content faithfully and links to the real thread; for
actual Slack-rendered pixels run the decoupled screenshot sweep after a run. It drives
a dedicated Chrome profile (never your main Chrome), so it's non-disruptive, and gates
each capture on the conversation's text appearing in the DOM — a flaky capture is skipped,
never fatal.
npm run gallery:shots:login # one-time: sign into example in the window that opens
npm run gallery:shots # headless sweep → out/shots/*.png, folds them into gallery.htmlLIVE_E2E_SHOTS_PROFILE overrides the profile dir (default ~/.cache/live-slack-shots-chrome),LIVE_E2E_SHOTS_PORT the debug port (default 9333).
Reading a run
Start with out/gallery.html — one self-contained page (opens offline) that reconstructs
what happened in Slack for every scenario, pass or fail, so you can verify by eye instead of
trusting the LLM judge. Each card shows the driver's messages, the bot's reply, its full
streaming edit history (the ⚙ Working… ticker → ▌ cursor → final text, collapsed under
"N streaming edits"), and a ↗ slack permalink into the real thread for pixel-true
inspection. Failures sort to the top with their error and core turn errors inline. This is the
primary artifact for the on-demand / feature-push use case.
For deeper debugging, the artifact also has out/summary.md (also in the step summary),out/results.json (includes each scenario's timeline), out/transcripts/*.json — the failing
scenario's full session entries and LLM requests (ground truth for "what did the agent
actually see") — and .ci-instance/{core,slack}.log. Don't trust the agent's in-channel
self-explanations; read the transcript.
Sandbox-provider release qualification
Set LIVE_E2E_SANDBOX_PROVIDERS=all with LIVE_E2E_GATE=1 to require a real
agent execute on every implemented provider: Sprites, AWS, local Docker,
Smolmachines, E2B, Modal, Porter, and Agent37. These eight scenarios run concurrently
in their own lane alongside the selected catalog. The source type requires a
coverage entry when a provider is added. Missing providers, skips, retries,
execution errors, and cleanup errors block release. Provider checks do not retry; a failed first execution blocks qualification.
Release qualification cannot shard away a required provider check.
Alternatively, set LIVE_E2E_SANDBOX_PROVIDERS to an explicit comma-separated
provider list. Every listed provider remains mandatory and runs concurrently.
Providers omitted from that list are outside the gate, not failed or skipped
required tests. Empty, unknown, and duplicate provider names are rejected.
Each check creates an isolated sandbox, makes it the default only for its fresh
test channel, and asks the agent to execute a nonce command on its explicit ID.
The assertion requires a correlated tool call and result with exit code zero,
no timeout, and exact stdout. Agent reply text cannot satisfy it. Cleanup clears
the test channel default and retires its named test resources, including failed
provisioning records. Core requests are bounded, cleanup uses a separate deadline,
and the runner waits for provider cleanup to settle before recording an attempt
as timed out. Slack requests time out after 30 seconds without SDK retries
or automatic rate-limit waits; the scenario runner owns retry policy. A cleanup deadline failure remains a release blocker and requires
operator reconciliation of retained resource records.
The target instance must enable sandbox resources and configure every required
provider with real credentials and infrastructure. Local Docker additionally
requires a Docker-capable host and a built sandbox image; a Fargate service alone
cannot supply it. Unconfigured providers are failures, never optional coverage.
When staging cannot host Docker, run scripts/qualify-local-provider.sh /path/to/release-candidate.json as a separate required job on an ephemeral
Docker-capable ARM64 runner. It pulls the candidate's immutable core image from
ECR and checks its embedded source SHA against the checkout. The runner builds
the local sandbox from that source, then invokes the candidate's real application,
Pi harness, model, and sandbox tools. Only the probe and transcript assertion are
mounted into the core container; application code comes from the candidate image.
Provide AWS ECR access and ANTHROPIC_API_KEY. Require this job alongside cloud
qualification before promotion. If capturing output through a pipe, use a shell
with pipefail so a failed probe cannot become a successful job.
Overview (`vendor/pi-ai/README.md`)
Vendored pi-ai 0.82.0 (qm.1)
qm installs @earendil-works/pi-ai from the GitHub releasevendored-pi-ai-0.82.0-qm.1,
built from stock npm 0.82.0 plus provider-error.patch (dist only; version unchanged; not sent upstream).
Why: stock pi-ai folds a provider HTTP failure into the display string errorMessage, so qm had to
parse text to tell a budget cap from a rate limit. The patch keeps the structured data next to it:
AssistantMessage.providerError?: { status?, type?, code?, body? }, from the SDK error, in the
error path of openai-completions, openai-responses, azure-openai-responses, openai-codex-responses,
anthropic-messages, google-generative-ai, google-vertex, mistral-conversations and
bedrock-converse-stream (extractProviderErrorindist/utils/error-body.js).AssistantMessage.rawStopReason?: stringwhen a stop/finish reason maps tostopReason: "error"
(anthropic-messages, openai-completions, bedrock-converse-stream), e.g. Anthropicrefusal.
errorMessage is byte-for-byte what stock 0.82.0 produces.
The vendored pi-coding-agent (vendored-pi-coding-agent-0.82.0-security.6) is security.5 with only itsnpm-shrinkwrap.json pi-ai entry repointed at this tarball, so there is one pi-ai in the tree; the rootoverrides entry enforces the same.
Rebuild (both are byte-reproducible and verify their inputs' SHA-256):
vendor/pi-ai/build.sh # earendil-works-pi-ai-0.82.0-qm.1.tgz
vendor/pi-ai/repack-coding-agent.sh # earendil-works-pi-coding-agent-0.82.0-qm-security.6.tgzDrop this directory when qm upgrades past 0.82.0 to a pi-ai that exposes equivalent fields.
Overview (`cli/test/e2e/README.md`)
`qm` CLI end-to-end tests
These drive the real qm binary as a subprocess and verify each command's
objective — files actually scaffolded, containers actually running, the right flyctl
actually issued, the deployment actually torn down — not just that something was printed.
cd cli
npm run test:e2e
npm run test:all
npm testThey load the repo's ./.env into the child environment (so ANTHROPIC_API_KEY /CORE_SIGNING_SECRET / SKILL_SIGNING_SECRET are present, as an operator's shell would have them).
What each file covers
| file | command(s) | how it's real |
|---|---|---|
meta |
help · version · unknown cmd |
exit codes + stream routing through the shipped bin |
init |
init (+ --org/--target, clobber refusal) |
scaffolds, then round-trips through check |
check |
check (+ --sandbox-dir) |
pass on a valid layer; exit 1 naming each break |
plan |
plan = up --dry-run (docker) (+ --build-from/--config/--sandbox-dir/basePort) |
asserts the resolved plan; verifies it starts nothing |
sandbox-build |
sandbox build --dry-run (+ --from/--app/--tag) |
asserts the generated Dockerfile + push command |
docker-lifecycle |
up→status→logs→down/--purge |
real Docker daemon, asserts against docker's own state |
identity |
init→check→slack render→outputs with a custom botName |
brands the real manifest; outputs rejects a stale one |
fly |
plan/status/logs/down/--target/--only/-f (fly) |
a fake flyctl records the exact commands issued |
dev |
dev status/down/logs/up precondition |
isolated pool store; never touches the real pool |
Design notes
- Docker lifecycle uses stand-in images. The CLI's job is to provision and supervise
services, so the test builds tinyalpinestand-ins (they echo every service's readiness
line then idle) viaup --build-from <fake checkout>. That exercises the full
build → run → wire → wait-readiness → status → logs → teardown path without depending on
the heavyweight (and not-yet-published) service images. Skips when no daemon is reachable. - Fly is never really deployed. A fake
flyctl(FLY_BIN) answers the read/list verbs and
logs every invocation; real Fly deploys are outward-facing and create apps. - Isolation. Every deployment uses a unique org
qm-e2e-<label>-(so it never collides
with a realacmestack); dev tests setQM_POOL_STOREto a temp dir. Everything is
torn down infinally/after.
Note: `--env-file` and node's same-named flag
Node (≥24, incl. the repo's pinned 24.13.0) treats --env-file as its own flag and pre-scans
the whole command line for it — even after the script — so a naive node bin/qm.ts … --env-file X
lets node hijack the flag before the CLI runs (loading it into node's env, or exiting on a missing
file). The bin's shebang therefore ends node's option parsing with -- (#!/usr/bin/env -S node --),
so the user's --env-file reaches the CLI. runCli mirrors that by spawning node -- …, and
the plan --env-file e2e guards it (it would exit 9 with node's error, not 1 with the CLI's, if the
shield regressed).
Overview (`docs/qa/app-shell/README.md`)
App shell and management QA
QA used a local web dev instance using the real Vite/Lit web UI, core, isolated Postgres, and a disposable Fieldnotes deployment. No UI or API responses are mocked. Loopback adapters supply the local development session and app host routing; the instance is not publicly accessible.
App and chat
The agent identified the app from server-supplied conversation context, then published the requested background changes. The green version was verified during QA. The surrounding bar stays neutral.
Manage
Long histories show the latest ten versions initially. “Show older versions” reveals ten more per click; tests exercise up to 150 versions.
Permissions
Browser QA covered opening and closing the drawer, top-level escape, staging a person without granting access, the custom access menu, and the manage page. Earlier QA exercised adding view access, changing it to manage, and revoking the test grant. Tests cover dialog-bound menu placement and frame-denying upstream responses.
The picker adds people. Existing project/channel grants can be edited or removed; adding those scope types and an in-bar version selector are outside this change. The top-level escape applies to the current URL; navigation that drops its query parameter restores the shell.
Overview (`docs/qa/chat-streaming/README.md`)
Chat streaming and recovery QA
QA used the real browser UI and isolated local core/Postgres with synthetic arithmetic prompts. No UI or API responses are mocked. The unchanged main frontend and updated frontend displayed the same saved conversation at the same window size. The local instance was not publicly reachable.
Before
The latest prompt occupies several lines, and narration and tool summaries remain outside a single completed-work disclosure.
After
The latest prompt starts as a two-line preview with Show more. Completed work collapses into one timed disclosure while the answer remains visible.
Expanding the disclosure shows narration and commands in their original order.
Interaction checks
Live browser checks covered streamed prose, code and tables; manual scroll-away and selection; opening and closing split panes during streaming; Stop followed by reload and another turn; queued-message editing with attachments; approval decisions and duration; a 25-second connection outage; and expanded full tool output after reload. Backend-specific cancellation and transport races have deterministic regression coverage. Resting-layout inspection alone does not establish timing behavior.
Overview (`docs/qa/execution-credentials/README.md`)
Execution credential QA
The retired command-scoped credential flag is removed from Customize → Feature flags. The screenshots show the real admin page against a local development instance with synthetic data.
| Before | After |
|---|---|
Live Slack QA used Firefox, a real model, local Docker/Postgres, and credentials containing disposable synthetic values with no authority:
- An execution without selection saw the variable absent.
- An execution selecting the saved handle verified the expected value and returned
prefix=<redacted:credential>:suffix. - The next execution without selection again saw the variable absent.
- Saving a second credential and selecting the returned handle within that same turn returned
new=<redacted:credential>:end. - A failing command preserved exit code 7 and surrounding stdout/stderr while masking the synthetic value.
- The retired tool was absent from the model tool catalog.
Postgres inspection confirmed the selected executions' tool-result entries and tape result content contained the mask and no raw synthetic values. User messages and tool arguments deliberately containing the synthetic fixture are outside output masking.
The change covers known injected env values in execution output. File credential restoration, the raw keychain use endpoint, and unrelated asynchronous/file output paths are separate work.
Getting Started (`docs/getting-started.md`)
Deploy QM for an organization
Deploying QM does not require a copy of this repository: qm init materializes a
deployment directory from the published package. Start in an empty organization-owned
private repository:
npm exec --yes --package=@yc-software/qm@latest -- \
qm init . --org <slug> --target <fly-or-aws>
npm installChoose Fly.io or AWS before initialization; the slug is a local name derived from the
organization, not globally unique. Customize config, tools, skills, and services in this
directory. To change QM itself, use a public or private source fork and explicitly
build its source, as described in the README.
A private source fork can keep its deployment at deploy/layers/<org>/; a public source
checkout uses a separate private deployment directory. See../deploy/layers/README.md.
Provider choice is part of initialization because it determines the config,
secret rules, generated files, and teardown contract. Changing providers means
initializing a new empty directory. qm init materializes deployment.md and.codex/skills/deploy-qm/. Hand that skill to an agent. It confirms the
operator-owned account and billing before mutation, configures email-gated web
onboarding first, optionally adds connectors and Slack, performs live checks,
and returns the operational URLs. Sign-in defaults to the built-in auth
broker, which emails a one-time link: supply the admin address, a verified
sender, and a Resend key or SMTP credentials, and the CLI generates and wires
everything else. Drop "auth" from services to use an external identity
provider instead; that provider must then register the exact/auth/callback redirect.
The installed package carries Fly and AWS provider templates and dispatches
their common lifecycle through the hosting-provider registry. Initialization
does not create deployment CI, and the QM source repository has no production
deployment workflow.
SPEC (`docs/SPEC.md`)
QM spec
Human-written. Agents must not edit this file, except to add entries to the Wall of shame.
North stars
- Unhobble. Frontier models are smarter than we usually allow them to be. What scaffolds and supports the model today will restrain it tomorrow; keep the harness as thin as possible.
- Free the brain. State lives in Postgres and the agent can query it. Unhobbling also implies freeing the agent from dependency on any specific provider: sandboxes, models, and harnesses are just swappable resources for the agent.
- Fast is the best feature. Every bit of overhead above base inference time should be measured and driven down.
- Agent UX = human UX. Tools, errors, hints and prompts are the agent's interface, so they get the same care as the web UI. Spend every context token on purpose.
- Delete before you add. Via negativa. This codebase is already way too flabby. Always try the solution that simplifies or removes code before adding anything.
Subsystems, most central first
Agent turns. The system revolves around durable execution against a central writeahead log: the session tape. To execute an agent turn, a session offers up a write lease, at which point a worker claims it from the run queue. The orchestrator assembles context and drives a harness, which fulfills tool calls, and the result is committed to the session tape. The system should be robust to interruption at any time, end to end, and scale to swarms that are millions of agents strong.
Swappable harnesses and models. Agents have a single tool catalog; their turns can be fulfilled by Pi or Claude Code, which call into the model catalog, the canonical source for model varieties, effort levels, speed multipliers, and their prices. A particular selection of those variables fulfilling a turn is called the runtime.
Scopes. The original model was one OpenClaw per-person or per-channel. That means isolated compute, memory, files, crons, and apps. Breaking through that isolation requires an explicit and auditable grant.
Sandboxes. In OpenClaw and similar systems – the agent’s computer is home, but also a prison. In QM, no sandbox is precious. Parallel computation is encouraged, and durable state should be written in either Postgres or object storage wherever possible to enable that. Management and provisioning of compute is generally left to the model. This allows it to use its intelligence in the deployment of resources as it deems necessary.
Credentials. The most secure configuration is a secret that the agent never sees. Unfortunately, the world was not designed with agents in mind. Certain operations become awkward or impossible under this rule. So, in keeping with our capabilities-first north star – we allow escape hatches that trade off some level of security (for example, on-disk credentials that could in principle be lost to a prompt injected agent) to be used at the operators’ discretion.
Context. Primarily, this is system prompt, memory, guidance, and skills. Current model rule following is weak and prone to fail. System prompt is used for general orientation of the agent to the QM harness environment, not strict instructions or checks – these belong locally – closer in context-distance to where the agent’s attention actually is. By default, memory is an append-only per-scope notebook recalled with vector search matching on every turn. Given that org memory is an unsolved problem, we allow hooks for swappable implementations of this. Guidance is intended for a per-scope's standing orders; and skills are implemented typically.
Background work. Crons, loops, and webhooks run with their owners permissions, but unattended work is generally considered more of a prompt injection risk. Therefore, explicit grants are required before credentials can be used for it.
Surfaces. There is one source of truth, which are the tokens sent to the model provider’s GPUs for inference. Everything else is a projection of that: Slack and the web UI read the same sessions and present them the way each host expects.
Artifacts. Apps, files, skills, crons, etc should all be shareable with a familiar Google-Docs style interface, be versioned, and tracked in Postgres or object storage.
Wall of shame
Patterns from this repo's history, with the PRs that introduced or removed them. Lenses marked always reviewed get a reviewer on every PR; triage cannot mark them not relevant.
Overengineering (always reviewed, 6 examples)
Let sLOC +/- be your guide here. Small and net-negative LOC fixes will be rewarded. This codebase at baseline is flabby -- many bugs can be fixed by deleting, removing, simplifying. For features: use restraint when adding new subsystems that increase surface area and widen the config matrix we will have to support.
Past mistakes and examples:
- Amendments to the master spec that are way too large for the importance of what the PR actually adds qm#2138.
- The loops subsystem should have just re-used the crons machinery, but it built its own little empire for no reason qm#963.
- The artifact pages in the web UI could have all used the same group-by backbone, but each one got its own design for no reason qm#963.
- Delivery retries (2026-08-30): Undeliverable Slack notices prompted a proposal for attempt counters, scheduled exponential backoff, a parked state, and admin visibility. The proposal optimized for eventually delivering messages long after they were useful; simply expiring stale deliveries addressed the actual need. qm#2133.
- Harness/model selection (2026-07-17): A selector grew into a proposed "agent profile" platform with its own catalog, preference layers, upgrade acknowledgments, readiness handling, and cohort rollout. The smaller solution extended the existing scoped setting with a
{ harness, model }pair and reused the existing configuration and picker machinery. qm#2134. - Admin tool (2026-09-17–18): Concern about oversized responses grew into a 43-file, roughly 4,580-line patch with field limits, custom JSON validation, layered timeouts, duplicate concurrency limits, and a special handshake. Only after pushback did the agent inventory actual calls and identify large transcripts as the concrete risk. The replacement used bounded transcript pagination and a thin read-only tool: 9 files, +329/−18. Source/status: original transcript and patch not yet recovered; size and rejection claims are unverified.
Band-aid fixes (always reviewed, 11 examples)
Hiding a symptom while leaving its true cause unfixed. Smells here are special cases (string matching and regex are especially bad), many-branched conditionals acquiring yet another branch. A bug isn't a problem to fix, it is useful evidence that the system we have built is flawed.
- Credential handle prefix: The prompt lists shared credentials as
service_gitlab, andexecute.credentialstakes that name, but the credential broker matches the bare sluggitlaband answers the prefixed name with403 not_entitled. QM read that as a missing grant. The proposed fix stripped a leadingservice_in the broker, so both spellings would work. That teaches one more caller to tolerate two names instead of removing the second name: a credential should have one name across the prompt,execute.credentials, and the broker. (2026-10-13) - Slack task completion: A proposed acknowledgment fix reconstructed completion from descendant runs, mailbox returns, the latest conversation turn, and delivery-key prefixes. Core needed to own request completion through delegation and final delivery, then expose that state to Slack; moving the same guesses into a helper would not fix the missing ownership. qm#2135.
- Transcript row grouping: A proposed UI fix joined adjacent assistant rows only when the second contained subagent activity. Multiple commentary messages, intervening tools, and resumed turns defeated the special case. Grouping events by their actual turn/run identity would give streaming and reloaded history the same response structure. qm#2136.
- Credential capture: Indiscriminate credential collection swept up oversized browser profiles. The proposed fix recognized browser-profile marker files and skipped those directories, adding another exception to the sweep. The deeper fix was to let the agent identify which credential files to preserve, or explicitly request capture of changes from an authentication turn. qm#2137.
- qm#1743 (2026-09-30): Refusal fallback extended by regex-matching Anthropic usage-policy text and 'gateway model is unavailable' to trigger a hard-coded alternate-model ladder (claude-opus-5 / claude-sonnet-5), plus a new admin fallbackRuntime.
- qm#1748 (2026-09-30): Every non-Modal sandbox took one exclusive sandbox-resource: advisory lock around each command/file op, so sessions sharing a computer queued behind each other; replaced a backend!=='modal' special case with shared locks and a parksOnTeardown profile property. Status: wound back in qm#1748.
- qm#1432 (2026-09-19): Per-turn reconciliation of a shared skills/ index under a sandbox-wide advisory lock (skill projection) stalled turns 5 minutes; replaced with an explicit skill tool and per-turn skill dirs. Status: wound back in qm#1432.
- qm#1133 (2026-09-12): A factory-specific retry ladder (FACTORY_READ_RETRIES=6) was added around sandbox readProcess polling after one timeout under load. Status: wound back (factory code absent on current main).
- qm#965 (2026-09-07): One legacy \u0000 payload bricked sessions; fixes proposed tolerating poisoned rows and sanitizing every Postgres text write. Status: unknown.
- qm#394 (2026-08-13): Memory tool silently coerces malformed
remembercalls (content/query/bare string) into facts instead of fixing the tool schema. - qm#469 (2026-08-13): Sandbox status collapses any unanswering shell into a 'wedged' verdict with baked-in 'restart the computer' advice.
Duplication (always reviewed, 7 examples)
Giving the same behavior or fact multiple independent implementations or sources of truth. These copies drift, disagree, and multiply maintenance work; reuse the existing owner or consolidate the competing paths.
- qm#1776 (2026-09-30): Sprites cold-boot '503 Process not ready' exec re-send implemented on main after the same fix (qm#1489) had already landed only on the long-lived factory side branch.
- qm#1520 (2026-09-22): Factory required pasted factory-anthropic/-github/-linear/-slack keychain secrets duplicating core's own model auth and connectors (two sources of truth); QM-73..76 resolve from core config/owner connectors instead. Status: wound back in qm#1520, qm#1522, qm#1523, qm#1524 (on factory branch).
- qm#1476 (2026-09-21): Context settings had its own model picker separate from the composer's; switched to reuse the composer model/preset picker. Status: wound back in qm#1476 (both plugins/web-ui/src/model-picker.ts and context-model.ts still exist).
- qm#1272 (2026-09-16): Session transcripts are kept in both session_entries and session_tape, with SESSION_TAPE_MODE shadow/serve choosing between them. Fixed in qm#2273: code uses only session_entries. Both tables are now gone:
itemsis the only transcript store. - qm#1185 (2026-09-15): Each subscriber held its own LISTEN connection and pg-boss kept its own pool; consolidated to one listener per process and the shared query pool. Status: wound back in qm#1185.
- qm#993 (2026-09-08): Chat search queried both the dedicated search index and legacy entry history for every visible conversation, causing timeouts. Status: wound back in qm#993.
- qm#488 (2026-08-13): Web-UI /api router had ~50 hand-copied body-parse/relay/path-decode fragments that drifted (some returned 400 instead of 413). Status: wound back in qm#488.
Hand-rolling (always reviewed, 5 examples)
Reimplementing a capability that an existing SDK, library, or supported API already provides. Custom protocols and plumbing make us own edge cases that the maintained integration already handles; use that integration when it meets the requirements.
- qm#1005: Run-queue retries. Added a
retry_aftercolumn, a matching in-memory deadline map, a custom exponential-backoff-with-jitter function, delayed-claim predicates, and an inline polling loop to keep delayed retries moving. pg-boss already provides durable deferred jobs, retry limits, and backoff; we implemented those mechanics again beside the pg-boss cron queue. Status: custom retry scheduling remains insrc/runs/. - qm#1255: Run-queue worker wakeups. Sixteen idle workers issued about 64 claim UPDATEs per second. The fix built a
qm_run_availablenotification channel, worker generation counters, reconnect rescans, recovery timers, and a shared availability probe. This is another layer of queue infrastructure to maintain when pg-boss provides worker polling and notification-based wakeups. Status: the custom notification and worker machinery remains. - qm#963: Delivery-queue claims and recovery. The imported implementation has its own
claim_expires_at,FOR UPDATE SKIP LOCKEDclaims, expired-row sweep, and Slack poller with per-row expiry tracking and a three-attemptreclaimOwnershiploop. pg-boss can own job claiming, expiry, and retries instead of making the delivery subsystem implement another queue. Delivery records, Slack idempotency, and session ordering still belong to the application; they are not reasons to rebuild the queue mechanics. Status: the custom delivery queue remains. - qm#1419 (2026-09-22): Sprites backend used raw REST fetches; moved to Sprites SDK 0.2.3 (WebSocket exec, filesystem APIs, checkpoints); sibling PRs #1420-#1422 aligned Modal/E2B/Smolmachines with provider docs. Status: wound back in qm#1419.
- qm#1407 (2026-09-22): Internal Fly transports piped data through
fly ssh console(broke on Windows PTY); replaced with Machines exec API stdin. Status: wound back in qm#1407.
Non-durability (always reviewed, 5 examples)
This is a distributed system that needs work to endure through deploys and crashes without hiccups. A common agent mistake has been to store data in process memory. Postgres is effectively the core's filesystem -- think of core memory as a cache at best.
- Operational error history:
createErrorLog()kept the admin Errors history in a 5,000-event process-local ring buffer, even with a production database configured. A deploy erased the history; a request routed to another instance could not see it. The fix added Postgreserror_eventsand wired production reads and writes to it. Status: fixed; original code and fix preserved in qm#2140. - Scoped configuration: Soul overrides, command policies, egress rules, and publishing flags lived in process-local
Maps. Restarting core discarded the overrides and restored defaults. The fix added Postgres backing and hydrated the read cache before serving. Status: fixed; original code and fix preserved in qm#2140. - Pending Slack approvals: Run/Deny cards depended on
pendingSlackAgentRequests, aMapin the Slack process. Deploys erased outstanding requests; clicks routed to another instance could report that the request had expired and leave the originating status stuck. The fix persisted the requests and approval linkage in Postgres with atomic claims. Status: fixed; the Map removal and durable replacement are in qm#963. - Completed runs with missing replies: A run was durably marked complete, but enqueueing its delivery depended on an in-process
onTerminalcallback. A crash between completion and enqueue left finished work with no delivery. The fix scans persisted terminal runs and reconstructs missing deliveries. Status: fixed in qm#2053. - Webhook work already in flight:
runOnceguarded running webhook work with a process-localinflightSet, while the receiver's durable idempotency check recognized only committed fires. A retry routed to another instance during a long turn could start the same work again. Status: still present on main; qm#1943 proposes shared Postgres claims and includes a two-instance regression test.
Config-matrix expansion (always reviewed, 10 examples)
Helpful KPIs here are: how many env vars do we have? how large is the database schema? Implicit modes in the form of conditionals, text matching or hard-coded flags also count. The combined footprint of this config matrix is currently enormous and makes our task much more difficult. Driving this down is critical and PRs that reduce the size of these will be rewarded.
- qm#1784 (2026-09-30): Security screening had three overlapping env knobs (SECURITY_SCREEN_BACKEND, SECURITY_SCREEN_ALL_POSTURES, SECURITY_SCREEN_PROXY_ROLLOUT) plus per-posture inboundScreening; collapsed into one SECURITY_SCREEN=off|observe|enforce. Status: wound back in qm#1784.
- qm#1747 (2026-09-30): New SANDBOX_CAPABILITY_TTL_HOURS env var (48h default, or 0/none for non-expiring bearer tokens) right after #1518 hard-set 48h.
- qm#1619 (2026-09-25): Separate org runtime defaults for conversations, crons/loops and sub-agents, then per-cron overrides (#1593) and a fallback runtime (#1743): four overlapping runtime settings with precedence rules.
- qm#1201 (2026-09-15): Security screening gained an off/model/proxy backend plus ALL_POSTURES, four PROXY_* vars and a timeout, layered on HARNESS_SECURITY_POSTURE and the org-level Auto flagger settings from qm#878.
- qm#1208 (2026-09-15): EAGER_PROVISION was an opt-in flag no deploy template set, so every deployment missed a 66s-to-3s median speedup until it defaulted on. Status: partly wound back; flag remains (src/config.ts).
- qm#1162 (2026-09-14): Gateway deployments had to maintain an environment model allowlist; replaced by discovering models from the gateway's key-scoped list. Status: wound back in qm#1162.
- qm#1044 (2026-09-10): The unified
sandboxtool shipped behind SANDBOX_RESOURCES_ENABLED alongside the legacy execute/background tools, so two tool surfaces and both flag states must be supported. - qm#922 (2026-09-04): Another sandbox backend (agent37) was added, bringing providers to about ten (agent37, aws, e2b, modal, porter, smolmachines, sprites, superserve, local/docker); qm#954 proposed Kubernetes as well.
- qm#876 (2026-09-02): Porter added as yet another SANDBOX_BACKEND and DEPLOY_PROVIDER (plus a Helm chart), shortly after Modal and E2B.
- qm#478 (2026-08-13): Added smolmachines as yet another sandbox backend, then SMOLMACHINES_CPUS/MEMORY_MB/DISK_GB env knobs (qm#507).
Provider drift (2 examples)
Every sandbox provider should give the agent the same machine. When one provider's image or behavior quietly differs, the agent hits failures that only reproduce there.
- Adding a provider that subtly deviates from the general sandbox contract (this has been done several times). For example, ordinary teardown parks E2B, AWS, and local Docker computers, while Modal needs different handling. qm#1748 removes a
backend !== "modal"locking exception and introduces aparksOnTeardownprofile property; the distinct lifecycle behaviors remain part of the matrix. - AWS CLI v1 on E2B and Modal (2026-10-09): The E2B and Modal sandbox images came with no AWS CLI at all. When the agent needed
aws, it ranpip install awscli, and pip only has version 1. Version 1 can't do device-code sign-in. The Fly, Porter and Superserve images already included version 2, so only these two were missing it.
God files (4 examples)
Letting one file or module absorb unrelated responsibilities until changes require understanding the whole system. Keep responsibilities with clear owners and boundaries; splitting a file by line count alone does not untangle those responsibilities.
- qm#1296 (2026-09-16): The AWS deploy backend keeps absorbing capacity proofs, ownership handover and candidate logic.
- qm#1636: Core orchestrator.
src/core/orchestrator.tsabsorbed memory-audience changes, transcript cutoffs, provenance dependency capture, harness resets, and approval invalidation inside the same turn-execution closure. The diff shows memory-disclosure policy reaching into retry replay and pending approvals; those responsibilities remain embedded in the orchestrator despite separate memory helpers. - qm#175: Chat surface.
plugins/web-ui/src/chat.tscombines transcript rendering and pagination, pane lifecycle and URL state, command-approval submission, active-run recovery, session forking, and connector-auth widgets insidecreateChatSurface. The multiview change rewrites that shared closure, so changing how a pane mounts also means navigating approval and execution-recovery state. Status: these responsibilities still sharechat.ts. - qm#488: Web server entrypoint.
plugins/web-ui/server/index.tscombines session cookies and sign-in, OAuth callback forwarding, core API transport, request-body handling, and route handlers for sessions, files, projects, and crons. The PR extracts a shared router and relay helpers, but keeps the product domains and authentication flows in the same server module. Status: partial cleanup; the shared entrypoint still owns those domains.
Mismatched UI (4 examples)
Building interface pieces that disagree with the surrounding product in appearance, structure, or interaction. Reuse the existing components and design rules, and check the result in its containing surface so duplicated chrome and inconsistent typography do not slip through.
- Admin UI versus web UI: The same product has separate admin and web interfaces with different layouts, styling, and controls. Moving between them should not feel like switching products. qm#1441 migrated admin views to Lit while explicitly retaining the existing admin CSS and layout; sharing a rendering library did not unify the UI.
- Artifact pages under Browse: Skills, crons, webhooks, apps, and files each developed their own search, filters, and toggles when they could use more or less the same controls. Skills has its own scope/source/status filters; crons has ownership tabs and a disabled toggle; apps has another tab implementation; files renders its own search and ownership/type filters. The implementations are visible together in qm#963. Artifact-specific options can vary, but the common search, filtering, and toggle interactions should be shared across Browse.
- qm#1545 (2026-09-22): Transcript elements each hardcoded their own font-size, so multiview panes showed 15px/14px headers beside 12px text; unified on one --chat-font-size base. Status: wound back in qm#1545.
- qm#513 (2026-08-13): Multiview panes showed two stacked headers (pane chrome plus the hosted chat's own top bar). Status: wound back in qm#513.
Over-indexing on YC (3 examples)
QM is for organizations in general. YC's usage guides development but architecture should not be warped to fit our purposes and customizations. Leaking YC data is a separate concern.
- Dedicated people-directory configuration: Role lookup from a people website became its own People directory admin card, org-wide URL setting, and save path in the generic product. This could have been ordinary organization knowledge or a deployment tool. Status: qm#1441 removes the dedicated admin card.
- qm#1008 (2026-09-09): A 29-file 'software factory' loop (Linear auto-triage, forge ship contract) built for YC's own workflow was ported into public src/loops/factory before it had ever run end to end. Status: wound back in qm#1026.
- qm#530 (2026-08-15): Assistant and org names were fixed across prompts, manifests, auth and UI; made deployment-configurable with neutral defaults. Status: wound back in qm#530.
Regex (3 examples)
Some people, when confronted with a problem, think “I know, I'll use regular expressions.” Now they have two problems.
Main has 1,443 production regex sites in 380 files; cleanup is the regex-removal backlog.
- qm#1354 (2026-09-17): The web UI re-parses shell commands with a hand-written tokenizing regex (and sniffs
sed -n Npand Markdown headings by pattern) to decide how to present tool activity, instead of using the structured call data. - qm#1210 (2026-09-15): Composio consent links are found by regex-scanning model text for URLs and Markdown links, then stripped with dynamically built
RegExps, rather than arriving as a typed connector-link field. - qm#169 (2026-08-03): The agent self-API allowlist is a chain of path regexes (
/^\/v1\/projects\/[^/]+$/, …) instead of matching on the router's declared routes.
YC info leaking into public qm (2 examples)
Publishing organization-specific information in the public repository through code, docs, fixtures, screenshots, or change descriptions. Inspect outgoing content for private identities, operational details, and internal references; keep private deployment material in private storage.
- qm#1315 (2026-09-16): The generic web UI onboarding says 'the agent harness we use to run YC' and 'your YC partner in a box', and the welcome ideas cite 'YC Deal' and 'the YC investor database'.
- qm#1504 (2026-09-22): Public docs/test fixtures had org-specific rollout guidance and identity examples plus 92 tracked screenshots (8.1 MB); scrubbed and AGENTS.md now bans committed screenshots. Status: wound back in qm#1504 (partially; YC welcome copy remains).
Discarded error evidence (3 examples)
Replacing or swallowing a failure so the evidence needed to diagnose it disappears.
- Sandbox startup and cleanup (2026-10-02): A provider authentication failure reached the tool transcript as a bare "Command execution failed." The cleanup wrappers in
src/sandbox/sandbox.tsdropped the original causes, destruction retries insrc/core/orchestrator/sandboxes.tsswallowed every exception, andsrc/harness/agent-tools.tsrecorded only the generic status. Status: wound back in qm#2018. - Rejected session titles:
sanitizeTitlereturnedundefinedfor rejected model output. The fallback title made the request look successful, while neither the rejected text nor the rule that rejected it reached the error log. Status: qm#1303 records the rejection rule and a bounded sample in the durable error log. - Failed transaction rollback:
withPgTransactionand copied transaction handlers awaitedROLLBACKbefore rethrowing the original error. If rollback also failed, its exception replaced the actual transaction failure. Status: still present on main; qm#1834 proposes preserving the original error and discarding the broken connection.
Historylessness (always reviewed, 5 examples)
Growing the codebase to keep every path it ever took alive. The canonical case: a conditional that string-matches IDs or prefixes because older rows lack a typed field. Trust the typed field; rows without it get no special handling, and if they matter, migrate them once. The same goes for compatibility shims, dual reads and writes, and old endpoints or data shapes kept beside their replacement. Make the breaking change and let people adapt; agents can usually smooth over the breakage for existing users. If keeping an old shape truly seems necessary, ask the user rather than building it. The codebase represents the system at its current best, without sedimentary layers of how it got there.
- Sidebar surface (2026-10-14): The web sidebar's
surfaceOf(plugins/web-ui/src/sessions.ts) is a conditional that string-matchedthreadRefprefixes (web:,dm:,ch:) because older sessions lacked a typedsurface, and trusted the stored field only for subagents. When automation sessions landed in the wrong group, the proposed fix added more prefixes. Every session now stores its surface; read that field alone. Status: still present on main. - Credential capture redesign (2026-10-08): The login-as-a-skill handoff planned that "a stale skill calling /v1/keychain/use gets a clear refusal" and that "logins saved under the old capture system still load." Both keep the old system on life support. Remove the endpoint and the old capture system outright; stale skills fail and get fixed. Status: qm#2146 deletes
/v1/keychain/use(open). - Sandbox routing retirement (2026-10-02): Retiring
sandbox_routingcame with a locked, marker-guarded one-time upgrade import that preserved legacy IDs, explicit nulls, and machine IDs, kept the old table as rollback input, and told operators to keep credentials for legacy-mapped providers. A plain migration would have done. Status: merged in qm#2019. - Session tape beside session entries (2026-09-16): The tape was meant to replace
session_entries, but caution kept both.SESSION_TAPE_MODEshadow/serve chose between them, exact-transcript annotations were added so the tape could reproduce every historical field, and a verifying, lease-aware operator backfill with dry runs and partial-scan statuses guarded the cutover. We got stuck here for weeks; one migration and a hard cutover would have left one system instead of two. Status: both are gone. Code reads and writes onlyitems; startup migrations copy older history into it and then dropsession_tape,session_entriesand the separate search table. - Legacy Inbox loop:
src/loops/inbox-migration.tsmigrates each owner's legacy Inbox loop at runtime, with its own phase record in UI-state preferences, a pause-and-disable sequence, and a repair path for half-finished runs. Status: still on main; deletion approved for the loops rebuild.
Over-testing
Description and examples pending human input.
Prod-like QA (1 example)
QA every behavior change in a prod-like setting on the real system it touches before merge. Mocks and unit tests don't count; a missing credential is a blocker to solve or report, not a reason to merge.
- Shipped changes for a provider without testing on that provider (2026-10-12): qm#2226 changed how the Fly deploy provider starts and supervises apps, and merged with only mocked unit tests for Fly. The agent had no working Fly token and shipped anyway.
Performance (3 examples)
Doing unnecessary work, repeating expensive work, or making independent work wait. Watch for hot polling loops, unbounded database reads, and locks that serialize unrelated operations. Token costs belong under Token performance.
- Polling that writes even when nothing is due: The cron scheduler sent a synthetic tick job every second and repeatedly submitted cron jobs. Idle scheduling still generated database writes and queue churn. Status: qm#2007 removes the per-second tick job; mutation and completion hooks keep jobs current, with periodic repair on the scheduler lease holder.
- Unbounded admin list queries: The session list combined per-row JSON subqueries with full-history sorting and OFFSET pagination. A routine admin page could saturate Postgres and starve the shared connection pool used by actual turns. Status: qm#2076 makes the list query bounded and removes repeated work from each row.
- Serializing independent sandbox operations: A single exclusive machine lock covered commands, file operations, and status checks. One long-running command blocked unrelated sessions using the same computer. Status: qm#1748 lets ordinary operations share the lock while keeping destructive lifecycle operations exclusive.
Token performance (4 examples)
Tokens are the hot path. Anything sent to the model on every turn is paid for on every turn, in money, latency, and attention. Keep prompt prefixes stable so the KV cache holds, and make every token in the system prompt and per-turn context earn its place: no duplicates, no inventories the request doesn't need, no boilerplate repeated per item. Default to small and let the agent fetch the rest on demand.
- Defeating prompt caching with incidental changes: Changing countdowns, fresh sandbox/job snapshots, and inventories rendered in discovery order kept changing otherwise reusable prompt prefixes. Status: qm#1618 stabilizes snapshots and timestamps; qm#1690 fixes the remaining inventory-order churn.
- Per-turn
<environment>note: Memory, scheduled-work snapshots, and time blocks ride along with every turn whether or not the request needs them. The whole memory notebook is sent at session start and after compaction, then as deltas; on a long-lived DM that is hundreds of bullets. The spec describes vector recall, but the whole notebook is sent. Status: still present on main. - Redundant system prompt: The same instructions appear more than once: scope guidance that copies the org block and adds to it is re-sent in full (only exact matches are skipped), and every credential is listed twice (as execute handles and again as keychains), on top of a full skills index and a shared-file listing. Caching softens the price, not the attention cost. Status: still present on main.
- Per-fact provenance in memory: Each remembered fact carries its own date and scope tag, and the same fact recalled from two scopes is printed twice, so the boilerplate grows linearly with the notebook. Dedupe across scopes when rendering, save each fact once, and state provenance once per group. Status: still present on main.
Honorable mentions
These can be addressed by a single combined reviewer.
- Screenshots: The repo is not to be cluttered with screenshots or images of any kind that aren't actually used in UI.
- Code comments: Not allowed -- these historically have reinforced drift and reward-hacky agent decisions, providing air cover for future agents to do the same.
Admin Model Verification (`docs/admin-model-verification.md`)
Admin-managed model verification
In the admin model registry, Verify and enable checks a proposed OpenAI or Anthropic model before saving it. Only organization administrators can run the check. API clients must explicitly consent by including verify: true with the registry PUT; it is not part of the model definition.
The check uses Pi's model runtime, the selected template's protocol, the exact model ID, and the organization's serving credential and endpoint configuration. It sends only synthetic text and a synthetic tool schema; no tools are executed or conversation data sent. It requires a completed, nonempty text response. The output cap is the lesser of 128 tokens and the model's configured limit. Fast-mode definitions require ordinary and fast checks. Verification has a 15-second deadline; provider retries may apply. These requests may incur provider charges.
A failed check does not create or save the proposed definition. Failed edits preserve the previously saved settings. A failed recheck of the same saved settings removes their earlier verification. Errors distinguish missing credentials, access denial, unavailable models, quota/rate limits, rejected configurations, timeouts, and other provider failures without returning raw provider details.
Successful checks store a timestamp and a private fingerprint of the definition, resolved template metadata, and serving credential/configuration revision. Each durable refresh checks that fingerprint without making another provider request. Relevant changes invalidate verification and remove the model from selectable choices until an administrator verifies it again. Previously stored definitions without verification are unavailable until checked. Existing selected IDs are preserved rather than silently replaced.
Slow probes run outside the registry write lock. Before saving, the store rechecks the definition, serving configuration, and prior stored revision. A late result cannot undo a deletion or overwrite another edit.
What verification does not promise
- The timestamp records a successful check at that time, not permanent provider access. Entitlements, quota, or provider behavior can subsequently change.
- Organization verification does not certify any user's personal API key or subscription. Existing credential boundaries still apply.
- A small probe does not certify prices, the maximum advertised context/output limits, image support, every reasoning level, or correct tool execution. Check provider documentation for those fields.
- Configuring a model does not add support for a new protocol or native harness. Admin-managed API models remain Pi-only in normal use.
- Live provider compatibility requires running the check against that provider. Development tests use local streaming provider fixtures.
Find a model before entering metadata
Start with Provider and Model ID, then Look up model. Exact matches use the bundled, maintained model catalog, including models not currently offered in the picker. The result shows its source and catalog generation date when available. Existing models can be verified and added to the web picker without cloning them or changing the organization default. This uses existing built-in model policy; it does not create an admin-managed override or persistent overlay verification record.
For a new ID, lookup requests that exact record from the selected provider using organization credentials and configured endpoints. Redirects are refused. Lookup is bounded to five seconds and 64 KiB. Available name and token-limit fields are imported; missing prices, limits, and the compatible protocol template are requested explicitly. Provider APIs do not consistently publish pricing or complete metadata. Unknown values are never replaced with values from a similar model, and no third-party pricing source is queried.
The common path requires only the initial two inputs. New-model details that are already known remain under Advanced overrides, while missing fields are shown separately. All new or edited definitions still require Verify and enable. Metadata lookup is not a generation-access test and cannot certify a personal credential. Editing the provider or model ID clears a prior lookup result. The existing full-definition PUT remains supported.
Background Ownership (`docs/background-ownership.md`)
Background ownership
Production runs two core deployments (blue and green) against one PostgreSQL database. Exactly one of them may run background work at a time: Slack ingress, run claims, cron callbacks, and periodic maintenance. A single shared owner record names that deployment. Every core process polls the record and admits background work only while it names the process's own deployment.
Enable the capability
Configure every core replica with:
| Variable | Requirement |
|---|---|
BACKGROUND_DEPLOYMENT_ID |
An immutable identity shared by replicas of one deployment revision, distinct from every other deployment revision. |
DEPLOYMENT_CONTROL_SECRET |
At least 32 characters without surrounding whitespace, distinct from CORE_SIGNING_SECRET; distribute only to core and the trusted deployment operator. |
CORE_SIGNING_SECRET |
The normal source request signing secret. |
DATABASE_URL |
The shared PostgreSQL database. |
With BACKGROUND_DEPLOYMENT_ID set, BACKGROUND_WORK_ENABLED is ignored: the owner record alone decides. A fresh database has no owner, so no deployment runs background work until an operator sets one. Without BACKGROUND_DEPLOYMENT_ID, the process follows BACKGROUND_WORK_ENABLED as before and the legacy build-heartbeat supersession applies.
The owner record
The record lives in the background_ownership durable map under the key ownership:
| Field | Meaning |
|---|---|
ownerDeploymentId |
The deployment that may run background work, or null for nobody. |
setAt |
When the owner last changed. |
setBy |
The deployment whose API accepted the change. |
There are no per-process rows. Processes never write the record; only the operator endpoint does. A record written by the earlier member protocol (enabled, desiredDeploymentId, members, …) is read as enabled ? desiredDeploymentId : null; reads and no-op sets leave it untouched. The next owner change rewrites it, and the rewritten row also carries enabled: true, desiredDeploymentId equal to the owner, and the previous generation and members so that processes and CLIs still running the member protocol keep reading the same owner, acknowledge their relinquish normally, and refuse to admit: a build that predates this record can be replaced through the normal qm up path. If that older build's CLI changes desiredDeploymentId after the rewrite, the member fields win until the next owner change, so both builds always agree on the owner; do not use the older CLI once this one has set an owner. Ownership cannot be handed back to a color that still runs the member protocol with this CLI (its endpoint rejects the new request body), so the first rollout rolls forward only. The compatibility fields can go once no deployment runs the member protocol.
Operator endpoint
GET /v1/background-work and POST /v1/background-work require both normal source request signing and Authorization: Bearer <DEPLOYMENT_CONTROL_SECRET>. Agent capabilities cannot invoke these routes. Portal forwards only those exact methods and path, preserving the supplied credentials.
Status is:
{
"protocol": 2,
"deploymentId": "core:release-b",
"instanceId": "2b1c…",
"ownerDeploymentId": "core:release-a",
"setAt": "2026-10-04T18:02:11.148Z",
"setBy": "core:release-a",
"active": false
}deploymentId and instanceId identify the responding process; active says whether that process has finished starting background work and is admitting it. Because requests reach one process behind the load balancer, active describes one process; the CLI polls until it has seen every expected process of the deployment report the same state.
To change the owner, post:
{ "ownerDeploymentId": "core:release-b", "expectedOwnerDeploymentId": "core:release-a" }expectedOwnerDeploymentId is an optional compare-and-swap guard: the change is refused with 409 background_ownership_conflict when the current owner is neither the expected one nor already the requested one. Setting the owner to the value it already has is a no-op success, so a lost response is confirmed by re-reading the record or by repeating the same request. Set ownerDeploymentId to null to pause background work everywhere.
What processes do
Each process polls the record every second. When the record names its deployment, it starts background work: run claims, the cron scheduler, maintenance, and Slack ingress. While it owns, it keeps a local validity window of ten seconds that each successful read renews; a failed or hanging read lets the window expire, which fences new local claims until the next successful read confirms ownership again.
When the record stops naming its deployment, the process stops claiming new work and closes its Slack ingress within one poll interval, or within the ten-second validity window if its reads are failing. The new owner may start inside that window; run claims stay exclusive through the run store's leases, not through ownership, so the overlap costs at most a few redundant poll cycles. Turns and callbacks already admitted continue under their existing leases and heartbeats until they finish. There is no durable acknowledgment: the deploy workflow does not wait for old processes to report anything. It relies on ECS task replacement, which sends SIGTERM and allows a drain window, and on the new owner resuming any run whose lease lapses.
Synchronous turns and manually started cron callbacks are admitted work too. A deployment that is not the owner refuses new synchronous execution while still accepting durable asynchronous submissions for the owner to pick up.
Deploying
qm up and qm rollback refuse to replace a controlled stack's core tasks while the owner record names that stack. The deploy workflow therefore:
- Deploys the new release to the stack that is not the owner.
- Verifies it, then sets the owner record to the new stack (
awsSetBackgroundWork(..., true)), which waits until every one of the new stack's core tasks has reportedactive. - Switches public routing. The previous owner stops claiming within a second of the change and drains under
SIGTERMwhen its tasks are later replaced.
Rolling back ownership is the same operation in the other direction. Nothing needs to be retired, proven stopped, or cleaned up: a hard-killed process leaves nothing behind in the record. If the record names a deployment that no longer has tasks (for example after qm down), no background work runs until an operator sets the owner again; awsSetBackgroundWork(..., true) on the live stack does that, since its compare-and-swap expects whatever owner it just read.
Live deployment session check
A deployment that owns background work accepts POST /v1/deployment/live-session with the same source signature and distinct deployment bearer credential. The body contains exactly requestId (a fresh UUID) and expectedDeploymentId. The endpoint rejects unknown fields, a mismatched deployment, a non-owning deployment, and an inactive responder before starting work.
The check runs the same fixed session command used by the standalone deployment smoke: a real model reply, persisted turns, generated title, session error log, session archival, then the configured PostgreSQL catalog checks. The request cannot select a principal, URL, model, prompt, or command. Model HTTP requests have a five-minute limit; other HTTP requests and database operations have 30-second limits.
The response is newline-delimited JSON with an initial newline and five-second whitespace heartbeats, followed by one final object containing ok, requestId, deploymentId, and instanceId. A failed result adds a fixed error code without raw model, database, or credential details. Ownership is checked again before success. Heartbeats alone never prove success; the caller must receive and verify the complete final object.
Request IDs are durably consumed across replicas and cannot be replayed. Each process permits one check at a time, including its cleanup. Disconnecting the caller does not cancel the check or release that guard before cleanup finishes. An uncertain result must fail the release; do not retry automatically or fall back to a second canary execution.
Capability Tokens (`docs/capability-tokens.md`)
Capability token encoding and rollout
Room capabilities carry participant assertions for authorization. Large rosters appear
in both the publishing and keychain audiences, so uncompressed tokens can exceed HTTP
header limits before a request reaches core. Increasing core's header limit does not
fix limits imposed by upstream proxies.
CAPABILITY_TOKEN_COMPRESSION=1 enables compression of claim payloads larger than
4096 bytes for every capability audience. The signed envelope contains a base64url
raw-DEFLATE payload. Verification authenticates the envelope before inflating it,
limits decoded claims to 1 MiB, and applies the existing claim checks. Small tokens
retain their existing encoding. Tokens remain self-contained across replicas and
restarts; no authorization state is moved into a process-local cache. Compression
reduces roster repetition but is not a fixed-size token: unusually large or poorly
compressible claims can still exceed a deployment's header limit.
Compression is off by default for a staged protocol rollout:
- Keep
CAPABILITY_TOKEN_COMPRESSIONunset or0while deploying this verifier to
every core API replica, worker, and separately deployed egress proxy. Complete the
blue-green drain of older replicas before enabling issuance. - Refresh saved copies of the file upload helper from
/v1/files/upload-client;
older copies cannot decode compressed tokens. - Set
CAPABILITY_TOKEN_COMPRESSION=1on every process that issues capabilities,
including core and workers, and restart them. New turns receive compact tokens;
already-issued oversized tokens are not rewritten, so retry in a new turn.
To disable issuance, set the switch to 0. Both encodings remain readable regardless
of that switch. Do not roll verifiers back to a version predating compression until
all compressed tokens have expired or been revoked. Sandbox tokens live 48 hours by
default (see below); other capability audiences may have longer lifetimes, so inventory those
before rolling back. Leaving the updated verifiers in place is the safe rollback.
Sandbox token lifetime
Turn tokens handed to sandboxes (agent API, credential broker, egress proxy, OAuth
consent) expire 48 hours after the turn starts, which bounds how long background jobs
launched by that turn keep their access. Set SANDBOX_CAPABILITY_TTL_HOURS on every
process that issues capabilities to change it: a positive number of hours, or 0 /none for no expiry (exp: 0). The value is read
once at startup; an invalid value fails startup. Tokens already issued keep the
lifetime they were minted with. Revocation and scope checks still apply to
non-expiring tokens. This does not remove run/lease, background-job, provider, or
credential lifetime limits. Non-expiring bearer tokens increase exposure if leaked;
keep the default unless unattended work requires longer access.
Deploy the updated verifier to every core and worker, and any separately deployed
egress proxy, before enabling no expiry. Older verifiers reject exp: 0.
Changing the setting affects newly issued tokens only; rotate the signing secret
if existing non-expiring tokens must be invalidated.
Combined Services (`docs/combined-services.md`)
Combined web and sign-in services
QM deploys three application workloads: core, web-ui, and portal. The web-ui workload serves the chat interface and the admin module under /admin. Portal remains the public entry point and hosts the built-in auth broker when enabled. Slack remains in core.
The deployment's services list still declares capabilities. Keep admin to enable the admin module and auth to enable email sign-in. Their configuration remains under env.admin, env.auth, and the corresponding secret mappings. The CLI combines these into web-ui and portal respectively, rejects conflicting environment values, and preserves secret aliases. Omitting auth retains external OIDC sign-in. Logs and selective deployments for admin or auth resolve to the containing workload; the components cannot be released independently within a combined workload.
The modules retain separate handlers. Admin continues verifying signed portal identities and forwarding them to core, which authorizes every privileged action. Portal continues filtering inbound identity headers, checking sessions and CSRF, and gating admin access. The embedded auth broker listens only on 127.0.0.1:8099, with its existing browser route allowlist exposed through portal. Token and JWKS endpoints stay on loopback. Auth signing keys are now available to the portal process; core's cloud permissions do not move into either surface workload.
Upgrade an existing deployment
Record the existing release's source, image digests, configuration, secret versions, and workload sizes. Preserve database backups and the previous workloads for rollback.
Review the combined workloads' CPU and memory allocations, execution-role access to the union of their component secrets, and network access needed by the auth mail transport. Resolve conflicting environment or secret mappings before deployment.
Build web-ui and portal from the same source release. The CLI orders AWS routing transitions and Fly deployments with web-ui before portal. Ordinary AWS image updates can start both workloads together when the recorded and live cohorts are stable and their web/admin routing and enabled admin capability are unchanged. Missing or uncertain evidence preserves the ordered rollout. Deploy the combined web-ui before changing portal's admin upstream to its
/adminendpoint. Deploy portal with its embedded auth configuration and both names for the broker client secret. Keep the public issuer, callback URL, session keys, token secret, and signing JWK unchanged.
On AWS, a secrets push uploads values but defers task activation when the live tasks still use the separate-service configuration. Complete the image rollout withqm up; keep existing signing and session secret values during this migration.Verify signed-in chat, administrator and non-administrator access, login, logout, remembered sessions, and a live admin operation. Verify forged admin cookies are rejected, token endpoints are not publicly exposed, and streaming responses still work.
After qualification, scale the old admin and auth workloads to zero through the deployment provider. Docker containers can be stopped; Fly Machines and ECS services must have their old desired capacity explicitly reduced. The CLI does not destroy legacy workloads during this transition. Retain their images, configuration, and resource definitions until the rollback window closes.
Retire the obsolete infrastructure in a separate reviewed change. On AWS, new scaffolds contain only core, web-ui, and portal; existing
aws.services.adminandaws.services.authentries may remain temporarily to retain their Terraform resources. Do not blindly render and apply a plan that deletes rollback resources.
Rollback restores the old admin/auth capacity first, then the prior portal/web-ui image digests and routing configuration. This change introduces no database migration and does not rotate authentication keys.
The Helm chart treats admin/auth entries as module configuration and renders only their hosting workloads. Existing Helm releases require a reviewed upgrade because removing old Deployment resources happens with that upgrade. The separately deployable legacy image entrypoints remain available for rollback.
For source development, install root and web-ui dependencies. Portal-only package checks also need auth dependencies (npm ci --prefix plugins/auth). The local dev supervisor runs core, combined web, and portal; its localhost login bypass is for local wiring checks only.
Composio (`docs/composio.md`)
Composio through the QM backend
QM discovers apps, creates consent links, and executes Composio tools through its authenticated backend. Agent code never receives the project API key. The composio skill calls /v1/composio through the existing execute tool, retaining its command-policy and approval gates.
Setup
Store a personal keychain credential with env key COMPOSIO_API_KEY, or an org service credential with delivery: "env" and that env key. Existing records and service-credential grants remain compatible. The env key identifies a reserved backend credential: despite the legacy delivery label, QM never materializes it into a computer, command handle, or keychain-use script. Multi-field records containing that key are also backend-only.
An org administrator configures the project once. Users connect their own apps through QM's picker or agent-provided consent links. Composio handles provider authentication and refresh. Some apps still require provider setup. Never paste a project key into chat, scripts, or Git.
Authorization and execution
The runtime attests whether the initiating actor may use their connections in the current context. This follows existing owner-keychain access: personal conversations, explicitly Open sharing on a human-started shared turn, and authorized automations using their owner's keychain. Shared human requests recheck sharing posture. Org credentials require current grants for the conversation audience. Published apps and bot actors cannot use this API.
The backend derives the Composio user ID from the organization and canonical QM principal. New consent uses that canonical identity; existing accounts under verified linked aliases remain available. Removing a link revokes access through that alias after the existing directory refresh propagates. Before every execution it verifies the chosen account's owner, ACTIVE status, disabled flag, and toolkit, then checks the tool and concrete version. Request bodies cannot supply another user identity or override authentication. There is no raw proxy, workbench, or tool-router execution endpoint. The generic credential broker rejects Composio destinations so it cannot serve as a project-wide bypass.
Tool discovery returns schemas; connection discovery returns account IDs and toolkit names, not credentials. Execution returns provider data and success/error status. Audit records contain the actor, scope, tool, account and execution stage without request arguments or response bodies. An uncertain execution failure is never retried automatically. Agent calls require the current running turn's lease; detached scripts cannot keep using this capability after the run ends.
Calls run through the ordinary execute tool. Existing policies inspecting provider URLs or CLI syntax must be updated to recognize the new QM API calls; these are not a semantic per-provider operation approval system. Strict posture continues to block direct control-plane mutations.
Browser callback verification
Enable callback identity verification before production use, including for personal projects. Backend execution isolation alone does not prevent a forwarded consent link from attaching the wrong human’s account. For a project dedicated to one QM deployment, configure Composio's project verifier URL as:
https://<QM web origin>/api/composio/callbackThe authenticated web surface passes the opaque session_uri to core. Core supplies the signed-in user's derived identity to Composio's connected_accounts/complete_auth endpoint. Agent capabilities cannot complete verification. The browser must be signed into the same QM account that started consent.
Composio ignores a link's callback_url when project verification is enabled. QM saves the original return URL and expiry in its durable store, keyed by principal and connected account, and restores it after successful verification. This preserves app-picker state and personal Slack linking. The web surface accepts only same-origin return destinations. The backend never changes project settings automatically.
Verification is project-wide. A project shared across several company deployments needs a trusted central verifier/router or separate company projects before enabling it. Do not point that shared project's verifier at one company's QM URL. Dashboard-originated connections cannot complete against a verifier that only recognizes QM users. See Composio's verifier contract.
Existing deployment cutover
A source or image pin must adopt this change before behavior changes. Existing credential records, grants, canonical user IDs, and connected accounts are preserved; upgrading alone does not disconnect users or rotate keys.
- Inventory project use across deployments and identify any personal credentials, generic broker records, custom skills, scripts, or scheduled jobs that directly call the SDK. Connections created under ad hoc user IDs require explicit reconciliation or reconnection; QM never guesses another account owner.
- Update custom callers to the authenticated
/v1/composioAPI. Standard seed-managed skills update through normal startup seeding. User-authored or deployment-layer overrides are not silently overwritten and must be updated separately. SDK scripts that expect a sandboxCOMPOSIO_API_KEYstop working after upgrade. - Deploy the same version to all core/worker instances that share the key and drain old runs. During a rolling mixed-version deployment, old workers can still deliver the old key.
- Rotate the Composio project API key and update its existing QM credential records after old workers are retired. Keep the same project so connected accounts and user IDs remain valid. Coordinate every deployment sharing the key. Historical sandbox files, snapshots, environment captures and scripts may retain old copies; blocking new materialization cannot revoke them. Rebuild or clean affected sandboxes as appropriate; rotation invalidates old key copies.
- Configure and test callback verification only after deciding project routing. Existing connections do not need new OAuth consent merely because verification is enabled. Verify one new connection and one read-only operation, plus affected scheduled jobs and shared conversations.
No production credentials, projects, consent settings, fleet pins, or connections are changed by installing this code in a development checkout. Rolling back to an older worker restores its ability to deliver whichever project key it can read and therefore reopens the old boundary.
SDK assets
Older images and the build pipeline may still contain the credential-free Composio SDK bundle. It is not an authorization mechanism and is no longer used by the standard skill. Keeping or removing that asset does not replace rotating previously delivered keys.
Credential Refresh Handoff (`docs/credential-refresh-handoff.md`)
Credential login and refresh
Design
Logging in is a skill the agent follows (skills-seed/interactive-login):
- The agent runs the native login as an ordinary background job with
HOMEand the XDG
directories pointed at a freshmktemp -ddirectory and relays the verification URL and
code. - After the job exits,
scripts/save-login.mjs <service> <dir>uploads only the files under
that directory throughPOST /v1/keychain/credentialsand deletes the directory. Secrets
never enter model context. The keychain limits a login to 500 files and 8 MiB. - Later commands get the login only by naming its handle in
execute.credentials.
Re-saving a service replaces the entry in place. The credential id is derived from owner,
service and slot, so the handle, owner and grants are unchanged; createdAt is kept, while
files, origin, label and expiry come from the new save.
Execution
src/credentials/execute-files.ts stages requested bundles in a private/tmp/qm-credentials.<random>/home, points the CLI at it, captures changed files after the
command and writes them back with keychain.updateFiles, which rejects stale writes. Only
the owner's executions write back; a grant uses the saved files as they are. Each execution
removes its own directory in finally.
Background jobs accept the same credentials handles. The staging plan, credential ids,
grant ids and baseline fingerprints (no secrets) are stored on the job's process_sessions
row (credential_files). Any path that observes the exit (background poll/stop, monitor,
reaper, reconcile on provision) re-checks the baseline against the keychain, writes
refreshes back, clears the column and removes the directory. If a write fails for a reason
other than a keychain rejection, the column and directory stay so a later observer or the
next provision retries. A start that fails after the process launched leaves cleanup to
these paths. The start call masks the job's env credential values in its initial output;
later polls are unmasked because no secret values are stored.
Directories older than two hours are swept when the next credentialed execution starts,
which is longer than any execution or background job may live, so concurrent operations never
remove each other's files.
Known gaps
- The staging directory is protected by mode 0700 and a random name, not by a separate user.
Another process running as the same user during the command can read it. - No proactive refresh for idle AWS sessions; the CLI refreshes only when a command runs.
- Refreshes made through a grant are not saved.
Deploy Directory (`docs/deploy-directory.md`)
Deployment directory contract
Contract v1 makes a QM deployment a committed, portable directory. The qm CLI is the only interpreter of that directory: it validates the same inputs it uses to render containers, task definitions, secret routing, and the agent-computer layer.
Layout
package.json pins the @yc-software/qm deployment engine at the exact version that scaffolded the directory, so the directory records which CLI interprets it rather than drifting with whatever version an operator has installed; contract: 1 remains only the compatibility floor. package-lock.json records the installed artifact. qm.config.jsonc is the deployment config. deployment.md and .codex/skills/deploy-qm/ are materialized package assets an operator can hand to an agent. sandbox/ adds tools and skills to agent computers; plugins/ adds services; .env.example documents the computed secret names; .env supplies local values and is never committed. qm init writes slack-app-manifest.yml for the optional Socket Mode bot. It also writes slack-sso-manifest.yml only when the portal is configured to use Slack OpenID. qm slack render refreshes the applicable manifests after publicUrl changes, and qm outputs returns their creation links and the web coordinates. qm init --target aws also vendors the reference infra/ Terraform module and its derived terraform.tfvars; the copy belongs to the deployment after generation. Init never overwrites an existing deployment config.
The sandbox layout is:
sandbox/
Dockerfile
tools/<id>/tool.json
tools/<id>/<binary>
skills/<id>/SKILL.md
skills/<id>/<text assets>The Dockerfile is optional when every declared binary is present in its tool directory. Skill assets delivered through the deployment-layer API are text in v1. Executable text scripts can be delivered as skill assets. Native binary tools must already be installed in the platform image.
Configuration
The root object requires contract: 1, orgId, publicUrl, target, and services including core. On AWS the sandbox substrate is an explicit choice: omitting the sandbox block runs named Lambda MicroVM images; declaring one requires sandbox.backend — "sprites" runs Fly Sprites, "aws" states the MicroVM default in the file. Unknown contract majors fail closed. target is docker, fly, or aws.
Common optional fields select the model, plugins, extra skill directories, per-service non-secret environment values, image overrides, sandbox settings, and an external security screen. botName (at most 31 characters, so the generated "`` SSO" app name fits Slack's 35-character cap) names the bot everywhere users see it — the generated Slack app manifests, the prompt identity, and sign-in pages — and orgName (at most 40) is how the bot refers to the organization; both default to neutral values and can be changed live from the Admin page's Branding card, which then takes precedence over the deployed values. sandbox.backend selects the aws-target sandbox substrate (see above); sandbox.baseImage records the digest-pinned build input for local layer builds; sandbox.env is non-secret runtime environment; sandbox.secretEnv lists org-wide secret names whose values are forwarded to every sandbox. securityScreen sets mode (off, observe, or enforce) and classifier (model, the default, or proxy). A proxy classifier adds a lowercase provider label and an HTTPS endpoint, and requires secretEnv.core.SECURITY_SCREEN_PROXY_TOKEN. Absence disables screening. See "Content screening" below.
Fly requires region and flyOrg. AWS requires a 12-digit account, region, deployment label, ECS cluster, deploy-role ARN, Secrets Manager prefix, DNS-valid Cloud Map namespace, and an entry for every enabled first-party service and discovered plugin containing a unique valid ECR repository, a unique valid ECS service, and a valid Fargate CPU/memory combination. The cluster is constrained so every IAM, RDS, ALB, and related name derived by the reference module is valid. imageLabel identifies the complete deployment manifest used by rollback and live drift checks; the matching OCI/ECR tag is a convenience pointer. Workloads may also set arm64/amd64 architecture, non-secret build arguments, or role ARNs. External prebuilt images must declare their architecture; built-in workloads default to amd64 to match published images, while source-built plugins default to arm64. Explicit architecture choices are honored for source builds. Cloud Map names are the private workload addresses. The reference AWS module exposes CloudFront over HTTPS and restricts its HTTP ALB origin to CloudFront's managed origin prefix. With portal enabled, it is the ALB's sole target; access to private core, web, and admin surfaces requires signed portal identity. Without portal, only core is an ALB target. A real harness requires an HTTPS publicUrl.
publicUrl is the one public coordinate. The CLI derives the core, Slack, web, admin, and portal URL environment from it. Config env is for non-secret values only; secret-shaped keys are rejected.
Security screen proxy
The proxy endpoint receives one or more HTTPS POSTs per bounded classification with content-type: application/json, the routed token in x-api-key, redirects disabled, and this body:
{
"text": "untrusted content",
"hook": "user_input",
"metadata": {
"surface": "webhook",
"origin": "automation",
"qm": {
"request_id": "uuid",
"input_index": 0,
"chunk_index": 0,
"chunk_count": 1
},
"provider-label": {
"request_id": "uuid",
"input_index": 0,
"chunk_index": 0,
"chunk_count": 1
}
}
}hook is user_input or tool_response; metadata fields appear only when known. The chunk coordinates are also mirrored under the configured provider label so a direct provider endpoint can consume its own namespace without a built-in adapter. Inputs are capped at 16,000 characters and split into overlapping 1,600-character requests with at most two in flight per classification. All chunks share a request ID. A successful provider returns finite score and threshold numbers from zero through one plus an optional lowercase primary_outcome label:
{
"score": 0.91,
"threshold": 0.7,
"primary_outcome": "prompt_injection"
}A chunk whose score is at or above its threshold resolves to Strict, and any Strict chunk makes the whole classification Strict. When chunks agree, the highest-scoring result supplies the diagnostics. The configured provider is an audit label and metadata namespace, not a built-in adapter name, so any service implementing this contract can be selected. Throttled requests retry with bounded backoff inside the classification deadline. Invalid responses, timeouts, redirects, and other provider errors are unavailable classifications, audited as error. Observe mode changes authority, not disclosure: it still sends the full screened content to the configured endpoint, so operators must trust that provider with external messages, files, and surface results.
Spend export
secretEnv.core.SPEND_EXPORT_TOKEN (at least 32 characters, without surrounding whitespace, and distinct from every other environment value) turns on GET /v1/spend/export?from=YYYY-MM-DD&to=YYYY-MM-DD for an external spend or chargeback system. Requests send Authorization: Bearer <token>; the window is whole UTC days, half-open, and at most 93 days long. The response carries scopeId, the window, asOf when the report is served from saved daily totals, rows at the Spend page's ledger grain — one per UTC day, scope, source, cron and model — each with scopeId, kind, principalId (people only, after identity links), displayName, source (live, or the background kind: cron, loop, swarm, webhook or monitor), cronId (the cron that ran it, including its subagents' calls, else null), calls, costUsd and the four token counts, and crons: each billed cron's id, title, owner, ownerScopeId, schedule, enabled and archived, without its task. A deleted cron keeps its cronId on rows but has no crons entry. costUsd is the same estimate the admin Spend page shows, not a provider bill. Without the secret the route answers 404, and every export is audited as spend.export. Portal does not forward the route, so callers use the origin that reaches core directly.
Secrets
First-party services publish a typed SecretSpec schema. The CLI combines the enabled services and feature predicates with plugin secrets and sandbox.secretEnv to form the computed secret set. That same schema determines which task receives each secret. Core validates its own required runtime secrets at production boot.
init renders the set as .env.example; that file has names and descriptions, never values, and is not an input to deployment. Operators place values in gitignored .env. Docker reads the file locally. qm secrets push uploads supplied operator-managed values to Fly secrets or AWS Secrets Manager without printing them. Terraform owns DATABASE_URL on AWS because it owns RDS. doctor treats missing and placeholder required values as failures and reports absent optional plugin secrets without blocking deployment.
Tool descriptors
Only id is required. The remaining fields buy these runtime guarantees:
| Field | Guarantee |
|---|---|
label |
Human-readable name in resident-login status. |
advertise |
Added to the agent computer's installed-CLI list. |
hints |
Added to the model's deployment-tool guidance. |
auth.check, auth.reauth |
Merged into the resident-login connector registry. |
auth.credentialPaths |
One $HOME-relative set of { path, kind } entries drives resident capture, ephemeral linking, and device-flow persistence. Each entry explicitly declares file or directory; absolute paths and traversal are rejected, and .ssh warns. |
egress |
Validated as host names and checked for dangerous wildcards. Runtime enforcement is not claimed in v1. |
approvals |
Appended to the command-policy floor. A rule may deny or require approval for its own tool; it may never add an allow or loosen administrator policy. |
install.binary |
Must be present in the layer, declared under install.files, or installed by its Dockerfile; the image build checks PATH. |
install.files |
UTF-8 text files beside tool.json with an absolute destination under /usr/local/bin/ or /usr/local/lib/ and an octal mode (default 0755 under bin, 0644 under lib). They ride the layer bundle and every sandbox backend converges them on each provision by content hash, so a tool reaches imageless machines too. |
Raw approval patterns must start with the canonical \b\b boundary and cannot use a top-level alternative, so every match begins with their own tool; nested alternatives after that prefix remain available. A command rule is safely anchored to that same effective binary by the CLI. Duplicate tool ids fail. Skills require name and description frontmatter.
Deployment-specific safety belongs here too. For example, an ambiently authenticated CLI declares a deny approvals rule for its login command, a hints entry telling the model not to log in, and its auth.credentialPaths; generic core carries no vendor-specific command exception or credential path.
Delivery and pins
When sandbox/ exists, every up sends its descriptors and complete text skill trees to source-authenticated PUT /v1/deployment-layer. Without sandbox/, up skips layer sync and leaves the deployed layer unchanged. Core validates submitted bundles again, stores them in Postgres table deployment_layer, versions them by a canonical SHA-256 content hash, records an audit event, hydrates them before serving, and returns the restorable bundle with its metadata and resolved runtime state from source-authenticated GET /v1/deployment-layer. Removed layer-owned skills are archived. Filesystem DEPLOYMENT_LAYER remains a bootstrap input for local and recovery use.
The sandbox handoff is a layer content hash. Sprites boxes boot the platform's stock image; the layer's tool descriptors and skills arrive through the deployment-layer sync. AWS with sandbox.backend: "aws" (or no sandbox block) uses infra build-image to package the guest agent as a Lambda MicroVM image and records its immutable image version and execution role. Service task definitions use immutable pins, not mutable tags.
Postgres stores create their tables lazily with idempotent DDL through the shared pool. The Terraform module creates RDS and its DATABASE_URL secret. On AWS, up records a point-in-time restore point before its first mutation — refusing an unavailable database, one whose automated-backup retention is below aws.dbRetentionMinDays (default 1), or one whose LatestRestorableTime lags more than QM_AWS_DB_MAX_RESTORE_LAG_MS (default 10 minutes) — timestamped in the deployment manifest it precedes, and re-checked after the roll (a note while the backup stream is still inside the allowed lag, a loud warning once the restore point has gone uncovered longer than that, since a point is restorable only once the stream covers it and only within the retention window); aws.predeployDbSnapshot: false opts a deployment out. Restore remains operator-run: rollback prints the restore point (or the legacy snapshot on older manifests) alongside the code it rolls back.
Targets and prerequisites
| Requirement | Docker | Fly | AWS |
|---|---|---|---|
Node 24 and qm CLI |
yes | yes | yes |
| Docker daemon | yes | build path | image transfer/build path |
| Agent-computer image and credentials | Fly app for real execution | Fly app and scoped token | Lambda MicroVM image/version and execution role |
| Slack bot app created from generated manifest, bot token, app token | when Slack enabled | when Slack enabled | when Slack enabled |
| Admin email, verified sender, and a Resend key or SMTP credentials | with the built-in auth broker |
with the built-in auth broker |
with the built-in auth broker |
Slack SSO app, client id/secret, team gate, and exact /auth/callback redirect |
only with Slack OIDC | only with Slack OIDC | only with Slack OIDC |
| Postgres | local container or supplied DSN | Fly Postgres/supplied DSN | Terraform RDS |
| AWS credentials, ECS/ECR/RDS/ALB/Cloud Map, exact GitHub OIDC trust | no | no | yes |
doctor checks target resources read-only. When user-owned CI is requested, the AWS account must already have the account-level GitHub provider at arn:aws:iam::<account-id>:oidc-provider/token.actions.githubusercontent.com; check it with aws iam get-open-id-connect-provider --open-id-connect-provider-arn <arn> and, if absent, have an account administrator run aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com. The AWS doctor verifies ECS, ECR, RDS, CloudFront-to-ALB routing, the deploy role and its exact operator-owned GitHub repository plus configured branch or environment trust, required secret values, and the Lambda MicroVM image pin. Environment-based trust must be paired with GitHub deployment-branch restrictions because its OIDC subject does not contain a branch. Fork pull requests cannot assume the deploy role. No workflow in the qm source repository deploys a production stack.
Commands, conformance, and versioning
The normal gate order is check, doctor, plan, up --yes, then check --live. On AWS with MicroVM sandboxes, infra build-image precedes plan. First-party services come from the package's matching image manifest; --build-from explicitly builds modified services from a source fork or contributor checkout; use the checkout's CLI when changing deployment logic too. check is static and has JSON output keyed to clause ids. doctor makes read-only external checks. plan renders without mutation. On AWS, rollback restores the prior recorded deployment manifest as one unit under the deployment lease; --to selects another complete manifest by manifest id or recorded release label. Because rollback restores code and configuration but never data, it prints the pre-deploy database point-in-time restore timestamp recorded on the deployment it rolls back (or the legacy snapshot for older manifests). Fly and Docker do not claim rollback.
AWS aws.dbInstanceClass selects the RDS class rendered as db_instance_class and defaults to db.t4g.small. Operators set db_backup_retention_days in infra/terraform.tfvars to override its 35-day default. aws.dbRetentionMinDays is separate: it is the minimum retention that qm up accepts for its restore-point safety check and does not configure RDS. Before applying, the account must permit the chosen class and retention in the selected region and account plan; AWS availability and plan restrictions vary, and the rest of the deployment is not guaranteed to fit any free tier.
Upgrading the CLI alone does not update vendored Terraform. Existing deployments must preserve their customized Terraform and tfvars, copy only the validated db_instance_class variable (including its db.t4g.small default) from the current infra/variables.tf scaffold, and change only the RDS assignment to instance_class = var.db_instance_class before setting aws.dbInstanceClass; then run qm infra render and review the Terraform plan before applying.
AWS up is mutually excluded by a DynamoDB lease, verifies RDS continuous backups and records the exact pre-mutation restore point, registers digest-pinned task definitions, enables the ECS circuit breaker, updates services, and waits stable. AWS check --live compares environment, secret routing, task definitions, and the configured release label in both directions. Fly check --live verifies every configured workload has a live image-bearing machine and the public health endpoint responds. qm conformance remains the later cross-check between the static contract and core's resolved deployment-layer descriptors.
The semver-stable @yc-software/qm/contract export contains only config loading, layer validation/parsing, env derivation, approval compilation, and the contract version; AWS task rendering joins it with the AWS backend. A new incompatible directory shape increments the contract major. A CLI may add optional fields within a major.
Built-in targets live in one registry that owns discovery, initialization files and ignores, accepted deploy flags, backend creation, and provider output coordinates. To add one, implement that provider contract and backend lifecycle (up, status, logs, down, rollback, doctor, secret delivery, and live checking), add a namespaced config block and templates, render through the shared environment and secret pipelines, document prerequisites honestly, and add conformance fixtures. Loading arbitrary provider packages at runtime is outside contract v1.
Clause status
| Clause | Status | Verifier |
|---|---|---|
config.v1 |
ENFORCED | loadConfigAt, qm check |
config.no-secret-values |
ENFORCED | qm check |
secrets.computed-set |
ENFORCED | typed schema, qm check |
sandbox.descriptors |
ENFORCED | validateSandboxLayer, core PUT validation |
sandbox.approvals-tighten |
ENFORCED | descriptor parsers, command-policy composition |
runtime.layer-resolved |
ENFORCED | deployment-layer store/API and core PUT validation, qm conformance cross-check |
aws.rendered-task |
ENFORCED | renderTaskDefinition, AWS plan/up, digest and task-diff tests |
aws.live-drift |
ENFORCED | qm check --live, bidirectional task/environment/secret/release checks |
sandbox.egress |
VALIDATED-ONLY | wildcard/host warnings in qm check; no runtime enforcement claimed |
sandbox.aws-substrate |
ENFORCED | Lambda MicroVM image/version validation and live drift |
target.provider-registry |
ENFORCED | provider registry and packed-artifact tests |
extension.deployment-data-proxy |
RESERVED | optional env-gated org adapter; not part of contract v1 |
ENFORCED means code rejects or tests the clause today. VALIDATED-ONLY means the directory is checked but runtime enforcement is explicitly absent. RESERVED names a compatibility slot without claiming implementation.
Published-app credential calls
When core has a signing secret and API base URL, it supplies each deployed app whose
publisher is an internal principal with AGENT_API_URL and AGENT_CREDENTIAL_TOKEN,
the same pair an agent turn receives. The app's server sends a POST request to$AGENT_API_URL/v1/credentials/broker with the token in x-agent-capability and the
same credential, url, method, headers, and string body fields the agent HTTP
credential broker accepts. Keep the token on the server; it authorizes calls as the
immutable publisher, not the person viewing the app.
Every enabled broker-delivery credential is available to published apps by default; an
administrator switches a credential off for apps without affecting agent turns. The
token is long-lived and names the app, so the broker refuses a credential whose switch
was flipped after the app shipped and the audit row records which app called. Disabling
the credential revokes access on the next call. The token is minted at deploy time from
the credentials switched on at that moment, so a credential added later reaches an app
on its next deploy.
The destination host, HTTP methods, and paths remain constrained by the credential
record. If its injection configuration enables actor attestation, the broker setsx-qm-actor from the stored publisher. Caller-supplied actor headers are rejected.
The destination receives the configured credential, never the deployment token.
Public AWS plugin routes
Plugins stay private by default. An AWS plugin workload can declarepublicPaths: ["/hooks/*"] under aws.services.hooks to expose its own
namespace. Each of the one to five unique patterns must start with the plugin
name and end in /*; broader catch-all paths and paths outside that namespace
are rejected. The plugin must authenticate its requests.
The AWS scaffold renders these paths before core host rules and portal catch-all
rules. Deployment preflight verifies the exact paths, target-group attachments
and listener precedence, including native blue/green weighted routing. Update
vendored Terraform with the public_paths service attribute andpublic_path_services routing before enabling this setting.
Sandbox egress proxy
Add "egress-proxy" to services to deploy the sandbox egress proxy
(deploy/egress-proxy) with every qm up, from the same release as core. It
receives CAPABILITY_SECRET and CORE_SIGNING_SECRET from the computed secret
set and relays its audit records to core.
On AWS the reference module puts it behind its own network load balancer, which
terminates TLS on 443 with the module's certificate_arn (required) and forwards
plain TCP to the proxy. The proxy runs in its own security group, outside the
services' private network, so it relays audit records through apiUrl (orpublicUrl). Point a hostname covered by the certificate at theegress_proxy_hostname output. On Fly it serves TLS on 443 at<appPrefix>-egress-proxy.fly.dev. Docker publishes the plain listener on a host
port and leaves TLS to the operator. Then set the sandbox backend's proxy URL, for
example env.core.SPRITES_EGRESS_PROXY_URL or MODAL_EGRESS_PROXY_URL, tohttps://. Update vendored Terraform from the current scaffold
before enabling it on AWS.
Shared AWS foundations
Existing AWS deployments retain their company-owned resources by default. For a shared DynamoDB deployment table, set aws.deploymentState to { "table": "fleet-deploy-locks", "namespace": "acme" }. Every lease, current pointer, label pointer and immutable manifest uses that namespace. Use a unique namespace for each company and scope its item IAM permissions with dynamodb:LeadingKeys to acme/*; grant DescribeTable separately. Changing an existing deployment's state location requires explicitly migrating its history while deployment operations are stopped.
To use a shared ALB, set aws.alb and aws.sharedAlb: true. The supported shared topology has one blue/green portal ingress per company, with an exact host-header condition plus /* path, a fixed-404 listener default, separate primary/alternate target groups and a company-owned production rule. Additional public plugin/core routes are not supported in this topology. Other companies' rules must use explicit non-wildcard hostnames and must not reference this company's target groups. Set each service's targetGroup, taskRoleArn, and executionRoleArn explicitly when sharing an ECS cluster. Retain company-specific service permissions and allow ECS to modify only that company's listener rule.
An exact --candidate deployment consumes its digest-pinned images without changing ECR tags. Its publisher owns image retention; retain every active and rollback digest. Builds performed by the deployment CLI still stage and promote their images as before.
Content screening
When securityScreen.mode is not off, the classifier screens external content — inbound data, attachments and documents, shared skills, steering context, and external tool output — under every security posture. Posture only caps enforcement: Dangerous scopes observe, and Auto and Strict follow the deployment mode. Scope posture changes cannot turn screening off; they keep their own tool approvals and private-network policy.
observeclassifies in the background and never delays a turn, quarantines, prompts, or adds untrusted-content markers. Each observation is bounded bySECURITY_SCREEN_TIMEOUT_MS, the same deadline enforce uses; there is no separate concurrency cap. Observed model screens are not charged to the user's budget.enforcewaits for the verdict. Flagged content is quarantined pending release approval. Outages, timeouts, and unscreenable content fail open with an untrusted-content marker and an audit record.
Every classification writes one security_screen.classify audit record with status allow, block, would_block, or error. Its detail carries the hook, source labels, surface, origin, session, run and thread identifiers, request ID, attempt count, and any score, threshold, or outcome — enough to find the transcript behind a would-block. It never includes the screened text.
SECURITY_SCREEN_BACKEND, SECURITY_SCREEN_ALL_POSTURES, and SECURITY_SCREEN_PROXY_ROLLOUT (and the backend, allPostures, and rollout config keys) are retired. A configuration that only said backend: "off" (or SECURITY_SCREEN_BACKEND=off) still loads as mode: "off" with a deprecation warning; any other use of the old names refuses to start.
New tool-output release approvals deliver the exact saved text without running the tool again. They are once-only and retain the source scope in durable history. A release is refused if the current audience is no longer entitled to that source scope. Legacy persisted screening grants remain effective and are audited; remove those grants explicitly if a deployment requires every result to be classified. New release approvals cannot create session or always grants.
Deployment Broker Credentials (`docs/deployment-broker-credentials.md`)
Deployment broker credentials
An operator can give every agent in a deployment access to shared API keys without storing them in the
settings database. Set them in the server environment, the same way as MODEL_GATEWAY_API_KEY. The core
reads them once at startup.
Agents use these credentials only through the credential broker. They never see the secret, and org admins
can see them in Settings but can't edit, delete or replace them.
Format
BROKER_CREDENTIALS is a JSON array. Each entry names the env var that holds its secret:
[
{
"slug": "web-search",
"displayName": "Web search",
"host": "api.search.example",
"secretEnv": "WEB_SEARCH_API_KEY",
"injection": { "header": "X-API-KEY", "scheme": "" },
"allowedMethods": ["POST"],
"allowedPathPrefixes": ["/search"],
"maxRequestsPerDay": 500
}
]| Field | Required | Meaning |
|---|---|---|
slug |
yes | Lowercase kebab-case name agents use. Must be unique. |
host |
yes | Bare hostname the broker pins requests to (subdomains match, as for stored credentials). |
secretEnv |
yes | Name of the env var holding the secret. It must be set and non-empty. |
displayName |
no | Label shown in Settings and to agents. Defaults to the slug. |
injection |
no | header and scheme for the secret. Defaults to Authorization: Bearer <secret>; set scheme to "" to send the raw secret. headers adds fixed, non-secret headers the API needs on every call, such as {"x-api-version": "2025-11-01"}; callers cannot override them, and they cannot replace the secret's header, content-type, or transport headers. |
allowedMethods |
no | Allowed HTTP methods. Defaults to GET. |
allowedPathPrefixes |
no | Allowed path prefixes. Defaults to any path. |
maxRequestsPerDay |
no | Cap on calls let through per UTC day, shared by every core process. Calls over the cap get 429 daily_cap_reached. |
Never put a secret in BROKER_CREDENTIALS itself; a JSON syntax error can quote part of the value.
Unknown fields, a missing secret, a duplicate slug or any invalid value stop the core at startup with an
error naming the entry. Errors never include secret values.
Behaviour
- Who can use them: every conversation whose participants all belong to the org, as if the credential
had an org-wide grant. Admins can't narrow that. Published apps can't use them. - Delivery: broker only. They are never injected into sandbox environments.
- Precedence: a deployment slug is reserved. Lookups return the deployment credential even if the
settings database still holds one with the same slug, and admins can't create, edit or delete a stored
credential with that slug. - Broker only, everywhere: decision providers and skill-pack Git auth can't use a deployment credential.
- Usage: broker calls are logged and summarised in Settings like any other credential.
Operating
- Put each secret in your secret store and expose it to the core as its own env var.
- Set
BROKER_CREDENTIALSto the JSON array and restart the core. - To rotate a key, change the secret's env var and restart. To remove a credential, drop its entry and
restart. - If the settings database already holds a credential with the same slug, the core logs a warning at startup
and the stored one is ignored. Delete the stored one before adding the env entry; otherwise it becomes
active again if the env entry is later removed.
Do Managed Agents (`docs/do-managed-agents.md`)
DigitalOcean Managed Agents sandboxes
The do-managed-agents backend runs each QM scope on a DigitalOcean Managed Agents microVM. QM keeps its own agent loop; sessions start with agent: none, so DigitalOcean supplies the machine and nothing else.
Set it up
Create a DigitalOcean API token with Managed Agents access.
Point core at it:
shSANDBOX_BACKEND=do-managed-agents DO_AGENTS_API_TOKEN=your-token
That's enough to run. Sessions land on DigitalOcean's bare base image.
Optional
A QM template. For an image with the tools agents usually reach for (Node, git, jq, ripgrep, Python), register one on your team. You need Docker, an authenticated doctl, and a container registry:
deploy/do-managed-agents/register-template.sh
DO_AGENTS_TEMPLATE=qm-sandboxTemplates belong to a team, so each team registers its own. Rerun the script after changing the Dockerfile.
A snapshot bucket. When a scope parks, QM takes a DigitalOcean checkpoint, which lets it roll a wedged computer back in place. A checkpoint is tied to its session, though, so if the session is deleted the checkpoint goes with it. Set DO_AGENTS_SNAPSHOT_S3_BUCKET to also keep a copy of the home directory that survives that. Without it, the copy lives in core's memory and is lost when core restarts.
An egress proxy. Set DO_AGENTS_EGRESS_PROXY_URL and the proxy host becomes the session's only allowed destination.
Other knobs: DO_AGENTS_SIZE_SLUG, DO_AGENTS_IDLE_TIMEOUT_SEC, DO_AGENTS_NAME_PREFIX (use a distinct one per deployment sharing a team), DO_AGENTS_SNAPSHOT_INTERVAL_SEC, and DO_AGENTS_API_BASE_URL.
Good to know
- Commands and file transfers go over a port-forward to the microVM rather than DigitalOcean's REST exec, which caps output and runtime. Long commands are bounded only by
SANDBOX_TIMEOUT_SEC. - QM doesn't reap idle machines itself. DigitalOcean's idle timeout does, and paused sessions still count against team quota.
- Sessions allow
bashoutright, since QM already runs its own approval gates. scripts/managed-agents-sandbox-smoke.tschecks a token and template end to end before you point a deployment at them.
Document Inputs (`docs/document-inputs.md`)
Conversation document inputs
Inbound attachments are read from the authorized artifact store and included in model input on the first turn, follow-ups and uploads during an active answer, including after compaction and runtime handoff. No model tool call is needed to locate or parse supported attachments. Filename extensions and supported MIME types identify documents. Images continue through the existing image input path.
| Route | Native input | Other documents |
|---|---|---|
| OpenAI Responses | PDF, Word, Excel, PowerPoint, ODT, text and code | ODS, ODP and RTF use text extraction |
| Anthropic Messages and Claude SDK | PDF and plain-text document blocks | Text extraction |
| OpenAI Chat Completions | PDF with vision models | Text extraction |
| OpenRouter Chat Completions | PDF on vision-capable OpenAI, Anthropic and Google models, with the native parser explicitly selected | Text extraction |
| Gemini and Vertex | PDF with vision models | Text extraction |
| QM gateways | Responses files for OpenAI/Azure groups; native PDFs for verified provider families | Text extraction for unknown providers |
| Other model routes | No assumed native document capability | Text extraction |
PDF native inputs retain page visuals. Non-PDF OpenAI file inputs extract text; embedded images and charts are not preserved. OpenAI spreadsheet processing covers up to the first 1,000 rows per sheet, with additional metadata. Native support remains subject to the selected model, provider limits, document encryption and document validity. See the OpenAI file input documentation, Anthropic PDF documentation, Gemini document documentation, and OpenRouter PDF documentation.
Text fallback handles UTF-8 text/code, CSV, TSV, PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP and RTF. It explicitly identifies missing images/charts, empty extraction, truncation, unsupported formats and malformed documents. Legacy DOC/XLS/PPT and Pages/Keynote require a supporting native route or conversion. Scanned PDFs require a native visual route or OCR; text fallback does not pretend to read them.
At most ten documents and 8 MB of original bytes are included per turn, newest attachments first. Extraction runs in cancellable workers with a ten-second deadline, memory and decompression limits. Each extraction is capped at 200,000 characters and combined fallback input at 100,000 characters. Pi routes further constrain text to the remaining model context. Binary provider inputs remain subject to provider page and token limits.
Authorization and integrity checks run on every turn. Document content is untrusted data; the external-data security policy applies when enabled. Unscreenable visuals remain marked unscreened. Stored conversation tape does not duplicate the injected document bytes or extracted text; authorized documents are rehydrated from their artifact references.
QA
The checked-in fixtures contain synthetic verification markers. Format tests exercise genuine PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, ODT/ODS/ODP, RTF, TXT, Markdown, CSV/TSV, JSON, XML, HTML, YAML, Python, JavaScript, TypeScript, CSS, SQL, EML, ICS, VCF, SRT and VTT files. Additional cases cover scanned/malformed PDFs, hostile instructions, MIME-only filenames, byte/context limits, cancellation, revoked authorization, failed historical reads, follow-ups, compaction and tape redaction.
Provider serialization tests verify request shape and byte preservation; they do not establish live provider acceptance. Before release, run real model calls for each enabled native route with its credentials, including scanned PDFs and unsupported/encrypted files. Check both initial uploads and follow-ups through web and Slack, verify each independent marker, inspect tool calls, and record latency separately from sandbox startup.
Recorded development validation
The browser upload flow on the Codex text fallback route recovered all 26 format-specific markers in PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP, RTF and the 18 text/data fixtures. An eight-document follow-up recovered the same markers without reuploading. A mixed batch retained all readable documents and explicitly reported scanned and malformed PDFs unavailable. These turns made no model tool calls.
A real Claude SDK request using the same document-block builder recovered the native text-PDF and scanned-PDF markers, plus DOCX/XLSX/PPTX text fallback and a native plain-text document, in 8.0 seconds end to end. This is a small-fixture smoke test, not a latency guarantee.
Firefox Slack uploads recovered all 26 supported fallback-format markers across three batches, with zero model tool calls. Legacy DOC/XLS/PPT correctly reported unavailable on the Codex fallback route. Worker execution took 6.1–13.7 seconds per batch, excluding Slack delivery and conversation routing. A Slack follow-up recovered project and revenue details from the retained RTF/ODP attachments without reuploading or tools. The same synthetic fixtures were used in the web and Slack checks.
Direct OpenRouter native acceptance remains unverified because the configured gateway exposes no OpenRouter provider and no direct credential was available. Request-shape tests cover that route; they do not substitute for provider acceptance.
Authenticated LiteLLM checks verified scanned PDF comprehension on Anthropic Messages and OpenAI/Gemini Chat Completions. OpenAI Responses read 26 native fixtures across office, legacy office and text formats; RTF used text fallback after the provider accepted its file but returned no readable content. Explicit filename labels preserve attachment identity when provider extraction omits filename metadata. Text MIME types are normalized to accepted wire types; these checks caught rejected XML and TypeScript labels that serialization tests had missed. Gateway discovery selects Responses for OpenAI/Azure groups and retains provider-based document capabilities, so unknown vision models do not inherit assumed PDF support.
A live Pi gateway session using discovered OpenAI metadata also passed a read-tool round trip, a follow-up retaining the tool result without another call, and native scanned-PDF comprehension without tools. The measured turns took 5.6, 1.6 and 2.2 seconds respectively. After the gateway fixes, 99 affected tests, TypeScript and changed-file lint passed.
Encrypted PDF fallback was verified to report unavailable content without revealing its verification marker. Native OpenAI requests rejected encrypted and malformed PDFs with HTTP 400 invalid_file; they require an unlocked, valid replacement. Supported formats now receive bounded local preflight before native submission; unreadable files become individual unavailability notices. Scanned PDFs with no extractable text remain native visual inputs. Legacy formats without a local parser and provider-specific page/token limits still depend on provider acceptance. A live Pi mixed batch read the scanned marker while reporting malformed/encrypted files unavailable, and repeated that result on a follow-up without tools (2.9 and 3.5 seconds). A twelve-page native PDF check recovered independent markers on the first and last pages in 5.5 seconds.
A live Pi gateway task also accepted a scanned PDF uploaded while its read tool was running, recovered the visual marker in 4.6 seconds, and kept document bytes out of its tape. Regression tests cover active-turn uploads across all four harnesses, shared text/count/byte budgets, screening and runtime handoff.
E2b Template (`docs/e2b-template.md`)
E2B sandbox template
Public template
E2B publishes a ready agent-computer template, qm-sandbox. It has the same tools as
the sandbox base image (fly/Dockerfile): Claude Code, Codex, GitHub CLI, AWS CLI v2, the
Python venv at /opt/agent-venv and x-api. Each sandbox gets 8 vCPU and 8 GiB where the
plan allows.
"sandbox": { "backend": "e2b" },
"env": { "core": { "E2B_TEMPLATE_ID": "qm-sandbox:v1.0.0" } }Set E2B_API_KEY with qm secrets set. Pin a version tag, so a new release does not
change the image of new scopes.
With E2B_TEMPLATE_ID set, the agent computer's profile lists gh, aws and x-api as
installed. Without it, core uses E2B's stock base image and lists them as missing.
On E2B plans capped at one hour, also set E2B_MAX_LIFETIME_SEC=3600 in env.core.
Core otherwise asks for a 61 minute sandbox timeout, and those plans reject every create
with 400: Timeout cannot be greater than 1 hours. The setting also caps a command at 59
minutes.
To run core on E2B as well, e2b sandbox create qm starts it from the qm-core template
with a setup page for the E2B key, org, admin and model keys. See
E2B's docs for the CLI.
Build your own
E2B stopped accepting V1 template builds (e2b template build against a bare
Dockerfile with an access token) on 2026-08-01. deploy/e2b/e2b.Dockerfile is still
the single source for the sandbox image, but it is now fed to the Template SDK, which
E2B's migration guide names as the replacement for Dockerfile-only builds.
From the matching source checkout, install dependencies with npm ci, exportE2B_API_KEY, then run:
npm run build:e2b-templateThe command builds the Dockerfile as the template qm-base and printsE2B_TEMPLATE_ID=qm-base with the template and build identifiers. SetE2B_TEMPLATE_ID in the core deployment to that name. Sandboxes created from the
template run as the user account with /home/user/workspace as the working
directory, which is what src/sandbox/e2b-sandbox.ts assumes.
E2B_TEMPLATE_NAME renames the template. E2B_TEMPLATE_CPUS andE2B_TEMPLATE_MEMORY_MB set the sandbox shape recorded on the template; E2B's plan
limits cap them, and the running shape is reported back through Sandbox.getInfo, so
the profile advertises whatever the sandbox actually received rather than a guessed
value. Leaving them unset uses E2B's default of 2 vCPU and 512 MiB.
Layer tools baked into a child template
Core installs the deployment layer's tool files into every new sandbox on first
provision, after checking their checksums. To skip that step at runtime, pointDEPLOYMENT_LAYER at the layer directory when building:
DEPLOYMENT_LAYER=deploy/layers/<org> npm run build:e2b-templateThis builds a second template, qm-base-layer (override withE2B_LAYER_TEMPLATE_NAME), derived from qm-base with fromTemplate and containing
each declared install file at its destination path with its declared mode. SetE2B_TEMPLATE_ID to the layer template instead. The runtime checksum probe finds the
files already present and installs nothing; when the layer changes, rebuild the child
template or let the runtime installer replace the stale copies on the next provision.
Rebuilding from unchanged inputs uses E2B's build cache. Existing sandboxes keep the
template they were created from; a paused scope sandbox only picks up a new template
after it is destroyed or restored from a recovery snapshot, which is itself a template
derived from the old image.
Egress
E2B can filter a sandbox's outbound traffic itself (network.allowOut / denyOut), but
those rules cannot carry QM's egress policy, so QM keeps its own proxy as the enforcement
point and uses E2B's rules only to make that proxy unavoidable:
- QM policies are deny lists over open egress as well as allow lists. E2B accepts host
names only inallowOut;denyOuttakes IPs and CIDR ranges only, and allow rules
always win over deny rules, so "allowgithub.combut notgist.github.com" has no
E2B form. - E2B matches host names only on ports 80 and 443 (HTTP
Hostand TLS SNI). Other
ports, such as SSH to a git host, fall back to IP rules. - QM resolves the policy per turn and per speaker and signs it into the turn's egress
token. E2B rules are per sandbox, and one scope's computer serves concurrent turns from
different people. - The proxy also writes the egress audit log and checks the turn token; E2B's firewall
does neither.
Set E2B_EGRESS_PROXY_URL to a QM egress proxy reachable on port 443, for example
the egress-proxy service (TLS on 443 in front of the proxy's port 48080). Core then
creates every agent computer with allowOut: [] and denyOut: ["0.0.0.0/0"]
and points the computer's proxy settings at it with the turn's token. Without it, core
warns at boot and computers have open egress.
The proxy cannot run inside a core that is itself an E2B sandbox: E2B's public URL edge
forwards HTTP but answers CONNECT with 405, so sibling sandboxes cannot use it as a
forward proxy. Run it where it gets a real TCP listener on 443, and give it the sameCORE_SIGNING_SECRET as core so it can verify turn tokens.
See Internet access.
Published apps
DEPLOY_PROVIDER=e2b runs each published app in its own E2B sandbox, created fromE2B_TEMPLATE_ID so an app gets the same machine as the agent that built it. It uses
the same E2B_API_KEY.
- The sandbox is created with
lifecycle: { onTimeout: "pause", autoResume: true }. It
pauses ten minutes after it starts or resumes, and the next request to its URL resumes
it with memory intact, so the app process keeps running across pauses. E2B keeps paused
sandboxes until they are killed. - Its URL is
https://-<sandbox>.<e2b domain>, created withnetwork.allowPublicTraffic: false. Only core holds the sandbox's traffic access token
and sends it ase2b-traffic-access-tokenwhen it proxies a request, so viewers still
go through QM's sign-in and sharing at/d/<app>/or the apps domain. - A new version replaces
/home/user/appin the running sandbox and restarts the
entrypoint./home/user/data($DATA_DIR) survives redeploys and pauses; deleting the
app kills the sandbox and its data. - An always-on app is created with a 24 hour timeout. E2B still pauses it when that
window ends, and the next request resumes it. - Apps served at
/d/<app>/always require sign-in, so "anyone with the link" sharing
needs an apps domain (DEPLOY_APPS_DOMAINplusAWS_DEPLOY_GATE_SECRET) with a
wildcard DNS record routed to the portal.e2b.appcannot carry per-app subdomains, so
a core running inside an E2B sandbox needs a domain you control in front of it. Without
one, a request to make an app public is refused.
See Auto-resume and
Restricting public access.
Error Reporting (`docs/error-reporting.md`)
Backend error reporting
Set SENTRY_DSN in each backend service's deployment environment to enable Sentry. Leave it unset to disable reporting. Core (including in-process Slack), workers, web/admin servers, and portal/auth servers report to the configured project. This does not add browser instrumentation.
Use a separate project for deployments with different operators or data boundaries. Configure SENTRY_ENVIRONMENT (for example, production or staging) and SENTRY_DEPLOYMENT (a non-sensitive deployment identifier). SENTRY_RELEASE overrides the image's GIT_SHA. Backend images built by the AWS CLI receive the source revision automatically. A combined web/admin process uses service web; a combined portal/auth process uses service portal.
Reporting captures uncaught exceptions, unhandled promise rejections, HTTP handler and authentication delivery failures, and the core's operator error records. Recorded failures carry error_code and a matching grouping fingerprint. Recorded exceptions preserve their original stack; records without an exception carry the recording call's stack. Repeated capture of the same error object is deduplicated by the SDK. Ordinary console logs and expected failures outside these boundaries are not reported.
Only error events are enabled by default. Profiling, logs and replay are disabled. Sampled performance tracing, including request instrumentation, is described below. Events are sent unredacted: error messages, cause chains (as linked exceptions), stacks, and the failure's context (the detail passed to reportFailure, or the operator error record's category, code, message, scope and session) all reach Sentry so issues can be debugged there directly. Each recorded failure's log line also carries its Sentry event id ([sentry=]).
The SDK preserves existing fatal-error drain handlers. When no rejection handler exists it flushes and exits unsuccessfully. Core's explicit shutdown paths and web startup failures allow up to two seconds for pending error delivery. Delivery is best effort and never a substitute for application logs or infrastructure health alarms.
After deployment, verify a synthetic backend failure reaches the intended project with the expected release, environment, deployment and service. Check that its message and stack are readable. Test alerts against that event before relying on notifications. Disable reporting by removing SENTRY_DSN and redeploying; retain the prior immutable application candidate for code rollback.
Optional performance tracing
Set SENTRY_TRACES_SAMPLE_RATE (a fraction between 0 and 1; unset, 0, or an invalid value keeps tracing off) on a backend service that already has SENTRY_DSN to sample transactions. Start with 0.1. With a sample rate set, the SDK's automatic instrumentation records incoming and outgoing HTTP requests (including fetch) and Postgres pool checkouts, connects and queries. No model, tool or prompt instrumentation is registered. Health checks, server-sent event streams (GET …/events or Accept: text/event-stream) and the SDK's default 401–404 and redirect responses are not traced.
Each sampled request becomes an http.server transaction. The core names it by the registered route template, such as GET /v1/sessions/:id. Unregistered paths and every other service's requests are named METHOD /*, and deployment subdomain proxying is named /deployment-proxy/*. Errors captured during a request carry the same name. Each run attempt a worker processes becomes a queue.process transaction named run, with messaging.message.id (the run id), messaging.message.retry.count, messaging.message.receive.latency (queue wait in milliseconds), qm.surface and qm.origin attributes and a time_to_first_token measurement (milliseconds from the attempt's start to the first streamed text delta of the reply). Its children are the attempt's Postgres and HTTP spans.
Trace context is propagated only to QM's own services: the origins named by CORE_API_URL, WEB_UI_UPSTREAM, ADMIN_UPSTREAM and AUTH_BROKER_UPSTREAM. A run records the sentry-trace value of the request that queued it in its stored request (trace), so its worker transaction joins that trace. The run inherits the request's sampling decision, sampled or not, so it is never sampled again. Swarm dispatches never record one: a swarm fans out to many workers that would otherwise pile into the dispatching request's trace, so each swarm run starts its own trace and is found by its run id. Runs queued without trace context start their own trace. Browser timing is not linked to backend traces.
For requests without an upstream trace, the first traced service (normally the portal) makes the sampling decision and downstream services follow it. Incoming sentry-trace and baggage headers are honored as the SDK does by default, so a client that sends its own trace headers to a traced public endpoint can force its requests to be sampled. That costs Sentry quota. Upstream trace metadata is not exported: each envelope's trace header is rebuilt from the event's own trace id and this service's public key, release, environment and sample rate. A run stores only the sentry-trace value. With tracing on, error envelopes get the same rebuilt trace header; error event bodies are unchanged.
Transaction payloads are scrubbed before sending: only request (http.server) and run (queue.process) transactions are sent, so work that outlives its request or run is not exported on its own. Span attributes are reduced to an allowlist (Sentry's own, method, status, route, URL scheme, protocol, database system and name, pool settings, the run's messaging.* attributes and qm.surface/qm.origin). URLs, query strings, hosts, headers, cookies, request bodies and SQL text are dropped. Database spans are named by their SQL verb (SELECT, UPDATE, …; anything else is query), outgoing HTTP spans by method and origin, and any other span by its operation. Error events keep the unredacted policy above.
Browser timing uses SENTRY_BROWSER_TRACES_SAMPLE_RATE on the web server together with SENTRY_BROWSER_DSN; see the web-ui README.
Browser Sentry ignores plain network TypeError failures (Failed to fetch, NetworkError when attempting to fetch resource., Load failed) and AbortError cancellations; HTTP 4xx/5xx failures still report.
Published apps
Published-app failures become operator error records in the app owner's scope, so they reach the admin errors view and Sentry: deploy:app_start_failed (publish failed, including an app that only answered 5xx on / for the readiness window), deploy:app_restarted (the supervisor restarted the app since the last visit-time check), and deploy:app_down (the app and its supervisor are gone). Each record names the deployment and carries up to 4 KB of the app's recent output, with the app's env values and known secret patterns masked. Every app host (AWS, Porter, Fly, Docker) runs the app under the same supervisor and probes, so the app image must provide sh; with curl or wget in the image the readiness check also rejects an app that only answers 5xx, and without either it waits only for the port to listen.
External Slack (`docs/external-slack.md`)
External Slack workspaces
External workspace access is opt-in. Without this configuration, existing Slack identity, context, and authorization behavior is unchanged.
Add externalAccess to the selected SLACK_ACCOUNTS entry:
{
"id": "partners",
"botToken": "<bot token>",
"appToken": "<Socket Mode token>",
"externalAccess": {
"companyDomains": ["company.example"],
"companyTeamIds": ["TCOMPANY"],
"serviceCredentials": ["public-search"]
}
}For the default Slack account, set SLACK_EXTERNAL_ACCESS to the JSON value of externalAccess. The same policy works with HTTP Events accounts. Account IDs must be unique, including the implicit default account. Apply the configuration to core and workers together, then restart them.
Identity and public work
Set companyTeamIds to Slack workspace IDs administered by your company. This required, non-empty list identifies the workspaces trusted to establish employee identity; do not include the partner workspace. Existing external-access configurations must add this list before restarting.
The user's Slack team_id must be in companyTeamIds, and their profile must contain an email whose exact domain is in companyDomains. Email case is normalized; workspace IDs are matched exactly. Subdomains are not implicitly included. Company members connecting from those workspaces through Slack Connect are supported. Accounts belonging to the partner workspace, missing workspace IDs or emails, non-company emails, restricted guests, deleted users, and bots cannot initiate work, including DMs. allowFrom, if configured, can further restrict access.
Every non-DM conversation in an opted-in workspace has an external audience, including private channels and group DMs with an apparently staff-only roster. Its computer and conversation use a workspace-and-policy namespace. Enabling this policy, or changing its allowlists, starts a fresh restricted namespace rather than exposing an older computer or retained private context.
The channel agent can answer and execute code using its own restricted computer. It does not preload personal or organization memory, files, skills, private instructions, keychain inventory, connected apps, or MCP tools. Open sharing and admin privileges do not widen this context. Other resource operations must continue privately rather than using the channel as a route to private data.
serviceCredentials is an explicit external-use allowlist, not an alternative to existing service credential grants. A service must pass both checks and remain enabled. An organization-wide grant alone does not make a service safe for an external audience. Leave the list empty unless the service and everything it can return are suitable for the workspace's audience. In particular, do not add a confidential company search or database credential just because everyone at the company can use it internally.
Private continuation
When the request needs personal context or a private service, the agent finishes the channel turn with a self-only continuation directive. The platform:
- Loads the originating request, not a model-selected recipient.
- Rechecks the employee's company workspace membership and email against the current policy on the originating account.
- Opens that employee's DM and acknowledges the move in the source thread.
- Queues a normal personal-context turn with the original request, relevant channel history, and incoming attachments.
- Delivers results and approvals privately. There is no automatic result bridge back to the channel.
This is a general continuation mechanism, not a calendar-specific workflow. Existing personal authorization and approval rules still apply. Run idempotency prevents Slack redelivery from executing the private task twice. If a process stops after admission, the existing durable result-delivery path recovers the DM response using its recorded Slack account and workspace.
Operational limits
- Explicit mentions, genuine replies in an engaged thread, and employee DMs are supported. Passive ambient ingestion is disabled for opted-in accounts so it cannot start an unclassified alternate turn.
- Existing shared-scope approvals and legacy personal-agent bridges are not resumed in an opted-in workspace. Start a fresh request so it receives the current boundary.
- Old shared replies without the restricted namespace are not released into an opted-in workspace during delivery recovery. Unprovenanced historical shared Slack requests are refused when an external policy is configured.
- Recovered DM run results also require the current external-account policy. Results admitted before this policy configuration or under a previous policy are not delivered.
- Restricted sessions cannot be resumed through a surface that omits their authenticated workspace policy. External-account DMs also require admission under the current policy; after a policy change, start a fresh request. DMs on other accounts are unaffected.
- Configuration is deployment-level; there is no new admin UI in this change.
Files Publication (`docs/files-publication.md`)
Durable Files publication
Files publication is independent of a sandbox's lifetime. The S3/Postgres deployment supports direct multipart transfers from a sandbox or API client. File bytes travel to S3; core authorizes the upload and publishes its metadata after verifying the completed object.
Agent usage
Run this in a sandbox with the normal agent API environment:
curl -fsS "$AGENT_API_URL/v1/files/upload-client" \
-H "x-agent-capability: $AGENT_API_TOKEN" -o /tmp/qm-upload.py
python3 /tmp/qm-upload.py path/to/report.pdfThe helper prints the published file and its content URL only after completion. It needs Python and curl (curl carries every request, so it works through the sandbox's HTTPS egress proxy), holds at most one 64 MiB part in memory, retries transfers, and obtains another signed URL if one expires. A local <filename>.qm-upload.json tracks progress without storing credentials or signed URLs. Rerunning the same command resumes the same upload and returns the same artifact. If the file changed since an unfinished attempt, the helper cancels that attempt and starts a new upload, so it does not hold one of the four active-upload slots. --state selects another progress file; --name changes the published name. Keep the source file unchanged during transfer. The helper binds its progress to the API origin, actor, conversation scope, and complete part manifest.
The file belongs to the conversation scope. An agent token cannot publish into another scope or inspect another scope's upload sessions, even if its actor belongs to both scopes. Retirement of a sandbox does not affect a completed file. An unfinished upload is not a file and does not appear in Files.
Direct protocol
Each JSON request requires the existing authenticated agent or user identity. A user client follows the existing principal-bound source authentication contract; an agent uses x-agent-capability.
| Method | Endpoint | Meaning |
|---|---|---|
| POST | /v1/files/uploads |
Begin with name, optional mimetype, optional scopeId, sizeBytes, ordered checksums, optional requestId |
| GET | /v1/files/uploads/:id |
Read the caller's session state |
| POST | /v1/files/uploads/:id/parts/:number |
Obtain a signed PUT URL plus required headers |
| POST | /v1/files/uploads/:id/complete |
Verify uploaded parts and publish the file |
| DELETE | /v1/files/uploads/:id |
Abort before completion starts |
Split bytes into 64 MiB parts and supply each part's base64 SHA-256 checksum. An empty file has one empty part and the SHA-256 checksum of empty bytes. requestId is 32 lowercase hexadecimal characters; persist it before initiation to make response-loss retries idempotent. Reusing it with another manifest is rejected. Upload every part using the returned headers, including the signed content length and SHA-256 checksum. Completion uses S3's actual part sizes, checksums, and ETags rather than accepting a client claim that the transfer succeeded.
The limit is 100 GiB per file, four active uploads per actor, and 500 GiB of active declared bytes per actor. Sessions expire after 24 hours; signed URLs last at most 15 minutes and never outlast their session. Abort and completion have mutually exclusive durable state transitions. Once completion has begun, retry completion instead of aborting. Core can recover completion after S3 succeeded but a response or database write failed. Deleting an already-published artifact records an ID-only tombstone. Publication and deletion serialize on that ID, so recovery cannot recreate the file even if the upload state update failed after publication.
S3 stores a SHA-256 composite checksum for multipart objects. This is a checksum of the ordered part checksums, with a part-count suffix. It is not the SHA-256 of the entire file: these artifacts intentionally have sha256: null. Their unique blob key and server-verified multipart manifest identify the uploaded bytes.
Deployment and recovery
New direct uploads require S3 file storage, Postgres, and FILES_DIRECT_UPLOADS_ENABLED=true (default false). Deploy this release with the flag disabled to every API server and worker before enabling it in a separate rollout. Older releases cannot read the new object keys or preserve deletion fences; after activation, any rollback target must include this release's reader and deletion compatibility. Turning the flag off disables initiation while preserving authenticated helper downloads, existing session status, signing, completion, abort, and background recovery. The helper can resume an existing session while initiation is disabled, including after a sandbox replacement. Upload sessions reside in file_uploads; process-local state is not required for recovery. Missing multipart uploads with no completed object become terminal failed sessions and release their quota. Expired sessions are claimed in bounded batches with durable retry times, so repeated failures cannot starve later sessions. The worker sweeper retries expired sessions each minute and aborts old multipart uploads under the Files upload prefix, including uploads abandoned before their session could be recorded. It does not change bucket lifecycle configuration. Deployments should also configure S3 abort-incomplete-multipart lifecycle rules as protection when core is unavailable for an extended period.
The core's S3 role needs s3:GetObject, s3:PutObject, s3:AbortMultipartUpload, and s3:ListMultipartUploadParts on its Files prefix, plus s3:ListBucket and s3:ListBucketMultipartUploads on the bucket. Existing generated AWS infrastructure includes these permissions. KMS-encrypted buckets also need the applicable KMS permissions to complete uploads and retrieve checksums. The sandbox needs outbound HTTPS to the S3 endpoint but receives no AWS credentials.
The existing browser uploader remains on its existing attachment-size limit. It does not automatically use this protocol. A browser client adopting the protocol must configure appropriate bucket CORS for PUT and the checksum header; Python and other non-browser clients do not require CORS.
Local and proxied S3 writes spool incoming bytes to temporary disk while hashing, with backpressure and the existing size checks. Local publication renames the completed temporary file atomically; S3 uploads the spool as bounded-memory parts. Temporary files are removed on success and failure. This fallback uses core disk proportional to the upload size; use the direct path for large files.
Loop Ingestion (`docs/loop-ingestion.md`)
Loop ingestion
A Loop can receive work from a schedule, signed webhooks, Slack Events API callbacks, or Gmail Pub/Sub. Open the Loop and use Ingestion → Add event source. Adding an event source leaves schedules and approval rules unchanged. No event source is enabled automatically on upgrade.
Events are acknowledged only after durable receipt. The worker transfers them to the original Loop ledger and works queued items without running the discovery stage. Duplicates reuse the same item. Paused Loops retain received work; disabled sources stop accepting new deliveries. Re-enable a source to continue pending work. Pending jobs survive restarts; completed receipt metadata is retained for 30 days. Returned items run again even when no new event arrives.
Source updates invalidate old held drafts before processing the new conversation. Human edits remain in the item thread. A shipping output or an output awaiting confirmation must settle before that conversation refreshes. Existing ship gates remain authoritative; Email and Slack reply proposals stay held for human review.
Preview rollout
The durable inbox_loops feature flag defaults off. Enable it only for the intended personal: scopes in Admin → Feature flags. Existing Web UI INBOX_USERS and LOOPS_USERS allowlists still apply. Both the UI permissions and server routes enforce the flag; Inbox migration starts only for enabled people. Event receipt and background processing also check the source owner's flag. Revocation retains queued work without accepting or processing more events.
Slack receipt recovery covers events already persisted by QM. There is no Slack history reconciliation yet. Enable Slack's Delayed Events option for extended delivery retries; Gmail has history reconciliation as described below.
Signed webhooks
Choose Signed webhook on a Loop without a source restriction. Save the signing secret shown once. POST a JSON object to the displayed endpoint with X-Signature containing the hexadecimal HMAC-SHA256 of the exact request body. The optional title property is used as the work summary. The entire JSON object is untrusted input to the Loop playbook. Payloads are limited to 64 KB.
Identical request bodies are duplicate deliveries; include a stable event ID in the JSON when otherwise-identical events should be distinct. Do not change the body during retries. An invalid signature returns 401. Disabled or nonexistent endpoints return 404. Accepted receipts return 202, which does not mean the model has finished processing.
Slack events
Choose Slack events, enter the Slack app signing secret, workspace ID, and channel IDs. Configure the displayed endpoint as the app's Events API request URL and subscribe to the message events needed for those channels. The receiver handles Slack's signed URL-verification challenge. App membership and Slack OAuth scopes still determine which events Slack sends; this setting grants no additional Slack permissions.
Only human message events from the configured workspace and channels are imported. Bot messages and message subtypes, including edits and deletions, are excluded. Deliveries deduplicate by Slack event ID. Replies share their conversation's work item; DMs use the channel ID as their conversation key. A newer message refreshes that item.
This is HTTP Events API ingestion, independent of the deployment's existing Socket Mode bot. Slack has one Events API request URL per app, so use an app dedicated to this endpoint if another service already owns that URL. Slack Workflow requests can use the generic signed webhook only if the sender can produce the required HMAC signature; an unsigned workflow URL is not supported.
See Slack HTTP request URLs and request signature verification.
Gmail Pub/Sub
An administrator must configure:
GMAIL_PUBSUB_TOPIC=projects/PROJECT_ID/topics/TOPIC_ID
GMAIL_PUBSUB_AUDIENCE=https://QM_HOST/v1/loop-ingress/gmail
GMAIL_PUBSUB_SERVICE_ACCOUNT=pubsub-push@PROJECT_ID.iam.gserviceaccount.com- Create a Pub/Sub topic in the Google Cloud project associated with the Gmail OAuth application. Grant
[email protected]publisher access to that topic. - Create an authenticated push subscription pointing to
https://QM_HOST/v1/loop-ingress/gmail. Set its OIDC audience and push service account to match the settings above. Follow Google's service-agent/token-creator requirements for authenticated push subscriptions. Use the normal wrapped Pub/Sub message format. - Restart core with those settings. Set
PUBLIC_WEB_URLto the externally reachable HTTPS origin so the UI displays the correct endpoint. The portal forwards push requests without a portal login; core verifies Google's signature, issuer, audience, and verified service-account email. - Connect the Loop owner's personal Gmail account, then enable Gmail Pub/Sub on a personal Loop. Gmail watch/history access needs an appropriate Gmail OAuth scope. Company credentials and shared-scope Loops are not used for personal mailbox ingestion.
The initial watch starts at the mailbox's current history ID; it does not import the existing mailbox. New Inbox messages and messages newly labeled Inbox become work items. Watches renew daily without advancing the saved processing cursor. Notifications trigger paginated history reads; every ten minutes a history reconciliation also checks for dropped notifications. An expired history cursor triggers a paginated Inbox resync. The cursor advances only after all discovered items are durably imported.
Disabling a source stops receipt processing and watch renewal for that registration. It does not issue Gmail's mailbox-wide stop call, because another Loop may watch the same mailbox. If every registration is disabled, the remote watch expires naturally. Re-enabling preserves the processing cursor and catches up on subsequent reconciliation.
See Gmail push notifications, watch API, and authenticated Pub/Sub push.
Operations
Keep background work enabled: ingestion maintenance runs alongside the scheduler, without requiring any user cron. Registration state, Gmail cursors, retry times, receipts, and queued work live in the configured durable store. Production uses Postgres; memory persistence is only suitable for disposable tests.
The Loop's Ingestion panel shows the last receipt and processing error. A 202 acknowledgment followed by an error means the event is still durable and will retry or remains represented by a queued/parked ledger item. A failed Loop may require operator attention under its existing governor and retry limits. Watch renewal errors retry after five minutes.
Do not paste webhook signing secrets into chat, playbooks, or committed configuration. The secret is returned only on creation for generic webhooks and is omitted from later listing responses.
Mcp Connectors (`docs/mcp-connectors.md`)
MCP connector credentials
Administrators register HTTP MCP servers through PUT /v1/admin/mcp-servers/:id.
Registration controls the outbound destination and the tools available to agents.
credentialScope selects the identity used for tools/call:
shared(the default for existing and new registrations): all callers use the
configuredauth,bearerToken, orclientIdandclientSecret.per-user: each call uses the initiating actor's bearer token from the encrypted
connector keychain, undercredentialHostin the selectedcredentialAccountType
slot (default,personal, orcompany; defaults todefault). The
runtime supplies the actor; tool arguments cannot select another user.
For example:
{
"name": "Customer tools",
"url": "https://tools.example.com/mcp",
"auth": "none",
"credentialScope": "per-user",
"credentialHost": "accounts.example.com",
"readOnly": false,
"enabled": true
}Per-user mode requires persistent keychain encryption (CONNECTOR_SECRET_KEY).
Connect the user's account using an existing QM OAuth connector, or have a trusted
integration save its token through the source-authenticatedPOST /v1/connectors/token endpoint with host, principalId, accessToken, and
optional expiresAt (Unix milliseconds). Supported OAuth providers can also store
and refresh a refreshToken. This change does not add OAuth provider discovery or
an interactive MCP login flow. An unsupported provider must manage token renewal
in its integration; do not store a refresh token that QM cannot refresh.
Missing, expired, revoked, or unrefreshable user credentials fail the call with a
connection-required message. Per-user MCP calls do not use operator environment
tokens, organization credentials, another user's account, or the shared discovery
credential as fallback. Tokens are resolved again for every call rather than
cached across users. Triggered work uses its existing initiating actor identity.
Tool discovery (tools/list, including registration probes) remains connector-wide
and uses the configured auth. In per-user mode those credentials are only for
catalog discovery. The server must expose a common, non-sensitive catalog and
check each caller's permissions when executing a tool. A public catalog can useauth: "none"; a private catalog can use a dedicated shared discovery credential.
Do not put user-specific data into names, descriptions, or schemas.
credentialHost explicitly authorizes sending that connector's user token to the
registered server. Only register a trusted service permitted to receive those
tokens. Per-user endpoints require HTTPS, except on loopback for local development.
HTTP redirects are rejected for every MCP authentication mode. Updating an endpoint
or credential host is an administrative trust decision. Tool call auditing records
the initiating actor as before; authentication does not expand the audience allowed
to receive the result.
Memory Providers (`docs/memory-providers.md`)
Memory providers
QM keeps its built-in notebook memory unless MEMORY_PROVIDER_CONFIG defines a scope-aware provider router. Routes independently select where each scope recalls from, accepts explicit writes, and receives automatic post-turn capture.
Prompt memory
Each turn carries at most 6,000 characters of memory, selected across every notebook
the conversation may read, one heading per notebook. With PostgreSQL and an embedding
model, facts are ranked by exact cosine similarity to the current message and recent
dialogue; otherwise the newest facts come first. Later turns add only newly selected facts.
Choose the model with the org setting memory-embedding-model
({ "modelId": "gateway/<model>" }, or null to turn ranking off).
Gateway models with mode=embedding are listed automatically; a direct OpenAI-compatible
model is registered in the model registry with "kind": "embedding". The provider
receives fact text during background indexing and the query during recall. PostgreSQL
stores typed vectors in memory_fact_vectors; recall ranks live, disclosed facts once per
scope in SQL and returns compact ranked matches. No approximate vector index is used.
Semantic recall requires the PostgreSQL vector extension. Its migration is registered
only after a configured embedding model resolves, not during an unconfigured startup.
If the application database role cannot install extensions, an administrator must installvector first. File-backed installations use newest-first recall, even with a model selected.
A single background indexer embeds at most 64 missing facts and removes stale vectors in
bounded batches every 30 seconds. It also sends a short dimension probe every ten minutes so a provider's
vector-width change is detected even when its model identifier stays the same.
New or edited facts may wait for a background tick before becoming semantic matches.
Unindexed facts participate at the recency fallback score alongside indexed semantic
matches. If the provider/database fails, recall falls back to newest-first. Text search (memory search, POST /v1/memory/search) remains
immediate and does not use embeddings.
External routing
{
"providers": [
{
"id": "org-knowledge",
"type": "mcp",
"url": "http://memory-provider.internal:8080",
"timeoutMs": 3000,
"read": {
"tool": "search_knowledge",
"clientIdEnv": "KNOWLEDGE_RO_CLIENT_ID",
"clientSecretEnv": "KNOWLEDGE_RO_CLIENT_SECRET"
},
"write": {
"tool": "write_knowledge",
"clientIdEnv": "KNOWLEDGE_RW_CLIENT_ID",
"clientSecretEnv": "KNOWLEDGE_RW_CLIENT_SECRET"
}
}
],
"routes": [
{
"provider": "default",
"scopes": ["personal", "channel", "group", "team"],
"capture": "automatic"
},
{
"provider": "org-knowledge",
"scopes": ["org"],
"capture": "explicit",
"manage": false,
"label": "Organizational knowledge"
}
]
}Set the compact JSON document as MEMORY_PROVIDER_CONFIG. default names QM's local/Postgres notebook. A scope selector may be a kind (org) or an exact scope ID (org:acme). Earlier matching routes can compose multiple recall providers; the first route with manage enabled supplies notebook editing and revision history.
Capture policies are:
off: recall only;explicit: writes only through deliberate memory actions;automatic: explicit writes plus post-turn capture.
MCP reads receive query and acting_user by default. Writes receive content and acting_user. Operation entries can map optional fields with queryArg, contentArg, actorArg, scopeArg, maxCharsArg, inputArg, replyArg, capturedAtArg, sourceArg, and idempotencyArg. Only configured optional fields are sent, so providers can match strict MCP schemas.
Read and write operations use separate OAuth client-credential pairs. Omit write and set route capture to off for a read-only provider. External routes fail open by default so an outage does not block recall; set failOpen: false on a route to make it strict. Provider calls time out after timeoutMs (3 seconds by default). Explicit writes always fail visibly. QM continues to decide readable/writable scopes and passes the acting user to the provider.
Migrating from the retired `BRAIN_*` variables
Earlier releases wired an external knowledge server through BRAIN=mcp, BRAIN_MCP_URL, BRAIN_QUERY_TOOL, and the BRAIN_RO_*/BRAIN_RW_* OAuth client pairs, exposing read_brain and write_brain tools. Those variables are ignored now; startup logs a [config] warning while any of them is still set. Express the same server as an mcp provider above: url takes the old BRAIN_MCP_URL, read.tool the old BRAIN_QUERY_TOOL, and read/write name the env variables holding each OAuth client pair. Static bearer tokens (BRAIN_AUTH=bearer) have no equivalent; the provider framework authenticates with client credentials only.
Procedural memory (Memorable)
A provider with type: "memorable" records procedures rather than facts: when a turn's
automatic capture fires, QM derives a deterministic tool-call trace from the session (which
files changed, which commands verified the work), redacts any secret values, and hands it to
the Memorable CLI with memorable record. Recall runs memorable inject
with the turn's task and appends the returned pointer to the prompt. No model is involved in
either direction.
{
"providers": [{ "id": "procedures", "type": "memorable" }],
"routes": [
{ "provider": "default", "scopes": ["personal", "channel", "group", "team", "org"], "capture": "automatic" },
{ "provider": "procedures", "scopes": ["personal"], "capture": "automatic", "manage": false, "label": "Procedures" }
]
}Options: bin (default memorable; a string or an argv array such as ["node", "/opt/memorable/cli.js"]),passEnv (extra environment variable names to hand the CLI, e.g. ["MEMORABLE_STORE_KEY"]),injectTimeoutMs (default 15000) and recordTimeoutMs (default 120000). The CLI is not bundled:
install it with npm i memorable-cli@latest — the qm backend needs 0.5.9 or newer, and an
npm min-release-age setting can silently pick an older release, so check memorable --version
(its qm backend also needs the pg package resolvable
from QM's working directory). Recording calls the Memorable extraction service, so set bothMEMORABLE_API_URL and MEMORABLE_API_KEY; recall is local. Consent is the CLI's own act, per
scope: nothing is recorded for a scope until memorable enable --scope <scope-id> has been run
with the same MEMORABLE_BACKEND=qm and MEMORABLE_DB_URL. It sees only an allow-listed environment —MEMORABLE_*, PATH, HOME, proxy and TLS variables — with MEMORABLE_BACKEND defaulting toqm and the database reachable solely as MEMORABLE_DB_URL. Routes to this provider acceptcapture: "automatic" or "off"; explicit remember writes are facts, not procedures, and are
left to the notebook. A consent refusal from the CLI is reported as a capture error; like any
external route it fails open by default, so the notebook write still lands and the refusal is logged. The provider never exposes a notebook, so keep manage: false and letdefault handle editing.
Memory Records (`docs/memory-records.md`)
Memory records and audience isolation
One notebook representation
Built-in memory uses versioned records: stable ID, text, sensitivity, source
scope/session references and an incomplete-provenance flag (sourceUnknown).
Markdown is the rendered editing/display format, not a second source of truth.
Headings and freeform prose are records too.
Postgres stores the authoritative snapshot in memory_revisions.records. New
revisions derive body from those records for compatibility. A legacy revision
with NULL records imports its body as unknown-source records; its sources are never
guessed from where the notebook happens to live. An older writer during a rolling
deployment can still produce a NULL snapshot, which takes that conservative path.
The additive migration preserves existing revisions.
The workspace backend stores one JSON snapshot at its existing reserved memory
path. Existing Markdown is imported on read and migrated on the next write. The
memory API still returns Markdown. Malformed structured data fails closed rather
than silently falling back to potentially stale text. Old file-only binaries cannot
read the new snapshot format; export through the memory API before downgrading one.
Capture and curation
Automatic extraction classifies each batch as ordinary, unknown, sensitive
or restricted. Explicit capture uses the same labels. Missing or malformed
classification and model failure produce unknown. Classification is a model
estimate, not proof of harmlessness.
Runtime context supplies source scope/session, never model-authored source claims.
Captures inherit known input restrictions. Untracked history, tools, attachments
or incomplete dependencies add unknown provenance. Cross-scope personal copies keep
their original source. Duplicate capture can tighten metadata without adding text.
Built-in consolidation edits records by stable identity under a revision check.
Each model request sees only facts with identical sensitivity and provenance;
updates and additions inherit that group's metadata. Unchanged records keep their
IDs and metadata. Separate-source facts never share a consolidation prompt.
Bookkeeping is a snapshot counter, not a Markdown marker that rewrites every fact.
A concurrent edit wins over a stale consolidation. Opaque providers retain control
of their own consolidation; generic whole-Markdown automatic rewriting is removed.
An arbitrary whole-notebook rewrite cannot establish which facts its new text
came from. It conservatively inherits the old notebook's combined restrictions.
Restoration likewise inherits the restrictions of all intervening revisions.
Neither operation declassifies information. Clearing removes current content, not
historical revisions.
Fresh reads
One disclosure view serves recall snapshots, search, primitive tools, memory-file
redirects and agent memory APIs. Provider routes apply it per provider. Structured
search runs over filtered records, not an unfiltered provider query.
The actor must retain access to every recorded source. Carrying memory into another
conversation requires current Open policies at both ends and a known destination
audience. An ordinary personal fact can follow its owner into an eligible internal
Open room; sensitive or restricted facts require every recipient to be entitled to
the sources. Ordinary does not mean public. Unknown provenance stays within the
notebook's existing narrow audience. Lookups fail closed, and authorization results
are not cached between reads.
A partially visible notebook cannot be rewritten. Structured edits require a
revision check. Historical reads apply a conservative restriction floor from newer
revisions; restore checks the complete intervening history in storage.
Opaque external providers and the optional scratch-promotion workflow remain
compatibility paths, not sources of trusted cross-context provenance. Their
unclassified content stays narrow. Scratch captures with foreign dependencies and
cross-origin personal copying are refused. Scratch bookkeeping lives in snapshot
metadata, not fact text; a no-change promotion preserves every fact's provenance.
Direct MCP tools and authorized private
administrator inspection are separate access paths.
Retained working context is separate
Fresh-read restrictions do not erase information legitimately learned by the same
audience. Source revocation, notebook edits or disabling recall do not reset that
audience's conversation.
A durable session checkpoint records the destination/audience identity hash and a
replay cutoff. An actual audience change advances the cutoff: earlier model
history, summaries, native harness state, retries, approvals and historical tape
cannot re-enter the next turn. Later turns retain post-cutoff context normally.
An absent checkpoint establishes a baseline rather than deleting old context.
Project conversations keep their existing member-inherits-history behavior.
Agent conversation APIs and subagent reads also respect the cutoff, including
derived titles and metadata. Human transcripts and administrator audit records
remain intact. Unversioned external prior turns cannot safely be imported after a
cutoff. This is next-turn isolation, not cancellation of a model request already
in flight, nor retroactive deletion of exported or copied artifacts.
Verification limits
Synthetic tests cover storage migration, metadata-only capture, consolidation,
revision races, restoration, provider routing, fresh authorization and durable
context boundaries. Live HTTP tests use synthetic facts through the real server
and Postgres. Deterministic model responses isolate consolidation behavior; these
tests do not establish classifier accuracy or qualify every real model and Slack
configuration.
Modal Image (`docs/modal-image.md`)
Prebuilt Modal connector image
Build the sandbox image before deploying core to keep package installation and image
building out of the first user turn. The image contains the same locked Composio SDK
bundle that core supplies to sandboxes without a baked image. Connector credentials
are supplied at runtime; none are used during this build.
From the matching source checkout, install dependencies with npm ci, configure Modal
authentication, then run:
MODAL_APP_NAME=qm npm run build:modal-imageThe command eagerly builds the image on Modal and prints MODAL_IMAGE=im-... only
after the bundle's checksum and import checks pass. Save that exact image ID in the
core deployment's MODAL_IMAGE setting. Use the same Modal workspace and environment
for building and running; MODAL_ENVIRONMENT selects the SDK environment. The build
does not change running sandboxes or deploy core.
With no MODAL_IMAGE set during the build, the image starts from QM's pinned Node
base and installs its usual shell, Python, Git, curl, and archive utilities. It also
installs a pinned, checksum-verified uv, configures uv pip install to target the
system Python without a virtual environment, and preinstalls pinned requests,
beautifulsoup4, lxml, openpyxl, pypdf and python-docx, plus a pinned, checksum-verified
AWS CLI v2 so agents don't fall back to pip install awscli (v1, which lacks SSO
device-code login). Packages installed later withuv pip install land in /usr/local, outside home checkpoints, so they last until the
sandbox is replaced. To extend an existing custom image, set MODAL_IMAGE to its
registry reference (prefer an immutable digest) or an existing Modal image ID. Custom bases must already contain
Node, npm, and QM's baseline utilities; this preserves the existing custom-image
contract. Building again from the same inputs uses Modal's layer cache.
The connector build uses deploy/connector-sdk/package-lock.json and the shared
bundle builder. The resulting SDK, checksum, and license notices live in/opt/qm/composio, outside /root. Restoring a home snapshot therefore cannot hide
or replace image-installed dependencies. Core validates the baked artifact against
its own bundle and exposes the matching copy through the usual home launcher path;
an older image can use the runtime bundle fallback.
Native home checkpoints are directory snapshots of /root, so everything installed
under the home is captured again in every checkpoint. Deployment-layer tool files are
converged under /usr/local/bin and /usr/local/lib and stay out of checkpoints;
the runtime connector bundle fallback lands under /root/.qm/composio and adds about
1.3 MB to each checkpoint until MODAL_IMAGE points at a baked image, after which
only a symlink remains in the home. Set MODAL_IMAGE before enabling native
checkpoints on a deployment with many active users.
Rebuild the image when updating the SDK lockfile or builder, and retain the previous
image ID with the previous core release for rollback. Existing running sandboxes
keep their current image until normal replacement. Do not delete image IDs still
used by running deployments or retained rollback releases.
For an isolated provider smoke test after the build:
MODAL_APP_NAME=qm-image-smoke MODAL_IMAGE=im-... node scripts/modal-live-smoke.mjsThis creates temporary sandboxes and terminates them afterward. It does not switch
production scopes. Sprites and Modal deployments without a baked image continue to
use the shared runtime bundle installer.
Model Gateway (`docs/model-gateway.md`)
Model gateways
Pi can route model calls through an authenticated gateway instead of holding provider keys. Set MODEL_GATEWAY_URL, MODEL_GATEWAY_API_KEY, and MODEL_GATEWAY_API_KEY_HEADER. The key stays server-side. No model list is required.
QM discovers models using authenticated GET /v1/models and GET /model_group/info, the end-user metadata endpoint implemented by LiteLLM. A base URL ending in /v1 is supported; both endpoints retain any preceding path prefix. Discovery uses the same key as inference, rejects redirects, and ignores upstream credential and endpoint fields.
Only models listed by both endpoints, marked as chat models with tool support, and carrying valid context, output and pricing metadata are offered. Discovered IDs are namespaced as gateway/. Groups whose metadata identifies only Anthropic providers use native Messages, preserving signed thinking blocks across tool calls. Groups whose providers are exclusively OpenAI or Azure use Responses, including native file inputs. Other groups use OpenAI-compatible streaming chat completions. Reasoning controls are enabled for native Anthropic and OpenAI/Azure groups; other provider combinations use standard chat without requesting reasoning effort. They are available in Pi, not process harnesses. The gateway owns provider translation and group capabilities. QM selects the protocol from provider metadata, never model names. Gateways must expose /v1/messages for Anthropic groups and /v1/responses for OpenAI/Azure groups. PDF inputs on chat-completion routes require vision capability and a provider set consisting only of Anthropic, OpenAI, Azure, Gemini or Vertex. Unknown or mixed unsupported provider groups use bounded text extraction.
Availability refreshes on demand every five minutes and before inference when expired. Successful refreshes replace the catalog, including an empty catalog. After discovery has succeeded, a failed refresh disables gateway routes until recovery without interrupting independent direct providers, with retries after thirty seconds. In-flight requests are not cancelled; the gateway still enforces its key permissions on every call. Retired selections remain in conversation history but cannot issue a new request through a different provider.
MODEL_GATEWAY_MODELS is optional and accepts the existing local-id=upstream-id pairs. These provide compatibility aliases using QM's existing native model definitions; they do not limit discovered models. After discovery succeeds, aliases are usable only while their upstream IDs remain advertised. Gateways without the discovery endpoints can continue to use explicit mappings. Before the first successful discovery, failures leave those configured routes available. This compatibility fallback never restores a route removed by a successful discovery.
For LiteLLM deployments, allow the two authenticated GET endpoints alongside inference paths. Do not expose administrative /model/info or key-management routes to enable discovery. Gateways implementing only the OpenAI model-list endpoint can use explicit mappings or implement the metadata contract below.
The metadata endpoint returns {"data": [...]} with one object per model group. QM uses model_group, mode, supports_function_calling, max_input_tokens, max_output_tokens, input_cost_per_token, and output_cost_per_token. Optional fields include providers, supports_vision, supports_reasoning, supports_adaptive_thinking, supported_openai_params, cache_read_input_token_cost, and cache_creation_input_token_cost. Prices are per token and converted to per-million-token prices for QM. Missing cache prices use the input rate. Input capacity is used conservatively as the context budget; output capacity must be smaller.
LiteLLM group metadata may combine capabilities and maximum limits from multiple deployments. It is a gateway contract, not a guarantee that every backing deployment has identical capabilities.
Browser agent
The browse skill follows the user's saved AI access and default model. Company
access uses the configured model gateway, or the deployment's default provider
credentials when no gateway is configured; ChatGPT access uses the user's connected
OpenAI account, including refreshed subscription access; Claude access supports
API keys. Claude subscription access is unavailable for the inner browser agent
and returns an actionable error. No account silently falls back to another.
Kernel, Anchor, Browserbase and Browser Use still use their own credentials for browser sessions.
Core gives internal, non-strict turns a one-hour capability bound to the account,
model and conversation scope. The sandbox sends OpenAI-compatible, non-streaming
chat completions to /v1/browser-model/chat/completions; core reloads the saved
selection and credentials for each request. Changing the account or model requires
a fresh turn. Credentials stay on core. Without a gateway, company inference reloads the same provider credentials as normal
agent inference, including administrator changes and configured provider endpoints.
No separate browser model key is needed. Missing credentials fail closed.
Personal inference uses native provider
transports, bypassing organization endpoint overrides, and converts structured
browser responses and screenshots through the existing model runtime.
Company browser inference uses the same gateway key permissions and budgets as
other company model calls. The selected model must be in the gateway catalog or
configured aliases. Private gateways need no public listener. The endpoint accepts
up to 16 MiB for screenshot history, rejects provider overrides, and rechecks scope
membership, strict posture and gateway availability. Gateway budget exhaustion is
returned as HTTP 429; missing or retired models fail closed.
The managed browse runner needs browser-use 0.12.9 withChatOpenAI.default_headers support. It never requests separate browser model
credentials or falls back to them on account or gateway errors.
Ultrafast
Pi offers gpt-6-astra-ultrafast and gpt-6.1-sol-ultrafast, each labeled
Ultrafast (6× cost). These are QM selection identifiers: requests use the
corresponding base model (gpt-6-astra or gpt-6.1-sol) withservice_tier: "ultrafast" through the Responses API. Standard models and their
Fast switches retain their existing behavior. Ultrafast has its own pricing card,
including cached input, cache writes, and long-context rates.
Use an OpenAI API key or explicitly map the Ultrafast model ID to your gateway's
corresponding base-model group in MODEL_GATEWAY_MODELS. The gateway must support Responses and
forward service_tier. The choice is supported in Pi only, and is unavailable
with ChatGPT subscription authentication. It is never selected as the default
merely by enabling Fast.
OpenAI supports Ultrafast for Astra and Sol 6.1 with separate rate limits.
See Ultrafast availability
and pricing.
In the web picker, select Astra or Sol 6.1 and use the Ultrafast switch. The picker shows a
distinct active badge and the 6× cost before enabling it. Fast and Ultrafast are
mutually exclusive, and both speeds share one preset per base model. The internal model
IDs above preserve routing, authorization, pricing, and saved selections.
Persistent Subagent Sessions (`docs/persistent-subagent-sessions.md`)
Persistent subagent sessions
A delegated task runs in a durable session with its own transcript and a mutable parent. The subagents tool opens children, reads their transcripts, and sends attributed messages. Subagents have no sidebar entry. The separate sessions tool is offered only on web UI turns and works with the user-visible conversations in the sidebar: it lists and reads them, sends them private notes, and, on a turn a person is attending, starts a new clean session or forks the current one. It refuses subagents; use the subagents tool for those. Parentage is organization data: it survives restarts and can be changed independently of the original delegation.
Lifecycle
subagents.open accepts a task, title, model, harness, thinking level, and optional noComputer flag. noComputer: true removes computer access entirely: no shell, filesystem, browser, or computer-backed integrations. Only memory/history, session coordination, runtime inspection, and permitted read-only connectors remain. Leave it unset for tasks that need a computer to read email, files, or code; express any no-write constraint in the task.
Reusing a request key recovers the same child and initial run after a lost receipt; using that key for different work is rejected. Children inherit the caller's scope and audience. The existing internal readOnly restriction remains durable: noComputer: false cannot lift a caller's restriction or an existing child's restriction. The session tree admits at most ten pending or running qm runs, including direct turns and explicit follow-up tasks.
subagents.send_message adds attributed data to a durable recipient inbox without starting a turn. Parents, children, siblings, and other accessible sessions can exchange messages. Targets accept IDs, child/sibling titles, or parent. Messages arrive at tool boundaries; subagents.wait waits up to 60 seconds for mail. Idle recipients see messages when they next use a tool. Agents should continue independent work and wait for required results before giving one combined answer.
subagents.followup_task explicitly queues new work for an attached child, behind any active turn. Like open, it takes the instruction in task. Stable call IDs deduplicate retries. send_message with interrupt: true aborts active child work. Messages are external data, not human authorization. Each message is screened independently of its carrier tool and persisted at the receiving session's scope. Quarantined messages remain recoverable for approval; denying the approval consumes the message. Read-only and automated authorization restrictions still apply to assigned work.
Read access checks scope, current participant access, audience visibility, and participant tenure. Sending and adoption require current scope access. A wider target audience cannot receive a narrower source's contents.
Parent changes and completion
Subagents appear as clickable, draggable inline chips in their parent conversation’s collapsible tool rows, outside the sidebar session list. Dragging a chip into the sidebar detaches it and makes it a top-level session. The web UI does not offer reparenting onto other sessions. A child transcript links back to its current parent. The underlying parentage API rejects cycles, unauthorized changes, and cross-scope moves.
A terminal child run produces a deduplicated internal result for its current parent. With responsive_spine enabled for the delegating actor, a Slack or spine parent also receives a durable, deduplicated completion wake so it can report the result after ending its dispatch turn. Other parents retain passive mailbox delivery without a new run. The immutable delegating run supplies the actor, authorization, and delivery destination; the current parent request supplies the audience. A freshly adopted parent without runtime context leaves completion pending until that context exists. Detached children produce no automatic parent return.
Terminal receipts live in the run store. A paginated sweep retries pending returns after restart and continues past blocked entries. Completion receipts are acknowledged only after durable inbox insertion and any required completion wake succeed. Inbox messages are consumed after their content is persisted to the recipient transcript; an acknowledgement failure can cause redelivery. Silent completions use a generic notice rather than borrowing text from a later child run.
Storage and scope
The session migration adds parent and spawn metadata. The run migration adds terminal-return acknowledgements and an index restricted to unreturned terminal child runs. Signal replay uses the existing signal acknowledgement columns.
The ten-run limit governs qm session runs. Coordinator turns use durable session delegation and disable provider-native subagent tools; other turns retain their existing native-harness behavior.
Swarms
Swarm workers continue to coordinate through the swarm API, which owns their computer assignment, membership, and execution budgets. Session tools cannot spawn or send from swarm turns, send to swarm workers, or adopt a child under a swarm worker. Ordinary human turns in a swarm root remain ordinary session turns. All session-generated work remains automated. Explicit unattended grants belong to the assigning turn, never an earlier task. For responsive_spine participants, an attached child and its completion continuation can retain the original live requester’s sharing and author authority through verified durable run provenance. Current audience and sharing policy still apply; read-only, private-message, swarm, detached, and unrelated automated work cannot borrow that authority.
Feature flag
persistent_subagents defaults off. Enable it through the existing feature-flags admin resource for individual actor scopes (personal:). The actor flag applies even when that actor works in a shared conversation. Enable only the intended people in each deployment; other deployments remain off without their own opt-in. Actors with neither persistent_subagents nor responsive_spine enabled are not offered the subagents or sessions tools, and syscall authorization rechecks these flags. Existing transcripts remain accessible.
responsive_spine also defaults off and is independent of persistent_subagents. Enable it for individual personal: scopes through the feature-flags admin API to pilot responsive Slack and spine turns. It applies to the acting person even in shared conversations. Enabled coordinators cannot use command execution, background process tools, credential execution, or provider-native delegation. They dispatch substantial work through subagents.open, end promptly, and receive completion wakes. Child sessions retain command tools. Web conversations without spine tools and swarm workers retain their existing behavior. Turning the flag off restores the actor's ordinary toolset and passive completion handling on subsequent turns; it does not interrupt already running work.
Messages use the existing durable artifact store under session_mailbox; no process-local queue owns delivery. The flag configuration is also durable and local to each deployment.
Pgbouncer (`docs/pgbouncer.md`)
PostgreSQL transaction pooling
Keep DATABASE_URL pointed at PostgreSQL. Set DATABASE_POOL_URL to a transaction-mode PgBouncer endpoint with the same database and credentials. Set DATABASE_POOL_CA_CERT to the PEM CA certificate for that endpoint to verify its TLS identity independently of the direct database's configured trust.
Ordinary queries and transactions use the pooled endpoint. Session advisory locks and LISTEN subscriptions use separate direct connections. Schema initialization and dynamic migrations use a separate direct migration pool. pg-boss retains its existing direct endpoint, including its notification connection.
DATABASE_POOL_MAX bounds each process's shared query pool (default 10). DATABASE_DIRECT_POOL_MAX bounds its separate shared session pool (default 32). Both accept integers from 1 to 100. Migrations use a shared one-connection pool. Count every process, database and pooler replica when calculating database capacity; PgBouncer limits are per database/user and per pooler process.
Without DATABASE_POOL_URL, queries and sessions use separate pools to the direct database. Stores sharing a URL share the corresponding underlying pool, with reference-counted close. A closed store cannot reopen itself.
Use PgPool.pool() for ordinary transactions and PgPool.sessionPool() only when the PostgreSQL session must remain pinned, such as session locks and subscriptions. Register migrations through the helper; never run session-lock-based migrations through the transaction endpoint. Query-specific timeouts execute inside a transaction using SET LOCAL, preventing timeout state from leaking between PgBouncer borrowers.
Run scripts/test-pgbouncer.sh to create isolated PostgreSQL and TLS-enabled PgBouncer containers and exercise pooling, lock-held writes, timeout cleanup, migration ordering and shutdown races. The fixture uses a single backend connection to verify multiplexing and a single direct session slot to catch connection starvation. The PgBouncer compatibility workflow runs it on every pull request.
Before enabling pooling on a deployment, verify schema initialization, notifications, job scheduling, TLS failures, cross-database access denial and application reconnect behavior. Roll back routing by removing DATABASE_POOL_URL and restarting the application; database contents do not change.
Porter (`docs/porter.md`)
Running qm on Porter
Porter provisions and manages a Kubernetes cluster inside your own
AWS, GCP, or Azure account. qm can use it two ways, independently:
SANDBOX_BACKEND=porter— every agent computer is a Porter sandbox with a persistent
home volume.DEPLOY_PROVIDER=porter— apps the agent publishes run on the same cluster.
Both need a Porter API token and the project and cluster that own the sandbox API. The
complete environment for a Porter-backed instance, verified against a live deployment:
SANDBOX_BACKEND=porter
DEPLOY_PROVIDER=porter
PORTER_DEPLOY_API_TOKEN=<ADMIN-role api token from Settings -> API tokens>
PORTER_DEPLOY_PROJECT_ID=<project id>
PORTER_DEPLOY_CLUSTER_ID=<cluster id>
PORTER_SANDBOX_IMAGE=ghcr.io/porter-dev/qm-sandbox:latest
PORTER_DEPLOY_RUNNER_IMAGE=ghcr.io/porter-dev/qm-app-runner:latest
# PORTER_DEPLOY_URL= # only when the API host is not https://dashboard.porter.run
# PORTER_DEPLOY_APPS_DOMAIN= # optional; puts apps on sandbox ingress (see below)
# PORTER_DEPLOY_VISIBILITY=internal # default: no ingress host, core reaches apps in-cluster;
# private/public put apps on sandbox ingress, and public
# serves them to ANYONE with the URL, bypassing qm's gateOnboarding checklist
Everything below this section is reference and field notes; this is the order that
actually gets a new operator from zero to a working instance (the deployment workflow's
agent-facing version lives at cli/templates/deployment/references/porter.md). If you are an agent doing
this deployment: install and prefer the porter CLI over raw API calls wherever a
command exists (install instructions — on
Homebrew that is the porter-dev tap, Homebrew core's porter is an unrelated tool, anddocker-credential-porter is a separate checksum-verified binary the tap does not
install); use
the operator's browser for the dashboard steps when you have browser access, and
otherwise hand the operator the exact URL and wait — several steps below cannot be done
any other way.
- A cloud account must be linked to the Porter project at
dashboard.porter.run/cloud-accounts
before any cluster can exist. Check first — with a token,GET /api/v2/alpha/projects//cloud-accountslists linked
accounts and their connection state; without one, look at the dashboard page. If none
is connected, the operator has to link one themselves: the flow grants Porter IAM
roles via a CloudFormation stack (or the Azure/GCP equivalent) in their cloud
console, which no agent should drive unattended. Two AWS gotchas from the field: the
quick-create link opens inus-east-2, so an org SCP that pins regions makes the
stack fail with an explicit deny — reopen the same URL under the permitted region —
and the account ID Porter wants is the one shown in the AWS console's own top-right
menu, not whatever account the operator's CLI happens to be logged into. - Mint an Admin-role API token (Settings → API tokens) and record
PORTER_DEPLOY_PROJECT_IDandPORTER_DEPLOY_CLUSTER_ID. The role matters: a
Developer token survives every read until it dies mid-deployment withPERMISSION_DENIED(details under egress below) and cannot delete clusters. Check
the role before provisioning anything you will later need to remove. - Create the cluster with the sandbox load balancer in the creation contract —
sandbox ingress,sandboxesEnabled, and the apps root domain all belong in the
contract the cluster is born with; attaching them later is the wedged-cluster
forensics that "Giving published apps stable hostnames" below exists to debug. The
Route53 hosted zone for the root domain is yours to create either way (dedicated
zone,NSdelegation in the parent, wildcard record inside — then reconcile; see
below), and so is the LoadBalancer in front of the egress proxy ("Forcing sandbox
egress through the proxy" — without it egress runs fail-open). - Provision Postgres and set
DATABASE_URLbefore first boot. Sign-in is not
optional here: the auth broker's single-use claim endpoint needs core's durable
store, so an instance without a database accepts no sign-ins at all. The Porter
path has noqm-CLI database provisioning — create a datastore in the dashboard
(Datastores tab) or run in-cluster Postgres. A datastore created through the API
can come back withconnected_cluster_idsnaming your cluster while its security
group still refuses it — verify with apsqlfrom a pod before booting, and
prefer in-cluster Postgres when in doubt. - Name the administrator before first boot. Set
ADMIN_GRANTS=:org_admin
on core and the sign-in allowlist (AUTH_ALLOWED_EMAILS=— the Helm chart
bridges it to the portal'sOIDC_ALLOWED_EMAILS). This is the step the old docs
never mentioned: with Postgres and noADMIN_GRANTS, the instance boots, everyone
allowed can sign in, and the admin console is permanently unreachable — there is no
in-product way to grant the first admin afterwards. - Build, push, and apply: images built
--platform linux/amd64and pushed to
repositories that already exist (ECR does not create them on push — create or seed
each repository first), thenfor f in porter/apps/*.yaml; do porter apply -f "$f"; done(twice — the hostname
lands on the second pass), then wire the service URLs per the table below. In the
v2 app YAML,envbelongs at the app level: nested under a service it is silently
dropped, and the app boots without its configuration. Verify by signing in as the
admin and opening the Admin tab.
Hosting the qm surfaces
porter/apps/ declares four services (core, web-ui, portal,
egress-proxy) as one Porter app per file, built from deploy/*/Dockerfile — porter apply takes exactly one app per invocation and silently ignores extra YAML documents,
which is why they are separate files:
for f in porter/apps/*.yaml; do porter apply -f "$f"; donePorter runs the services and assigns each public web service an onporter.run
hostname with a Let's Encrypt certificate, so this path needs no DNS record, no TLS
certificate, and no ingress controller. Only the portal is meant to be Internet-facing;
core and web-ui carry private: true so Porter creates no ingress for them
and they are reachable only at their in-cluster address.
Two things porter apply will not do for you:
- Building on an Apple Silicon machine fails.
build.method: dockerbuilds locally
forlinux/amd64and the legacy builder cannot cross-compile: the apply dies withimage ... does not provide the specified platform (linux/amd64). Build and push withdocker buildx build --platform linux/amd64 --pushto the registry Porter already
connected for the project (porter registry listprints it), then point the app at the
pushed image with animage: {repository, tag}block.porter apply --remotebuilds
server-side but is an opt-in project feature and fails withremote build is not enabled for this projectuntil Porter enables it. - Env groups created by
porter env createare not visible toporter apply. Both
theenvGroups:key and--attach-env-groupsfail withinternal: unable to find latest environment with provided name, becauseporter env createmakes a
project-scoped group and apply resolves cluster-scoped ones. Put non-secret wiring in
the app'senv:block and pass secrets with--secrets KEY=value(note that pflag
splits those on commas, so a JSON value likeAUTH_SIGNING_JWKhas to go inenv:).
Nothing generates the inter-service wiring for you — cli/src/services.ts has fly anddocker targets but no Porter one — so set it by hand on each app:
| Service | Wiring |
|---|---|
| all | CORE_ORG_ID/ORG_ID, CORE_SIGNING_SECRET, PORTAL_IDENTITY_SECRET |
| all but core | CORE_API_URL=http://qm-core-core.<namespace>.svc.cluster.local:8080 |
| core | MODEL_PROVIDER, HARNESS, SANDBOX_BACKEND, PUBLIC_WEB_URL/WEB_UI_PUBLIC_URL (the portal's hostname), CAPABILITY_SECRET, CONNECTOR_SECRET_KEY, SKILL_SIGNING_SECRET, DATABASE_URL and SESSION_STORE=postgres — production refuses to boot without them |
| portal | PORTAL_PUBLIC_URL, PORTAL_SESSION_SECRET, WEB_UI_UPSTREAM, ADMIN_UPSTREAM, AUTH_BROKER_UPSTREAM, AUTH_BROKER_PREFIX=/idp, the OIDC_* set from brokerWiring in cli/src/services.ts (including OIDC_CLIENT_SECRET), and OIDC_ALLOWED_EMAILS or OIDC_ALLOWED_EMAIL_DOMAIN — production refuses to boot without an allow-list |
| admin | ADMIN_BASE_PATH=/admin |
| auth | AUTH_ISSUER=/idp, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_REDIRECT_URI=/auth/callback, AUTH_TOKEN_SECRET, AUTH_SIGNING_JWK (a P-256 private JWK), AUTH_EMAIL_FROM, and AUTH_EMAIL_TRANSPORT with a RESEND_API_KEY or SMTP credentials — without a mail transport nobody can sign in |
Core also reads RESEND_API_KEY and AUTH_EMAIL_FROM when they are set — optional there,
they let admins email external-user invitations from the admin Users tab or by chatting
with QM.
The admin module runs in web-ui at /admin; its environment belongs to web-ui.
The auth module runs inside portal: move its environment and secrets to portal,
set AUTH_EMBEDDED=1, and use http://127.0.0.1:8099 forAUTH_BROKER_UPSTREAM and the OIDC token, userinfo, and JWKS endpoint base.
Set ADMIN_UPSTREAM to the web-ui service URL followed by /admin.
See the combined-service migration before upgrading existing apps.
src/deployment/secret-schema.ts is the authoritative list of what each service
requires; when a boot refusal names a variable this table doesn't, that file is the place
to look.
The portal's hostname is only knowable after its first apply, and core, portal, and auth
all need it, so expect to apply twice: once to mint the hostname, once to wire it in. The
CLI does not print the assigned hostname — read it from the app's page in the dashboard,
or from the ingress itself: porter kubectl --print-kubeconfig writes a short-lived
kubeconfig, and kubectl get ingress lists every assigned host.
Porter's own Postgres datastores are created from the dashboard. An RDS instance in the
cluster's VPC works too, but Postgres 17 defaults to rds.force_ssl=1, so the URL needs?sslmode=no-verify (or a CA pinned with DATABASE_CA_CERT) or core dies on no pg_hba.conf entry ... no encryption.
deploy/helm/ carries the same topology as a Helm chart for operators who would rather
manage the release themselves; it expects the images published atghcr.io/yc-software/qm/<service> and an ingress you configure. Pin image.tag to a
commit that actually carries the Porter backend — the published tags are commit SHAs, and
one from before Porter support landed rejects SANDBOX_BACKEND=porter at startup. SetpublicUrl and the chart derives the broker wiring (AUTH_ISSUER, AUTH_REDIRECT_URI
and the portal's OIDC_* endpoints) the way cli/src/services.ts does for the other
targets; what it cannot invent is the identity allow-list, so production still refuses to
boot until AUTH_ALLOWED_EMAILS/AUTH_ALLOWED_EMAIL_DOMAIN is set, and the auth broker
needs a real mail transport (RESEND_API_KEY or the SMTP_* set) because there is no
console transport. SANDBOX_BACKEND has no default and core refuses to start in
production without it.
porter kubectl --print-kubeconfig is enough to read a Porter cluster but not to install
into one: the kubeconfig it prints authenticates as porter-readonly-sa-customer, which
holds get/list/watch and nothing else, so helm install fails on the first write. On
EKS, grant yourself real access through AWS instead — aws eks update-cluster-config --access-config authenticationMode=API_AND_CONFIG_MAP, then an access entry for your own
principal with AmazonEKSClusterAdminPolicy, then aws eks update-kubeconfig.
Giving published apps stable hostnames
App serving is tiered so the zero-config path works first. With nothing set, every
published app is reachable signed-in at /d/<app>/ on the portal, private to its owner
and whoever the deployment is shared with; proxied HTML is served under a sandbox CSP so
app code cannot act on the portal's origin. Setting DEPLOY_APPS_DOMAIN to a wildcard
domain you control (e.g. apps.example.com, with *.apps.example.com pointed at the
instance) upgrades every app to its own origin — https://<app>.apps.example.com/ — with
portal single sign-on, the request-access flow, and live editing; the portal derives its
cookie and returnTo settings from it, and boot probes the wildcard record and prints the
exact DNS record to add when it is missing. Point the wildcard at the ingress that fronts
core, never at Porter's sandbox ingress — the gate runs in core, so DNS that skips core
skips the gate. PORTER_DEPLOY_APPS_DOMAIN is the separate, ungated mechanism: it
registers each app's domain on Porter's own sandbox ingress and is deliberately NOT
defaulted from DEPLOY_APPS_DOMAIN.
By default published apps get no sandbox ingress host at all. Core reaches each app at the
cluster-internal address the cluster reports, so /d/<app>/ and DEPLOY_APPS_DOMAIN work,
no Porter hostname skips qm's gate, and the cluster needs no sandbox ingress. This needs core
running in the same cluster as the sandboxes and sandbox-api 0.1.58 or newer. Apps published
before an upgrade keep their ingress host until they are redeployed. The rest of this section
applies only when PORTER_DEPLOY_VISIBILITY is private or public, orPORTER_DEPLOY_APPS_DOMAIN is set (which implies private).
With ingress, published apps get their address from the cluster, not from qm. Declare the sandbox load
balancer with a root domain at cluster creation and Porter names every published app
itself: <app>.<root domain>, served on a wildcard Let's Encrypt certificate that Porter
also obtains. PORTER_DEPLOY_APPS_DOMAIN is then only for choosing a different name — leave
it unset and the cluster-assigned hostname is used as-is. Set it when the cluster serves
more than one apps domain, or when you want a name other than the cluster's own root domain;
it has to resolve to the same sandbox ingress either way.
The address comes from Porter's sandbox ingress, which is separate from the ingress
that serves Porter apps and is configured per cluster. A cluster without it rejects every
publish before DNS is ever consulted, with HTTP 400: validation error: visibility: no private sandbox ingress is configured on this cluster (no public sandbox ingress whenPORTER_DEPLOY_VISIBILITY=public). A cluster that has it but no root domain names no host,
and the deploy fails after the body is created (the body is then retired, so nothing leaks).
The dashboard has a toggle for it, but it is also a plain field on the cluster contract,
so it can be turned on headlessly. GET /api/projects//contracts returns the
revisions for the project, newest last, each with a base64_contract; decode the current
one, add the sandbox load balancer, and POST the whole contract back to/api/projects//contract (singular, raw protojson — not base64, not wrapped):
{
"kind": "LOAD_BALANCER_KIND_NLB",
"owner": "LOAD_BALANCER_OWNER_SANDBOX",
"networkAccess": "NETWORK_ACCESS_PUBLIC",
"dnsProviderConfig": { "provider": "DNS_PROVIDER_TYPE_ROUTE53", "route53Domain": "" },
"rootDomains": ["apps.example.com"],
"waf": { "enabled": false }
}appended to cluster.additionalLoadBalancers, alongside cluster.eksKind.sandboxesEnabled: true. Route53 authenticates through the cluster's own pod identity and needs no token;DNS_PROVIDER_TYPE_CLOUDFLARE needs one stored first at POST /api/v2/projects//clusters/<cluster>/dns/credentials. The same route creates a
cluster in the first place — omit cluster.clusterId and Porter provisions a new one.
Note where that array hangs: additionalLoadBalancers is a field of cluster, a sibling ofeksKind, while sandboxesEnabled is inside eksKind. Putting the load balancer in the
obvious-looking spot next to sandboxesEnabled does not fail — Porter parses the contract
with unknown fields discarded, so a misplaced or misspelled key is dropped in silence and
the POST still answers 201 with a revision id. The cluster then provisions with no sandbox
ingress and the mistake only surfaces 40 minutes later, when the first publish is refused.
Always read the contract back after submitting it and confirm the fields you set are
actually there:
curl -s -H "Authorization: Bearer $PORTER_DEPLOY_API_TOKEN" \
https://dashboard.porter.run/api/projects/<project>/contracts |
jq -r '.[] | select(.cluster_id==<cluster>) | .base64_contract' | tail -1 | base64 -d | jq .The same silent-drop rule makes the contract useless for guessing at fields the schema may
not have: an invented key and a real one are equally accepted. To test whether a field
exists, send it together with a deliberate control key that certainly is not a field, then
read back and compare which of the two survived.
Put the sandbox load balancer in the contract you create the cluster with, and treat every
later revision as a one-at-a-time operation: submit a revision only when the cluster
reads READY, and let it finish. A revision submitted while another is reconciling
supersedes it, and the interrupted one stops with CONCURRENT_UPDATE ("contract revision
is obsolete, and has been acked"). That path can strand the cluster in UPDATING, and
Porter gates both recovery routes on the status — a new contract is refused with cluster status forbids updating and deletion with unable to delete cluster that is updating.
Cancelling the dead revision returns 200 without clearing it, so the cluster has to be
unstuck by Porter support.
A stranded cluster is not idle. Porter's Cluster API controllers keep reconciling it from
Porter's own account, so tearing its AWS resources out by hand does not work: delete the
EKS cluster and the nodes and they are rebuilt within minutes, byarn:aws:iam::<account>:role/porter-manager with a aws.cluster.x-k8s.io user agent —aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=CreateCluster shows the recreate. The reconcile
loop enters the account through one door, so close that first: porter-manager andporter-access-manager each trust arn:aws:iam::108458755588:role/CAPIManagement under an
external id, and dropping those two statements from their trust policies (aws iam update-assume-role-policy) stops the rebuild immediately. Only then does a manual sweep
hold. For a wedged cluster the IAM revoke comes first, not last — the usual teardown
order that leaves IAM until the end cannot terminate.
Deleting a cluster through the API needs an admin-role Porter token: DELETE /api/projects//clusters/<cluster> answers a project API token with 403 {"error":"insufficient permissions to perform action"} even when that token may create
clusters. Check that you hold one before you provision anything you will need to remove.
The same status gate makes the contract endpoint useless for schema discovery on a wedged
cluster. Porter resolves the cluster and checks its status before it parses the protojson
body, so an unknown field and a well-formed one come back with the identical cluster status forbids updating, and a nonexistent clusterId returns sql: no rows in result set just
as uniformly — probing for whether a contract field exists needs a healthy cluster.
Once the revision reconciles, GET /api/v2/projects//clusters/<cluster>/load-balancers returns the newowner: "sandbox" entry whose address.value is what the wildcard record points at.
Routing, DNS and TLS all come up on their own, provided the sandbox load balancer was in
the contract the cluster was created with. Porter reads its dnsProviderConfig androotDomains, but the hosted zone itself is yours to create — confirmed against
Porter's team and a live cluster: even with the sandbox load balancer in the creation
contract, Porter never creates the zone, the delegation, or the wildcard record. Create a
dedicated Route53 hosted zone for the root domain, add its NS delegation to the parent
zone, put the wildcard record inside it pointing at the sandbox load balancer, then
reconcile the cluster (the dashboard's infra Update, or re-POST the contract) so Porter
creates the certificate issuer against the zone. cert-manager's DNS-01 challenge then has a
zone it can write to, and the wildcard certificate issues — a real Let's Encrypt*.<root domain> certificate, verified by ordinary clients with no -k. A wildcard CNAME
sitting in the parent zone is not enough: DNS resolves, but there is no delegated zone for
the challenge and the certificate never issues.
Porter also mints the credentials for it: a per-cluster roleporter-cert-manager-route53-<cluster id> whose inline policy allowsroute53:ChangeResourceRecordSets and ListResourceRecordSets on that one delegated zone,
plus GetChange and ListHostedZonesByName. If TLS is not issuing, check that this role
exists — its absence, not a cert-manager misconfiguration, is the usual cause.
Two things follow. The parent zone must be in the same AWS account the cluster runs in.
And teardown has to remove what you created: the delegated hosted zone and the NS
record in the parent zone both outlive the cluster.
This is worth stating plainly because the failure is so confusing: a cluster whose root
domain was never delegated has no usable zone for the DNS-01 challenge, so the certificate
never issues and the ingress serves nginx's self-signed Kubernetes Ingress Controller Fake
Certificate. Every client then fails certificate verification against an app that is
otherwise serving correctly — including the agent's own probe of the app it just published, which is why it
reports a gateway error it cannot explain. kubectl get certificate -n porter-sandbox tells
you which situation you are in before you go hunting.
Naming the domain is optional. Porter assigns every sandbox that exposes a port a hostname
of its own, <sandbox name>.<cluster root domain>, whether or not the create request asked
for a domain — sending networking: [{port: 8080}] with no domains key, or withdomains: [], both come back with a populated host. So a cluster whose sandbox load
balancer carries a root domain publishes apps at a working HTTPS address with nothing set:
PORTER_DEPLOY_APPS_DOMAIN=apps.example.com # optional; overrides the assigned hostnameSet it only to choose the name yourself; the wildcard record it relies on is the one Porter
already created. PORTER_DEPLOY_VISIBILITY=private serves apps on the private sandbox
ingress, and public opts them into public ingress. Note the
cluster-assigned hostname is Porter ingress only — qm's sign-on gate, request-access flow,
and live editing need DEPLOY_APPS_DOMAIN (a domain you control, not a shared platform
domain like onporter.run).
Forcing sandbox egress through the proxy
PORTER_SANDBOX_EGRESS_PROXY_URL makes core pass egress.allowed_destinations: [] when it creates a sandbox, and inject HTTPS_PROXY/HTTP_PROXY pointing at the
proxy with a capability token as the password. Two cluster-side facts decide whether that
works at all, and both fail in ways that do not name the real cause:
- The cluster must have egress restriction turned on. Without it Porter rejects the
create outright —HTTP 400: validation error: egress: egress restriction is not available on this cluster— so the agent ends up with no computer rather than an
unrestricted one. It is a per-cluster setting on thesandbox-apisystem application:PATCH /api/v2/projects//clusters/<cluster>/system-applications/sandbox-api
with{"sandbox_config":{"egress_enabled":true}}, thenPOST .../trigger-system-application-reconcilewith{"dry_run":false, "system_application_name":"sandbox-api"}.GETon the same path reads it back, and
turning it on installs Cilium, which enforces the allowlist. There is no contract field
for any of this — the cluster contract carries no egress key at all, so it cannot be set
at creation time and this PATCH is the only route. The call is gated on the token's role,
not on being a human: a Developer-role API token is refused withPERMISSION_DENIEDeven
though it may create clusters, while an Admin-role API token (Settings → API tokens)
is accepted. Minting an admin token is enough; no dashboard session is required. The same
distinction governsDELETE /api/projects//clusters/<cluster>, which answers a
Developer token with403 insufficient permissions to perform action. - The proxy has to be reachable from outside the cluster. Porter attaches a
NetworkPolicy to every sandbox that permits DNS plus0.0.0.0/0except10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16and100.64.0.0/10— every RFC1918
range, which is where both the pod and service CIDRs live. A sandbox therefore cannot
reach an in-cluster Service, so a*.svc.cluster.localproxy URL times out with no
error worth reading. Give the proxy an internet-reachable address (aLoadBalancer
Service in front of it is enough) and pointPORTER_SANDBOX_EGRESS_PROXY_URLthere.
This is also whydeploy/helm/'segress-proxyservice cannot serve Porter sandboxes
as-is: it is a ClusterIP worker.
The proxy itself runs fine unprivileged, but deploy/egress-proxy wants NET_ADMIN to
install the iptables rule that blocks the cloud metadata endpoint. Without the capability
it logs could not install metadata firewall ... NET_ADMIN missing? and keeps serving, so
grep for that line rather than assuming the container's own metadata block is in place.
Other knobs
| Variable | Meaning |
|---|---|
PORTER_DEPLOY_URL |
Porter API host, when it isn't https://dashboard.porter.run |
PORTER_SANDBOX_IMAGE |
Image agent computers boot from |
PORTER_DEPLOY_RUNNER_IMAGE |
Image published apps boot from (defaults to the sandbox image) |
PORTER_SANDBOX_EGRESS_PROXY_URL |
Forces sandbox traffic through the egress proxy; unset means no egress enforcement (see the constraints below) |
PORTER_SANDBOX_NAME_PREFIX |
Prefix for sandbox and app names on the cluster |
PORTER_DEPLOY_VISIBILITY |
internal (default) serves apps in-cluster only; private/public put them on sandbox ingress |
PORTER_DEPLOY_TTL_SEC / PORTER_SANDBOX_TTL_SEC |
Reap bodies after this long |
To QA a branch against a real Porter cluster before deploying it, the dev instance takes
the same backend:
bash scripts/dev-instance.sh up --sandbox porterPostgres Connections (`docs/postgres-connections.md`)
PostgreSQL connection ownership
Ordinary queries, including the pg-boss cron queue, share the process-wide query pool for a database URL. When DATABASE_POOL_URL is configured, these queries use transaction pooling. Queue shutdown releases its pool reference without closing other stores' connections. Queue migrations retain pg-boss's single-call transaction boundaries.
Notification subscriptions share one direct connection per database URL per process. Session state, ledger events, run signals, and run streaming register their channels on that connection. Closing a consumer removes its subscription; the last unsubscribe closes the listener. Reconnection restores the active channel set before notifying consumers to resynchronize from durable state. Notifications remain wake-up hints, not durable storage.
Session advisory locks and leader leases continue to use direct session connections. Query pool sizes, PgBouncer backend limits, and database placement are unchanged. Multiple processes and separate company databases still have separate listeners and pools.
Validate notification delivery, reconnection, and teardown with test/postgres-listener.test.ts against a disposable DATABASE_URL. The race and queue lifecycle tests run with the standard module-mocking test command. scripts/test-pgbouncer.sh exercises queue startup, execution, shutdown, and restart through a one-backend transaction pool.
This removes three notification connections when all four channels are in use, plus pg-boss's independent query pool. It does not promise a fixed total connection count: active queries, session locks, deployment overlap, and pooler replicas still determine server usage.
Principal Links (`docs/principal-links.md`)
Principal links
A person can reach qm through several sign-ins: a Slack account, an email
magic link, or a trusted OIDC provider. Each produces its own principal id
([email protected], U0JORDAN, oidc::<subject>). Without a
link, qm treats them as different people: a credential dropped from Slack is
owned by the email principal, and the same founder signed in through OIDC is
told the link is for someone else.
A principal link records that two principal ids are one person. One id is
the canonical principal; the other is a linked sign-in. Every
same-person check in core (personKey, samePerson) folds a linked sign-in to
its canonical principal, so ownership of credentials, admin grants, sessions,
approvals, and secret drops recorded under either id belongs to the person.
New writes use the canonical id: the identity service classifies a linked
sign-in as its canonical principal, the portal resolves the session subject
before proxying, and the keychain stores credentials under the canonical owner.
Rules
- Administrative links keep the directory member canonical. When the company runs Slack, the
Slack-verified directory principal is the person's canonical id and the
trusted OIDC subject is the linked sign-in. The admin API refuses a link
whose linked side is a directory member, by principal id or Slack id. - A link never creates an admin. A sign-in that holds an org admin grant
can only be linked to a canonical principal that is already an admin. - Links are flat. A canonical principal is never itself linked onward, and
a linked sign-in cannot become canonical for others. - Evidence is required. Every link records how the two identities were
verified as one person and which admin created it. - Links are reversible. Deleting a link restores two separate principals.
Records written while the link existed stay under the canonical id, so a
wrong link is an incident to reconcile, not just a row to delete. - Deactivation follows the person. Deactivating either id deactivates both.
Admin API
Org admins manage links through the admin surface; the agent cannot.
GET /v1/admin/principal-links
POST /v1/admin/principal-links { principalId, canonicalId, evidence }
DELETE /v1/admin/principal-links/:principalIdSurfaces resolve a sign-in with a source-authenticated read:
GET /v1/principals/:id/canonical → { principalId, canonicalId }The portal caches the answer for one minute and refuses to proxy (503) when
core cannot answer, rather than acting as the unresolved subject. A deleted
link therefore stays effective for at most a minute of cached sessions.
Links live in the principal_links durable map of the company database.
Self-service Slack linking
The dedicated Connect Slack flow preserves the signed-in web principal as canonical, keeping its existing connector ownership. Completion verifies a short-lived signed ticket, Composio account ownership, Slack user identity, and the installed company workspace before linking the directory member. Existing credentials or Composio accounts on the Slack principal require support instead of being silently stranded. Email similarity is not proof of identity.
Sandbox Preservation (`docs/sandbox-preservation.md`)
Sandbox recovery
See prebuilt Modal images to prepare system dependencies and the
connector SDK before creating user sandboxes.
The agent runs in core. Sandbox home state is working state with a provider-specific recovery window. Publish code to git and deliverables to Files when they must remain durable and accessible independently of compute.
MODAL_NATIVE_SNAPSHOTS_ENABLED defaults to false. First deploy this reader-compatible release with native capture disabled; keep portable checkpointing until every active core and the retained rollback release can read native checkpoints. Enable the flag only in a later deployment after that condition holds. Once a scope has a native checkpoint, disabling the flag continues its native capture and recovery; it does not revert that scope to stale portable state. After activation, rolling back to releases without the native reader is unsupported and can lose working state.
When enabled, Modal uses native directory checkpoints of /root, mounted into a fresh sandbox when the current sandbox is replaced. It does not stream the home through core during routine checkpointing or rollover. The base image supplies system dependencies outside the home; a home checkpoint does not preserve processes, RAM, or system-wide package installs. Runtime credential files under /tmp are outside the directory checkpoint; ordinary home files and symlinks are included.
MODAL_NATIVE_SNAPSHOT_INTERVAL_SEC defaults to 300 and controls checkpoint throttling independently of the legacy MODAL_SNAPSHOT_INTERVAL_SEC. Used-turn teardown, replacement, and idle reaping capture checkpoints. The sandbox maintenance sweep runs every five minutes whenever background maintenance is enabled and either idle window is positive; it checkpoints active machines when due, including machines running background work. A machine within thirty minutes of Modal's 24-hour sandbox lifetime is checkpointed and terminated by the sweep even while background work runs, because Modal would otherwise kill that work unsaved; the next provision restores the checkpoint into a fresh machine. This is best-effort recovery, not a guarantee that a process running into the provider deadline has saved its latest writes. Captures while processes write are not application-consistent database backups.
MODAL_SNAPSHOT_RETENTION_SEC defaults to 2592000 (30 days) and must be positive. Checkpoint IDs, capture timestamps, errors and expiration times are stored with sandbox records. DATABASE_URL must point to durable Postgres to retain these references across core restarts; without it the sandbox record map is memory-only. Status reports recovery.checkpointExpiresAtMs; using a checkpoint does not renew its expiry, because Modal retains a directory snapshot for a hard cutoff measured from creation. The maintenance sweep therefore renews the checkpoint of a reaped idle scope once less than seven days (or half the checkpoint's lifetime, whichever is shorter) remain: it restores the checkpoint into a fresh machine, captures a new checkpoint, and terminates that machine again, so an idle user never loses a home to retention alone. Renewal holds the same lifecycle lock as provisioning and leaves the scope's idle history untouched. Expired or failed native restores stop with an explicit recovery error instead of replacing the home with an empty directory or silently using an older portable backup. A core crash during hydration leaves a durable pending marker; another core refuses to expose that potentially incomplete home until an operator confirms the original core is no longer hydrating and invokes sandbox with action: "restart". Restart terminates only the incomplete replacement, retains checkpoint references, and clears the pending marker; the next provision retries that retained checkpoint. It does not archive or promote the incomplete home. Handled restore failures can retry after successful cleanup of the failed replacement.
E2B creates persistent machines with an explicit pause-on-timeout lifecycle and pauses them at teardown unless keepWarm retains running background work. See E2B sandbox template for building the sandbox image.
The provider timeout is managed, not left to expire under work. Every command first extends the sandbox timeout to cover the command plus a one-minute margin when the remaining lifetime is shorter, E2B_SANDBOX_TTL_SEC defaults to the largest command timeout plus that margin instead of fifteen minutes, and keepWarm teardown extends the timeout to BACKGROUND_JOB_TTL_MAX_SEC so background processes survive as long as the background-work contract promises. Every extension is capped by E2B_MAX_LIFETIME_SEC (default 86400, E2B's Pro-plan maximum; Hobby plans must set 3600). If a sandbox is nevertheless lost while a command is running, the command is not re-run: it may have partially executed, so the turn receives an explicit error and the next command reconnects. A sandbox found gone before a command starts is reconnected or replaced transparently, as before.
Routine native pause does not create a tar backup. Instead, used-turn teardown captures a provider-native persistent snapshot (E2B createSnapshot, an image that survives deletion of the sandbox) when one is due under E2B_NATIVE_SNAPSHOT_INTERVAL_SEC (default 300), then pauses. Only the newest snapshot per scope is kept; the superseded one is deleted after the new capture succeeds, and destroying the scope deletes the snapshot too. The capture briefly pauses the sandbox and drops any open connections, which is why it runs only at teardown and never while a command is in flight. Status reports the snapshot as recovery.checkpointId with no provider expiry. When the paused sandbox itself is gone, the next provision creates a replacement from that snapshot; a scope with no snapshot still requires explicit recovery rather than an automatic blank replacement. Snapshot capture failures are recorded as the recovery error and reported, and the pause still proceeds.
Provider pause retains disk and normally memory; provider fallback may retain disk only, so reconnectable processes must not depend on RAM surviving. Status can inspect a paused machine without waking it and, for a running one, reports CPU, memory and disk usage from the provider's metrics. Pause failures are recorded and returned, with the running machine retained for retry. Existing E2B machines whose lifecycle does not confirm pause-on-timeout retain legacy portable checkpointing under E2B_SNAPSHOT_INTERVAL_SEC.
With E2B_EGRESS_PROXY_URL set, sandboxes are created with E2B network rules that deny all outbound traffic except the proxy host, and existing sandboxes receive the same rules when core reconnects, so the HTTPS_PROXY environment cannot be bypassed from inside the sandbox. E2B matches allowed hostnames by SNI on port 443 and by Host header on port 80 only, so the proxy must listen on one of those ports or be given by IP; core refuses to start with a hostname on any other port rather than silently blocking all egress. Without the setting, sandboxes have unrestricted egress and the profile reports no enforcement.
Sprites keep the whole disk across sleeps, so the sprite itself is the durable home. Every used-turn teardown that may have changed the home takes a provider checkpoint (copy-on-write, throttled to one per five minutes per core); computerStatus reports the newest one as recovery.strategy: "provider_snapshot". Checkpoints are the rollback path: when a computer restart is refused and the health check positively reports an unhealthy status, restart restores the newest checkpoint and records checkpoint_restored, which discards changes made since that checkpoint. Deleting a sprite is irreversible and deletes its checkpoints with it, so with SPRITES_SNAPSHOT_S3_BUCKET set a scope's home is exported as a portable tar before its sprite is destroyed, a failed export refuses the destroy, and a replacement sprite for that scope hydrates from the export. Initialization is recorded as pending before provider creation in a durable map, and provision, capture, restore and destruction share a cross-core lifecycle lock. A failed initialization remains pending across core restarts and cannot overwrite a saved home; a later provision retries hydration. Failed or unknown health observations never authorize checkpoint rollback. Background processes started with process sessions hold the sprite awake through the Tasks API while they run; they still do not survive a cold wake, a restart or a checkpoint restore. SPRITES_MEMORY_MB sets an explicit memory resources policy per sprite; without it memory is provider-managed. Commands run over the WebSocket exec channel and files move through the filesystem API.
Smolmachines machines keep their disk across stop and start, and an idle machine stops after SMOLMACHINES_AUTOSTOP_SEC seconds without activity when that knob is set (the provider sweeps every 15 seconds; an in-flight exec keeps the machine alive). The provider documents that stopping is not a backup and that the cloud API has no machine snapshot; export to a .smolmachine artifact is the only native capture and it is a whole-disk registry artifact, so core keeps portable home tars instead. Every non-scratch teardown snapshots the home to SMOLMACHINES_SNAPSHOT_S3_BUCKET when the home changed, throttled by SMOLMACHINES_SNAPSHOT_INTERVAL_SEC; a replacement machine for a scope hydrates from that tar and a failed hydration deletes the incomplete replacement instead of adopting it. Initialization is marked pending in the durable sandbox record before create, so a lost create response or core restart cannot make the next core skip restoration. Provisioning, capture, and destruction share a lifecycle lock across cores; initialization is only marked complete after hydration succeeds. Status reports recovery.strategy: "workspace_snapshot" with the last capture time and error. Without the bucket the backend offers no persistHomeSnapshot and reports no recovery. Scratch machines are created ephemeral with a 24-hour ttlSeconds so a core crash cannot leave them running.
Explicit home export/import and persistHomeSnapshot continue to use the portable tar format for cross-provider moves and chosen recovery. Configure MODAL_SNAPSHOT_S3_BUCKET, E2B_SNAPSHOT_S3_BUCKET, SPRITES_SNAPSHOT_S3_BUCKET or SMOLMACHINES_SNAPSHOT_S3_BUCKET to retain those portable copies durably. They are not silently selected instead of a failed newer provider checkpoint.
Modal machines are created with a one-core, 2 GiB reservation (MODAL_CPUS, MODAL_MEMORY_MB), optional placement (MODAL_REGIONS, a comma-separated list; place machines beside MODAL_SNAPSHOT_S3_BUCKET), a 24-hour lifetime, and a provider idle timeout of twice MODAL_REAP_IDLE_SEC (12 hours by default) as a backstop when no core is sweeping. Every machine carries qm-org, qm-prefix, and qm-kind tags; the sweep lists running scope machines by tag and terminates any that no sandbox record or live session has claimed for ten minutes, so machines orphaned by store loss stop billing without operator action. Scratch machines are excluded because other cores hold them only in memory.
When MODAL_EGRESS_PROXY_URL is set, machines are created with Modal's outbound allowlist limited to the proxy host (a domain allowlist for an https proxy on port 443, a CIDR allowlist for an IP address; other proxy URLs are rejected at startup because Modal domain allowlists pass only TLS on 443), and the profile reports egressEnforcement: "domain". Command execution, file transfer, and checkpoints travel over Modal's control plane and are unaffected. Without the setting, core warns that Modal sandboxes run fail-open.
Turn environment reaches Modal commands through the exec env parameter rather than inlined export statements, exec deadlines are rounded up to whole seconds with a single thirty-second grace beyond the in-guest timeout, and a command longer than 64 KiB is written to /tmp through the filesystem API and executed from there.
Managed Agents sessions name a sandbox template explicitly, which yields a microVM carrying no managed agent loop, and the disk persists across the provider's pause and resume. Used-turn teardown calls POST /v2/agents/sessions/{id}/checkpoints when the home changed, throttled by DO_AGENTS_SNAPSHOT_INTERVAL_SEC from the last successful capture, then snapshots the home as a tar on that same interval measured from the last successful tar. The tar goes to DO_AGENTS_SNAPSHOT_S3_BUCKET when it is set; without it the tar is held in core's memory and does not survive a core restart. A failed tar is retried without taking another checkpoint. The checkpoint is the provider's machine-state capture of the live session (CreateCheckpoint blocks until the capture is READY); only the newest is kept, and the previous one is deleted after the new capture succeeds. A delete that fails stays recorded and is retried on a later teardown. A capture failure is recorded on recovery.error and the pause still proceeds. A pause failure is recorded as pause_failed and the running session is retained for retry. Status reports recovery.strategy: "workspace_snapshot" plus recovery.checkpointId. Restart of a wedged computer calls POST /v2/agents/sessions/{id}/checkpoints/{checkpoint_id}/rollback with that stored checkpoint: the session id stays, the response's sandbox id replaces the stored one, and any open port-forward is closed so the next command dials the restored microVM. A paused session is rolled back directly, without being resumed first. Restart with no checkpoint fails and leaves the session where it is. A checkpoint is bound to its session, so a replacement session for a destroyed or reclaimed scope still hydrates from the portable tar when one exists, and a failed hydration kills the replacement rather than adopting a blank home over a saved one. Session lifecycle travels over the Managed Agents REST surface with a DigitalOcean IAM token; commands and file transfer travel over a bearer-authenticated WebSocket port-forward to the guest's sandbox-agent gRPC server, which has no request deadline, so command length is bounded by SANDBOX_TIMEOUT_SEC rather than by the control plane. Sessions are created with a permissions block that allows bash outright, because Managed Agents would otherwise hold every command at its own approval gate behind core's.
Sandbox Resources (`docs/sandbox-resources.md`)
Sandbox resources and defaults
A sandbox is an independently recorded resource with an owning scope, provider, backing identity, and lifecycle state. Creating a sandbox provisions a blank machine without copying files or changing a default. Setting a default changes routing only. Background jobs retain the target on which they started.
Sandbox resources are always on. The sandbox tool provides lifecycle management; execute is the sole command launcher and background controls existing processes. list returns the available providers and supported actions. Unsupported provider operations fail explicitly. Files publication is separate from sandbox management.
execute accepts sandbox_id for both foreground and background commands. Without a target, execution requires the scope's stored default. set_default accepts an ID or null; null clears the default. A new scope has no default. Retiring a sandbox requires clearing its default and stopping its jobs first. Retirement is available only for providers with direct scope deletion. It never provisions or restores a machine. Once retirement begins, execution stays blocked; interrupted or failed cleanup remains visible and can be retried. Retirement deletes its working state, so durable outputs should be published to Files or git beforehand.
In Isolated posture, agent inventory and operations stay in the current owning scope. In Open posture, authenticated human turns and owner-authorized scheduled turns can also target the acting person's personal sandboxes and Open shared sandboxes where they are a current member. Access is rechecked for each operation; changing conversations does not transfer ownership or grant other participants personal access. Cross-context execution uses the target's existing workspace without copying the calling conversation's credentials, files, or capability tokens onto it. Defaults remain local to their owning scope. Cross-context commands must satisfy both the calling conversation's and target sandbox's command policies. Network access is narrowed to satisfy both egress policies. Provider-native recovery status includes its own expiry; a sandbox record is not an indefinite backup guarantee.
Agent actions
| Action | Parameters |
|---|---|
list |
purpose; returns providers, capabilities, resources and default |
create |
backend, optional name, purpose; creates a blank resource |
set_default |
sandbox_id or null, purpose; changes routing |
status, restart |
optional sandbox_id, purpose |
retire |
sandbox_id, purpose |
Commands use execute(command, sandbox_id?, credentials?, background?). Setbackground:true for a detached job and a process ID. The short purpose is saved
with the job and displayed instead of the command; the raw command remains available
in expanded output. timeout_seconds is the foreground wall-clock timeout or the
background job lifetime. Background jobs require scoped, durable execution; disposable
scratch, owner-auth and reached-room routing remain foreground-only.
The background tool controls existing jobs with poll, send_input, stop, list,watch, and unwatch. Process IDs retain their durable saved target even after the
scope default changes. Watches retain their durable monitor registration and wake this
conversation with output or exit. Provider loss or expiry can still interrupt a process.
Execution and process handlers retain existing command-policy, credential authorization,
and output-screening checks. Strict approvals use the called tool identity (execute,
a process action such as background:poll, or a lifecycle action such as sandbox:retire); a sandbox grant cannot
authorize commands. Transcript entries identify the called tool. Removed sandbox command
and process actions and background start are not aliases. No database migration or
backfill is needed; existing jobs remain controllable by process ID. Interrupted old tool calls may return an unsupported-action error and require agent recovery with the new tools.
Upgrade and rollback
Resources and explicit defaults are always enabled. At startup, a one-time, locked import adopts old routing entries, known session scopes, and provider records. It preserves existing defaults (including an explicit null), resource IDs, and original backing identities. It makes no provider calls or disk copies. Inferred resources start unverified; the completion marker is written last, so an interrupted import can be retried. Before writing adoption state, the import refuses any newly selected default whose provider is not configured, naming the affected scope and provider. Installations with the existing explicit-defaults marker have already completed this import.
The old routing table is retained only as upgrade input. It is never read when selecting a computer, and there are no routing-table write or migration endpoints. SANDBOX_SCOPE_BACKENDS is accepted only to interpret old bindings during the import; remove it afterwards. SANDBOX_RESOURCES_ENABLED no longer changes behavior.
Before upgrading from a release without resource-aware readers, drain its cores and workers. Rollback must use a resource-aware release that honors the permanent completion marker. A missing default after import means no default, not permission to recreate an old scoped computer.
Scoped commands require an explicit target or selected default. Disposable scratch and owner-auth computers use the selected computer's provider without reusing its disk or resource identity; with no selection they use the installation provider, without creating a default. An unavailable selected provider fails explicitly rather than substituting another provider.
To move work between providers, create a computer, restore the required work from git or Files, verify it, and select it as the new default. Selection does not copy files or migrate running jobs.
Session Sharing (`docs/session-sharing.md`)
Session sharing
The Share button beside a session title creates a read-only copy of its visible messages, replies, and attachments. Every creation produces a fresh link. Existing links keep their original content; there are no update or revoke controls.
Choose Anyone in your organization to require a signed-in internal account, or Anyone with the link to allow viewing and downloading without signing in. The latter is a bearer link: recipients can forward it. External links use the deployment's public portal origin; localhost links work only on the machine running the dev instance.
The server constructs the snapshot from an allowlist of user messages, final assistant replies, successful published replies, and their attachments. Thinking, intermediate text, tool commands/results, hidden messages, raw payloads, and source metadata are never sent to the shared webpage. Successful attachment delivery is projected to file metadata only. Text and attachments can themselves contain sensitive information; choose the audience accordingly.
Each attachment is authorized for the creator and copied into separate durable share storage. Share-specific file identifiers authorize only that snapshot's copied files. Downloads cannot fetch arbitrary session files. Raster images may render inline; other types download with a sandbox policy. Shared markdown cannot embed remote media or private resource links.
Snapshots live in the durable session_shares map, keyed by an unguessable random token. Copied bytes live in the configured durable byte backend under session-shares. Both internal and external reads verify that the creator is still internal and can access the original entries. If that access disappears, the share becomes unavailable.
The public portal permits only external share pages, their file downloads, and built static assets without sign-in. It forwards no browser cookies or identity headers on those routes. The web server calls the dedicated signed core projection endpoint; it never loads the private session API. Shared pages use an isolated frontend entry with no session fetches, no-store responses, no-referrer, noindex, and a restrictive content security policy. External pages use the production build even during development.
Skill Registry (`docs/skill-registry.md`)
Skill registry (Phase A)
Import skills from a git repo of SKILL.md files into the governed skill store — no code change,
no image rebuild. An admin registers a pack, browses it, and imports skills org-wide; the
skills then show on the admin Skills page and materialize onto boxes through the normal path.
How it works
git pack ──fetch(pinned ref)──▶ general normalizer ──▶ planIngest (classify) ──▶ importPack
│
create→review→publish (shared upsert)
▼
org-scope SkillStore ──▶ materialize ──▶ box- One general normalizer (
src/skills/normalize.ts) maps any repo's frontmatter dialect onto the
canonical manifest via an alias table + ignore-unknowns + safe defaults. No per-repo code — a
repo's specifics are declarativeconfigon the pack (skillGlobs/exclude/fieldOverrides). - Eligibility (org import): a skill is imported only if its scope hint is org-shareable
(company/both/org/shared/ …). Excluded with a visible reason:scope(personal/missing),private(owner-only flags or aTHE-AGENT-ONLYbody),collision(name already owned by a
different pack / native seed),binary-asset,malformed. - Pinned in Phase A: an install is frozen at the pack's
ref. "Update" = bump the ref + re-import
(idempotent; unchanged skills are skipped, changed ones re-published, upstream-removed ones archived
onremove). The sync engine (tracked mode) is Phase B. - Org scope only in Phase A (team scope needs resolution work — Phase D). Authz: any
org_admin.
Admin API (`/v1/admin/skill-packs*`, org_admin, audited)
POST /v1/admin/skill-packs register { url, ref, config?, authCredentialSlug?, trustTier? }
GET /v1/admin/skill-packs list packs + last-import status
GET /v1/admin/skill-packs/:id/catalog browse (planIngest: candidates + eligibility + counts)
POST /v1/admin/skill-packs/:id/import { selected: "all" | string[] }
PATCH /v1/admin/skill-packs/:id edit ref / subset / trustTier / config
DELETE /v1/admin/skill-packs/:id remove pack + archive its imported skillsOr use the Admin UI → Skill packs tab (register form, browse with select-all/pick, import, remove).
Registering a private pack
First vend a read-only deploy token as an org service credential, then register the pack at a pinned
commit.
Vend the deploy token (Admin UI → Governance → Shared service credentials, or
PUT /v1/admin/scopes/org:<org>/service-credentials): create a credential with a slug likeskills-repo-tokenwhose secret is a GitHub token with read access to the repository.Register the pack (Admin UI → Skill packs → Register, or
POST /v1/admin/skill-packs):json{ "url": "https://github.com/example/acme-skills", "ref": "<pinned commit SHA>", "authCredentialSlug": "skills-repo-token", "trustTier": "third-party" }Personal/missing-scope/private skills and any name colliding with a native skill are excluded
automatically and shown with their reason in the catalog.Browse + import: open the pack, review the candidate list (≈ the company/both skills, minus
thepublishcollision), and Import all. The imported skills appear on the Skills page aspublished,createdBy: pack:, withpack.commitprovenance.Updating: bump the pack
ref(PATCH) and re-import. Removing the pack archives its skills.
The skills are governed store records, visible and auditable on the admin Skills page, rather than
ungoverned files on each box.
Loading published skills
The skill index lists each visible skill. The agent's skills tool resolves the currently
visible, screened published skill and returns its SKILL.md, or a relative manifest file
named by path, without creating or waking a sandbox. Unavailable skills and invalid
paths return no file.
When a skill ships supporting files or pack bundles, the same call writes them to.agent-skills/<conversation>/<name>/ on the computer (packs under.agent-skills/<conversation>/.packs//) and reports that absolute path; the body's ownskills/<name>/ references are rewritten to it. The copy stays across turns and each read
overwrites it with the published version. When a turn first prepares the computer, it
removes every copy of a skill that turn's speaker cannot see, so archived, revoked, blocked,
or another person's skills do not linger on a shared computer. Publish changes through the
skill API to update the source.
Superserve (`docs/superserve.md`)
Superserve agent sandboxes
The superserve backend runs each QM scope in a sandbox with a persistent workspace,
file transfers, and background process sessions. Paused sandboxes resume on use.
Configure
Build and verify an agent template, then set:
SANDBOX_BACKEND=superserve
SUPERSERVE_API_KEY=your-superserve-api-key
SUPERSERVE_TEMPLATE=qm-agent-0.1.0
SUPERSERVE_NAME_PREFIX=my-qmStore the API key as a core service secret. Production requires DATABASE_URL for
sandbox records, provisioning locks, and configuration ordering across instances.
Use a distinct name prefix for each independent deployment sharing a Superserve
team, including development instances.
For CLI deployments, add these fields to the deployment config and supply the API
key through the CLI's secret configuration:
{
"sandbox": { "backend": "superserve" },
"env": {
"core": {
"SUPERSERVE_TEMPLATE": "qm-agent-0.1.0",
"SUPERSERVE_NAME_PREFIX": "my-qm"
}
}
}env.core.SANDBOX_BACKEND overrides sandbox.backend. Agents and admins select Superserve per
scope by creating a Superserve sandbox and setting it as the default.
For local development, export the key and template and runnpm run dev-instance -- --sandbox superserve.
Sandbox capacity
Sandboxes inherit CPU, memory, and disk capacity from their template. The agent
template builder defaults to 8 vCPUs, 16384 MiB (16 GiB) memory, and 32768 MiB
(32 GiB) disk. Override these with --vcpu, --memory-mib, and --disk-mib at
build time; there are no runtime resource-size settings. Existing sandboxes are
not resized by updating QM or rebuilding a template. Follow the capacity rollout steps
to build and verify a new template, preserve resident work, and replace sandboxes.
Settings
| Variable | Default | Purpose |
|---|---|---|
SUPERSERVE_API_KEY |
Required | Team API key, held by core. |
SUPERSERVE_TEMPLATE |
Required | Ready template name. |
SUPERSERVE_BASE_URL |
SDK default | API endpoint override. |
SUPERSERVE_NAME_PREFIX |
qm |
Namespace for scope discovery. |
SUPERSERVE_HOME_DIR |
/root |
Guest home; workspace is <home>/workspace. |
SUPERSERVE_IDLE_PAUSE_SEC |
900 |
Active-time limit before pause, not an inactivity timer. |
SUPERSERVE_RETENTION_SEC |
2592000 |
Delete the sandbox after this many continuous seconds paused (30 days by default). |
SUPERSERVE_EGRESS_ALLOW |
Unset | Comma-separated outbound allow rules. |
SUPERSERVE_EGRESS_DENY |
Unset | Comma-separated outbound deny rules. |
SUPERSERVE_CONFIG_GENERATION |
Automatic | Explicit rollout sequence. |
SANDBOX_TIMEOUT_SEC |
600 |
Default command deadline in seconds. |
Ending a turn leaves its sandbox running until the active-time limit, so other
turns sharing it can continue. Background work can extend that limit to QM's
configured maximum background job lifetime. Scratch sandboxes are deleted when
their last local handle closes, with time limits as a cleanup fallback.
Command output is limited to 2 MiB per stream; write larger results to files.
Guest restart and the optional browser engine are not supported.
Updates and retention
QM rediscovers sandboxes by scope metadata after a restart and checks their actual
egress policy before reuse. Changing the template replaces existing scope
sandboxes and deletes their resident files. An egress change can also require
replacement when a paused sandbox cannot accept the update. Export needed files
before either change.
A sandbox that remains paused for SUPERSERVE_RETENTION_SEC is automatically
deleted, including its disk. Explicit scope destruction also deletes the disk.
Automatic configuration ordering assigns a generation to each build and
configuration. Older instances cannot overwrite a newer sandbox configuration.
A rollback reuses the earlier generation: to apply it, setSUPERSERVE_CONFIG_GENERATION to a strictly increasing rollout sequence and
continue using that sequence for subsequent deployments. Template and egress
changes during rollback have the same replacement behavior described above.
Tests
Backend tests run without an account:
node --experimental-test-module-mocks --test test/superserve-*.test.tsThe template verifier requires an API key and creates a temporary sandbox.
Swarms (`docs/swarms.md`)
Agent swarms
A swarm coordinates ordinary QM sessions. The initiating session is its root;
workers have their own durable transcripts, runs, identity, and editable JSON
character/context. The existing authenticated session viewer lists worker sessions
with a Swarm worker title. Worker transcripts remain read-only for ordinary messages in the web UI, but the requesting human can allow or deny pending approvals there. Discovery returns their session IDs and portal-relativesessionUrl links. Session activity and results use the normal viewer and run APIs.
Deployment
Use Postgres for production (DATABASE_URL, SESSION_STORE=postgres, andRUN_STORE=postgres) (sandbox resources are always on). Configure a sandbox backend with its required
credentials and deployment-specific settings. Workers use the provider of the scope's selected computer, or the deployment default when none is selected. An initialbackend override chooses another configured provider that supports creation and
retirement. That choice is stored once; later default changes never move workers
to another provider, and the selected computer's files are never copied.
Swarms are also gated per person by the swarms feature flag, off by default.
Enable it for a person's personal scope (for example personal:[email protected])
or the org scope in the admin feature-flag table. Without it the swarm routes refuse
the caller, /v1/apis omits them, and existing swarms owned by that person stop
reconciling and delivering. SWARMS_ENABLED=false remains the deployment kill switch.
The swarms durable-map table has its own registered migration,durable-map/swarms/0001. Apply registered migrations through the normal QM deploy
path before starting workers. Memory stores implement the same contract for tests;
they are not a production durability substitute. Swarms are unavailable with
mixed memory/Postgres stores so execution leases and writes share one authority.
Agent API
These routes appear in /v1/apis and work from every harness that can use QM's
self-API. Use the existing x-agent-capability: $AGENT_API_TOKEN header. Swarm
operations require a token bound to the current session and a real durable run.
An agent using a token minted during a human turn is still an agent, not a
human author. Request bodies cannot set identity, authorization, or provenance.
Spawn one or a pool
POST /v1/swarm:
{
"action": "spawn",
"requestId": "initial-pool",
"count": 3,
"contexts": [
{ "group": "implementation", "role": "planner" },
{ "group": "implementation", "role": "worker" },
{ "group": "implementation", "role": "reviewer" }
],
"text": "Coordinate a small implementation. Report findings through swarm messages."
}Any spawn — initial or later, by the root or by a worker — may choose the
model for the workers it creates with optional model, effort (a thinking
level) and fastMode, e.g.{"action":"spawn","requestId":"r1","count":8,"model":"gpt-6.1-sol","effort":"medium","fastMode":false,"text":"..."}.
The choice is stored on each new member (visible in inspect), applies to every
turn that worker takes, and is inherited by workers it spawns unless they choose
again. Workers do not copy the root's runtime: fields nobody chose resolve like any
subagent turn, from the org subagent default and then the owner's default. An
unavailable model fails closed at dispatch.
Optional settings overrides the defaults below at initial spawn. The backend
can configure defaults with SWARM_DEFAULTS, a JSON object such as{"agents":16,"turnMs":900000}. Values must be positive safe integers within the
safety bounds; unknown settings are rejected. Resolved settings and the chosen backend are stored once. Identical
initial requests can be retried. Later spawns by the root session may passsettings again to change any setting except turnMs (for example, to raiseagents); workers inherit the settings and cannot reconfigure the swarm. The
backend cannot change after creation.
Omit count to spawn one; contexts also determines the count when provided. A
single context supplies the same initial JSON to every worker. Context may be any
JSON value, not just a role object. contexts must match the requested count.
The root is enrolled on the first spawn; workers can recursively call the same API.
The response is 202 with reserved members. A durable outbox provisions their
computers, creates sessions, adds the original participants, and queues initial
turns. Discovery shows reserved, ready, or failed and any provisioning error.
Restarts resume the same reservations rather than allocating another computer.
Provisioning gets three attempts. Terminal failures retire owned computers and
remove empty sessions; unresolved cleanup remains recorded and retried. Failed
reservations still consume the finite lifetime agent budget, preventing unlimited
provider creation/retry loops.
Each worker gets a dedicated blank computer owned by the parent's
authorization scope. No parent files are copied. Data access, keychain policy,
memory resolution, and approval grants continue through QM's existing scope and
identity rules, with unattended rather than human-attended permissions. Session
grants belong only to the session that received them, not its workers. A computer's backing storage key
is not an authorization principal. Existing provider persistence behavior is
unchanged; swarms add no filesystem snapshots, immutable copies, or restores.
Every swarm gets one shared board computer in the swarm scope, created with the
first worker. inspect returns it as board.sandboxId, and every worker carries
it as forumSandboxId; the root session and all workers reach it withexecute's sandbox_id. Board creation is part of each worker's provisioning and
fails that worker like any other provisioning failure. The board outlives worker
cleanup so its files remain available to the root. To use an existing computer instead,
supply forumSandboxId naming an authorized sandbox in the same scope; no board is
then created. Every worker still gets its own blank private computer.
The forum ID appears in peer metadata and the worker prompt; select it explicitly
with execute's sandbox_id for commands that should use the shared computer.
This is not a new filesystem synchronization feature. Workers using a forum share
that disk and must coordinate concurrent writes themselves. Failed worker creation
never retires the existing forum.
Identity, peers, and character
GET /v1/swarm returns id, self, peers, backend, settings, and expiresAt when a work window is set. Peer
records keep trusted IDs, ancestry, session links, storage selection, and lifecycle
state separate from arbitrary context data. Only this swarm is listed, not every
session with the same owner, and never all private sessions in the organization.
POST /v1/swarm with {"action":"context","context":{"role":"reviewer"}}
replaces the caller's own context. There is no update-other-peer operation. The
character is supplied to the harness as untrusted metadata on subsequent turns.
Setting fields such as id, scopeId, or depth in context changes no authority.
Messages and audience selection
POST /v1/swarm:
{
"action": "send",
"requestId": "review-request",
"audience": ["member-id"],
"text": "Please check the proposed change and reply with your findings."
}The response includes a durable message id and monotonic per-swarm seq.audience is either a list of recipient IDs or the string "all". IDs are
validated against eligible, ready peers. Duplicates collapse into one recipient;
reordering IDs does not change a retry. "all" includes every eligible member,
including the sender. An empty list records a shared message without waking anyone.
Messages record intended recipients, author kind, authenticated actor, sender
agent identity (senderId), ordinary QM session (senderSessionId), and optionalreplyTo. Other authorized swarm members can read
them even if they were not in the intended audience. Root and worker participant
rosters must remain the original roster; changes fail closed. Current scope and
identity authorization is rechecked before access and execution.
Notification state is persisted as pending, queued with a durable runId, orfailed. A queued notification means the run was accepted, not that the agent has
finished. Idle recipients wake through the normal durable worker queue. Active
recipients receive queued unattended turns, not forged human steer signals.
Notification enqueue uses a stable deduplication key so a crash between enqueue
and acknowledgment does not enqueue another run. Agent-only turns have no Slack
delivery target, and the delivery layer also refuses swarm result delivery.
Set notify:false for a durable message without a wakeup. Sending to oneself does
not wake oneself. There are no automatic reply-to-reply notifications: agents
explicitly decide whether a response warrants another turn. When notifications
is set, every generated turn consumes that budget.
Worker goals
A worker turn normally ends when the worker replies, and the worker then waits
for its next message. To keep a worker working, its parent can attach a goal tospawn or send:
{
"action": "send",
"requestId": "lane-goal",
"audience": ["member-id"],
"text": "Work through every archive for your lane.",
"goal": { "objective": "Exhaust the lane's archives", "floor": { "minMs": 3600000 }, "token_cap": 2000000 }
}The goal is installed as the worker session's goal when that turn starts, and the
turn runs under the normal goal loop: the worker is prompted to continue until the
goal's governor approves completion or pauses it, or an optional token_cap is
spent. A turn that carries a goal runs without the swarm turnMs deadline; other
turns keep it. floor and token_cap take the same keys as the goal tool, and
neither is required. Only a parent can assign goals, and
only to its own workers; a goal needs notify. The worker treats its parent as
the user for the goal and messages it when it needs a decision. Each goal message
installs a fresh goal once (a retried delivery does not reinstall it), so sending
a goal again replaces a paused or finished one and starts a new token budget.
Ask, read, and tail
GET /v1/swarm?read=1&after=0&waitMs=0 returns at most 32 messages. Advance after
to the last seq to tail without rereading prior pages. waitMs may be at most
the stored settings.waitMs (10,000 by default); a timeout returns an emptymessages array, not a fabricated response.
To ask, send a message, keep its id, then read with replyTo=&waitMs=10000.
The responding agent sends with replyTo set to that ID. Replies can usenotify:false when the requester is already waiting. Waiting does not hold a
swarm transaction, provision lock, or other agent's run lease. It does not promise
an answer: an active recipient may finish its current turn first, and mutually
waiting agents time out rather than holding each other indefinitely. Prefer
asynchronous work over nested waits when the worker pool is saturated.
requestId is required for spawn and send, at most 128 bytes. Retry the same
payload and requestId to get the original reservation/message. Reusing a key
with different content fails. Context updates are simple replacements.
Human API
Human callers use GET and POST /v1/sessions/:id/swarm through the existing
source-authenticated API with a signed portal identity. The caller must already
be allowed to view that session and belong to the swarm scope. Request actor
and provenance selector fields are rejected rather than ignored. Arbitrary keys
inside context remain permitted. Bodies and read parameters match the
agent API. Initializing a root from this endpoint additionally requires a runId
belonging to that session and actor. Human-origin actions are separately recorded;
their asynchronous notifications still run unattended.
Agent operations require credentials bound to the exact session, run attempt, and
unexpired execution lease. Swarm writes recheck that lease inside their database
transaction; credentials from replaced attempts cannot authorize new work. Completed, failed, queued, and expired runs cannot authorize agent swarm
operations. Authenticated human initialization may reference a historical run.
Worker sessions never inherit the root session's command or security-screen
approval grants; a worker must obtain its own approval. Auto screening receives host-verified swarm delegation provenance, so routine delegated tasks and in-swarm reporting are distinguished from external instructions. Delegation does not authorize credential disclosure, permission changes, or overriding higher-priority instructions; those remain screened. Frozen swarm roster checks
apply to swarm notifications, not ordinary human follow-ups in the root session.
Each outbox sweep selects at most 16 pending swarms and reconciles resources for at
most four concurrently, advancing through pending pages so failed cleanup cannot
monopolize the batch. Notification delivery uses a separate four-slot pool and
per-swarm lock, so ready recipients can receive work even when all resource slots
are occupied. Each phase has a 30-second reconciliation deadline and each provisioning
attempt has a 10-second deadline. A timed-out worker fails without receiving work;
its private-resource cleanup remains durable and retries on later sweeps, including
after a late provider completion. The explicitly shared forum is never retired by
worker cleanup. Unsettled operations retain their execution slot and swarm lock
until their side effects finish; timeout never permits overlapping cleanup or
unbounded provider calls. A later process can retire stale provisioning after the
original process releases its locks or exits. Providers that never settle leave
cleanup pending rather than being treated as successfully cleaned up; four stuck
resource operations exhaust only this instance's resource capacity until a slot is
released. Pending selection remains single-flight until its underlying database
query settles, even when a sweep reports a selection timeout.
Worker controls
POST with {action:"control",memberId,state} sets a worker to paused,active, or stopped. Humans in the swarm may control any worker; an agent may
control only itself or its descendants. The root session cannot be controlled.
Controls apply to descendants too. Pausing holds new notifications as pending
until the worker is resumed; runs already queued continue. Stopping is terminal:
pending notifications fail, unstarted queued runs are withdrawn, running turns are
aborted, and later turns in that worker session are refused. inspect reports each
peer's control. The web UI shows a Swarm strip on swarm sessions: a collapsed summary of
active, starting, paused, stopped, and failed workers; when expanded, workers
grouped under their parent (20 per group, with "show more"); per-worker Pause,
Resume, and Stop; and Pause all, Resume all, and Stop all (with a confirm), which
apply this action to the root's direct children so it cascades. The strip
refreshes every few seconds only while workers are active and the tab is visible.
Defaults
| Setting | Default |
|---|---|
agents (root and failed reservations included) |
32 |
depth below root |
4 |
contextBytes / textBytes |
8,192 each |
spawnRequests |
unset (no limit) |
messages including initial work |
unset (no limit) |
notifications including initial work |
unset (no limit) |
lifetimeMs from first spawn |
unset (no limit) |
turnMs per notification |
600,000 (10 minutes) |
waitMs per read |
10,000 |
The root and at least one worker require agents >= 2. All other settings accept
any positive integer; turnMs is limited only by the 2,147,483,647 ms timer ceiling. Read requests can still use waitMs:0.spawnRequests, messages, notifications, and lifetimeMs are off unless a
spawn or SWARM_DEFAULTS sets them. When set, they are enforced as budgets that are
reserved atomically with the members and messages that consume them. Both the
orchestrator and independent worker cancellation honor the stored turn deadline;
existing organization-wide limits can shorten it. Internal reconciliation limits,
the 32-message read page, and bounded run retry counts are not swarm
configuration.
When lifetimeMs is set and the window passes, history remains readable, but new
work, notifications, and character changes are rejected until the root extendslifetimeMs on a later spawn. Swarms without lifetimeMs never expire. Human-initiated
ordinary session turns remain ordinary QM actions.
Workers and their durable results remain visible; computers can be retired
through the existing sandbox inventory. There is no automatic
filesystem export on retirement: publish useful outputs through existing Files
or your version-control workflow first.
Verification
Run the focused suites with node --experimental-test-module-mocks --test test/swarm*.test.ts.
Run npm run test:pg against a disposable Postgres database. The swarm suite uses
a separate schema so migration-reset tests cannot invalidate its state.
Set SWARM_TEST_DATABASE_URL when running test/swarm-orchestrator.test.ts to
exercise the HTTP spawn/reply flow across an application restart with real durable
state. These tests use deterministic model and sandbox doubles; live provider and
model acceptance remains a separate deployment check. The agent-board UI is
intentionally deferred.
Operators can set SWARMS_ENABLED=false to disable the swarm service, API,
agent discovery, and background reconciliation. Existing swarm records and
computers are retained. Already queued swarm turns fail without model execution
and are not retried. The default is true. Restart core and workers after changing
this deployment setting.
Upgrading Runtime (`docs/upgrading-runtime.md`)
Runtime upgrade compatibility
Pin the runtime source or image digest and deployment CLI together. Run migrations
before serving traffic, then qualify that exact candidate against an isolated copy
of the existing database. Keep the previous images and deployment configuration
available until the new revision has passed operational checks.
Configuration changes
qm sandbox publishand thesandbox.imageruntime pin are retired.qm sandbox buildremains available for validating local layer builds;sandbox.baseImageidentifies their build input. Supply sandbox tools through
deployment layers and configure the selected backend through its supported
settings.SANDBOX_SECONDARY_BACKENDis retired.SANDBOX_BACKENDselects the primary
backend; additional configured backends become available through their
credentials and backend settings. Remove the old secondary setting.- The
sandbox_routingtable,SANDBOX_RESOURCES_ENABLED, and the sandbox
migration endpoints and agent action are retired. Sandbox resources are always
on; scoped execution uses the selected sandbox resource. Disposable computers use
that resource's provider, or the installation provider when no resource is selected. On upgrade,
existing routes andSANDBOX_SCOPE_BACKENDSmappings are imported once as
sandbox records so existing computers are preserved; afterwardsSANDBOX_SCOPE_BACKENDShas no runtime effect. To move a scope to another
provider, create a sandbox there, copy durable work via git or Files, and set
it as the default. Keep credentials and provider settings for all legacy mappings
until the import completes; an unconfigured provider needed by the import stops
startup before any adoption state is written. - Reach-denied Slack notifications are retired. Consult the audit log for denied
requests.
Database upgrades and rollback
Migration identifiers and SQL checksums retain their original meaning. An existing
checksum mismatch stops the upgrade; do not clear the migration ledger or edit its
checksums to bypass this check. A previously completed legacy webhook sweep is
adopted into the checksum ledger without disabling webhooks that an operator has
subsequently re-enabled.
The cron journal imports existing cron_fire_log history into cron_fires and
continues importing writes from older workers during a rolling upgrade. History
retention also removes expired entries from the legacy table, preventing a later
restart from importing them again. The legacy table remains available for older
binaries.
Upgrading from a release that predates the items table copies session history
into items during startup migrations, then drops session_entries, session_tape
and the old search table. Stop every older core and worker first: they cannot run
against the new schema. src/sessions/legacy-history-migrations.ts keeps those released
migrations verbatim; delete it once every supported deployment has appliedsessions/store/0036-items-cutover. Upgrading from a release that still writes session_tape
first copies its step metadata into legacy_tape_steps, then drops the tape. Stop every
older core and worker before starting the new release: a tape write after the copy
starts makes the drop refuse, and that startup fails with the reset to run. The copy
runs on the direct migration connection without a statement timeout and resumes
after an interrupted startup. It walks every session at roughly 30 sessions per
second, so a few thousand sessions take a couple of minutes; above that, runnode src/migrate-main.ts with the release's configuration before starting the
servers so readiness checks do not time out. Instances that start meanwhile wait for the copy on
the migration lock.
Sessions created before QM recorded each session's origin get origin and origin_id
from their thread reference during startup migrations. The backfill commits 5,000
sessions at a time, resumes after an interrupted startup, and fills roughly 10,000
sessions per second; with more than a few hundred thousand such sessions, runnode src/migrate-main.ts with the release's configuration before starting the servers
so readiness checks do not time out.
Rolling back the runtime restores code and configuration, not database contents.
Older binaries do not display cron history written only to the new journal; those
records remain stored in cron_fires. Validate both old and new readers against the
candidate database before promotion, and retain a database recovery point for any
rollback that requires restoring data as well as code.
Qa Fly Published Apps (`docs/qa/fly-published-apps.md`)
Fly published application acceptance
Status: final-image HTTP/runtime and private-network qualification passed. Production SSO/browser qualification remains outstanding; existing deployments are not approved for migration yet. Automatic suspension is verified with variable timing, not a fixed deadline. See the final entries below for the immutable image and measured results.
The Fly adapter must retain QM's application access controls and durable application data while delegating idle suspension and request-driven wake-up to Fly Proxy. Existing deployments remain on their current provider until the following checks pass against the candidate source and a real QM instance.
| Area | Required live evidence |
|---|---|
| Publishing | Publish through QM, open the resulting URL, verify actual application responses |
| Authentication | Signed-out, unrelated-user and unrelated-company denial; authorized-user success; direct-origin bypass denied |
| Idle and wake | Observe automatic suspension with zero active Machines, then wake through the normal QM URL; repeat with concurrent first requests |
| Cold start | Force a stopped state, then verify request-driven boot and data recovery |
| Persistence | Write SQLite and file data, verify after suspend, stop, update, rollback and replacement |
| Updates | Publish v2 and roll back to v1 without losing data; failed entrypoint must preserve the working version |
| Transport | Streaming responses, WebSockets, uploads, cookies, redirects and proxy headers |
| Lifecycle | Always-on enable/disable, explicit stop/restart, deletion, core restart and overlapping core instances |
| Isolation | Company credentials cannot inspect, modify or delete another company's apps; application code cannot reach sibling networks |
| Capacity | Bounded concurrent publishes and wake-ups; retry partial failures without orphaning resources |
Record candidate commit, image digest, timestamps, HTTP assertions, machine states, data checks and cleanup in private receipts. Unit tests and direct provider calls are supporting evidence, not end-to-end acceptance.
The following entries record the implementation and QA chronologically; later entries resolve earlier blockers.
The prototype adds Fly service autostop/suspend, autostart and a zero minimum. The application reaper must not delete provider-managed sleeping apps. AWS-to-Fly company-isolated private connectivity remains unresolved.
Observed live results from the initial prototype:
- Publish v1 succeeded with private Flycast ingress.
- A tunnel on the application's custom network could not reach its Flycast address allocated on the organization network. Using a tunnel on the matching organization network returned HTTP 200. Production connectivity still needs a company-isolated path from AWS.
- Forced suspension was confirmed through the Machines API. A normal request through Flycast resumed it in 0.37 seconds, preserving a file counter.
- Replacement publish v2 succeeded, but the file counter reset from 1 to 0. This is a failing acceptance check; the existing ephemeral filesystem cannot provide durable application data across replacement.
- Automatic idle suspension was observed after about five minutes. A request resumed the app in 0.83 seconds and preserved the counter. This used a private Flycast tunnel, not the complete QM request path.
Set FLY_DEPLOY_DATA_VOLUME_SIZE_GB to a positive integer to opt into persistent /data. The prototype creates one encrypted volume per application, updates the attached Machine in place, and retains the volume on archive. It refuses to silently convert existing ephemeral applications or downgrade a volume-backed app when configuration is missing. Volumes are host-local; this is not replicated storage or high availability. Machine replacement must retain the existing volume, and host-loss recovery needs separate qualification.
The direct live volume fixture passed file and SQLite persistence after an update, failed-entrypoint rollback, explicit version rollback, cold stop/request wake, and archive/recreation. Follow-up tests passed both always-on toggles and recovery after confirming a broken configuration had stopped, then recreating the provider. The fixture uses a private file-backed configuration store; application wiring uses Postgres, which still needs full QM qualification. Unit tests cover accepted-create response loss, missing volume configuration, failed configuration persistence, rollback after provider recreation, and always-on changes.
The first live always-on toggle from suspension failed: updating the Machine configuration left it stopped. Treat configuration acceptance and a running application as separate conditions. The candidate now requests a start when either the update response or subsequent readiness polling reports stopped/suspended. The successful retry verified the toggle, live data, and persisted rollback recovery. Durable mode requires Postgres in application wiring; an in-memory configuration map does not qualify restart recovery.
A possible credential arrangement is one pre-created Fly app per company with an app-scoped token and multiple deployment Machines. The proposed routing uses a distinct raw TCP external port for each deployment. This requires live wake/routing tests and durable port ownership before it is accepted. No company credentials or production provider settings have changed for this prototype.
An app-scoped token passed direct Machines API operations but failed flyctl proxy's organization lookup. Do not distribute an organization-wide token to work around this. Network policies and a separately provisioned private tunnel are being evaluated for company-isolated ingress. Policies do not filter Fly Proxy traffic, so direct-ingress restrictions alone cannot establish isolation. The API also rejects an ingress rule with no allowed ports; do not assume an empty rule means deny-all.
The live network fixture established direct private-IP access to port 8080, then denied that access with an ingress policy allowing only managed SSH on port 22. Flycast continued serving the fixture and its data. A separately provisioned custom-network WireGuard peer and userspace wireproxy tunnel also returned HTTP 200 without a Fly API token at runtime. This was one local tunnel, not an AWS core deployment.
Design review identified two further requirements. Prefer a distinct raw TCP external port per deployment over the HTTP force-instance header: an untrusted app can emit Fly replay instructions when the HTTP handler is enabled. Port assignments need durable collision-free ownership. Also give simultaneously running core tunnels distinct WireGuard peers; sharing one peer causes endpoint roaming between replicas. Exclusive peer assignment and blue-green overlap remain unimplemented and unqualified.
A live two-Machine fixture in the same Fly app passed six raw TCP routing assertions: plain responses, fly-replay headers, and replay JSON each stayed on the assigned deployment port, both with and without a forged fly-force-instance-id request header targeting the sibling Machine. Replay instructions were returned as ordinary bytes; sibling application data was not returned. The initial requests ran before the new service was ready and received empty replies; after a successful plain-response baseline, all six assertions passed. This supports raw TCP port routing, but does not qualify port allocation, concurrent wake-up, QM authentication, or overlapping core tunnels.
The same two Machines were then confirmed suspended and received 16 concurrent requests split evenly between their raw TCP ports. All 16 returned the correct application response, both Machines started, and the durable fixture retained its file and SQLite counters. Requests completed in 0.56–0.67 seconds; total request wall time was 0.675 seconds. This is a two-Machine private-tunnel measurement, not a fleet throughput estimate or a full QM cold-start measurement.
The provider now has an optional shared-app mode requiring a durable configuration store, durable port claims, and persistent volumes. Machine metadata selects the owning deployment; each deployment keeps its volume and port across archive and restore. Tests cover sibling preservation through update, always-on, archive and restore, plus an occupied initial port and provider recreation. Set FLY_DEPLOY_SHARED_APP_NAME to select this mode; wiring supplies Postgres-backed port and configuration maps. The shared app and private ingress must already exist. Private transport integration remains unfinished, and this mode has not been exercised through QM.
Independent review found no additional sibling resource deletion or port collision defect, but rejected a database lease alone as fencing for WireGuard peer reuse: a paused core can leave its tunnel child alive after the database releases the lock. Peer reuse must wait for authoritative previous-task termination or equivalent fencing. Acceptance must include a paused core plus server-side database-session termination. Shared provisioning must also establish and verify the separate ingress network and direct-Machine ingress restrictions; the provider's private-IP check alone does not establish this boundary.
The shared provider itself passed a live two-deployment fixture: parallel publish assigned distinct ports, a file/SQLite write in one deployment left its sibling unchanged, archiving that deployment left the sibling serving, and provider recreation restored the original port and persisted data. This fixture uses file-backed maps and the existing local tunnel; Postgres-backed QM and AWS task lifecycle remain unqualified.
The peer-claim prototype now uses sticky ECS task ARN ownership, exact STOPPED verification, and atomic compare-and-swap on reclamation. Unit tests cover concurrent tasks, stale observations, missing records and lookup errors. Independent review accepted the cross-task fencing premise for a tunnel contained in a Fargate task, but requires one manager per task and trusted task metadata. Integration must persist STOPPED evidence promptly or replace stranded peers because ECS task history is temporary. This claim helper is not yet connected to a running tunnel or fleet rollout scripts.
Peer claims can now retain confirmed termination evidence and reuse it after ECS history disappears; tests ensure that evidence is cleared on reassignment and cannot mark a replacement owner stopped. The collector still needs runtime scheduling and rollout recovery integration. The local tunnel lifecycle passed two start/serve/stop cycles against the live shared provider, preserving file and SQLite data and rejecting an occupied listener. This does not yet prove task identity, singleton behavior across process failure, or ECS rollout handoff.
QM HTTP deployment proxy and agent fetch now support the local SOCKS tunnel. A live route fixture reached the shared Fly app and preserved data: unsigned requests returned 401, unrelated actors 404, owners 200, and the ordinary proxy 200. Authorization decisions were stubbed, so this proves transport and route enforcement, not production SSO or real scope ACL acceptance. The affected proxy/fetch and peer-claim tests passed (29 tests).
Runtime wiring now constructs a lazy Fargate tunnel manager from FLY_DEPLOY_WIREGUARD_PEERS, requires private connectivity for shared-app mode, and persists SOCKS routing on deployment endpoints. Task-termination collection runs while the tunnel manager is active. The core Dockerfile builds wireproxy v1.1.2 in a separate stage; that image has not yet been built or deployed. Hostname resolution through the private tunnel passed the live route fixture. ECS startup, task failure and deployment overlap still need live qualification.
Runtime review fixes now have tests: concurrent requests restart a confirmed-dead tunnel once; termination collection starts with core runtime even without application traffic; and peer claim IDs derive from the actual X25519 public identity, preventing duplicate private-key identities or label changes from bypassing ownership. The initial local core-image build is in progress; the final source must be rebuilt before image qualification.
The local ARM64 core image built successfully after the review fixes (sha256:3b5e80d716c48902f06d580bf73fdef0116d29b84e8dd547cb215be1fb208b4f). Its wireproxy executable runs. The pinned v1.1.2 source reports its upstream hard-coded 1.0.8-dev banner when built without release linker flags; source/module provenance, rather than that banner, identifies this build. This image is not yet an ECS candidate or a production release.
A standalone ARM64 Fargate task passed private transport QA with source ce4b53eaad246ab2733fdf98ef160c24ceda8ff6 and ECR digest sha256:15fdf763ed76e0e6376fec44087a20da405d027c934dcaf438451b85e6c94144. It verified its exact running ECS task record, started wireproxy inside Fargate, resolved the private Flycast hostname and read the expected file/SQLite data. It exited with code 0, reached STOPPED, and its task definition was deregistered. This used the tunnel helper directly, not the full QM runtime or shared Postgres claims; overlapping core-task ownership remains to qualify.
The live Fargate overlap fixture passed against disposable Postgres. An observer confirmed core A was SIGSTOP-paused while its tunnel remained in the task; B terminated A's database sessions, acquired a different peer and served requests while A remained RUNNING. After ECS confirmed A STOPPED, C reused A's peer and served requests while B continued on its original peer. The first attempted pause ran Node as PID 1 and did not pause; it was discarded and repeated with ECS init enabled and an independent process-state observer. Full QM login/ACL and published-app acceptance still remain.
The deployment contract now requires FLY_DEPLOY_WIREGUARD_PEERS when shared Fly publishing is selected, in both CLI secret discovery and runtime validation. AWS core task roles gain cluster-scoped ecs:DescribeTasks for peer reclamation. The Fly token check uses the shared application's Machines list, avoiding an organization-list permission requirement for app-scoped credentials. All 155 affected config, secret, Terraform and Fly CLI tests passed; typecheck and lint passed. These source changes have not been applied to company infrastructure or included in the earlier candidate image.
A full core runtime in Fargate passed HTTP API acceptance using the immutable candidate above and a fresh disposable Postgres sidecar. The test invoked real src/index.ts, published through /v1/deployments, and exercised persisted ACLs: signed-out access returned 401, an unrelated user received 403, and that user could not archive the app. Owner access, file/SQLite writes, a 1 MiB upload, two separate SSE chunks, redirects and cookies passed. Publishing v2 and archive/restore preserved both counters. The successful run took 54.6 seconds from the QA process start, excluding ECS provisioning/image pull. The first run incorrectly expected unrelated-user denial to be 404; its 403 response was valid and the assertion was corrected before the successful rerun. Both runs archived their test apps and their exact orphan volumes were removed. This test used signed test identities, not production SSO; browser login, cross-company isolation, WebSockets and idle/wake through QM remain acceptance work.
Independent comparison with deployed source 208086ab found WebSockets unsupported on the existing published-app path: neither core nor portal has an upgrade handler, and AWS app subdomains still enter core's authentication/proxy path. The Fly candidate does not remove an existing WebSocket path. Record this as a product limitation rather than a Fly regression; raw provider WebSocket support alone would not establish QM support. The QA app-scoped token also passed own-app Machine listing (200) and was denied a separate app's Machine listing (403); this is a control-plane credential check, not proof of cross-company network isolation.
The full runtime idle test observed the exact deployment Machine suspended before eight concurrent authenticated QM reads; all succeeded with intact file/SQLite counters in 763 ms. With no further application requests, automatic suspension was observed after 324.3 seconds, and the next authenticated QM request resumed it in 622 ms with both counters intact. This used the actual Fargate core proxy and Postgres-backed provider wiring. A separate local dev-instance browser check on branch source loaded the signed-in web Apps and admin Apps surfaces; it used a disposable local database and development identity, not production SSO or the Fly backend. Local Docker disk exhaustion was worked around with a QA-only tmpfs database, and that dev instance was stopped after inspection.
The published-code network probe passed with positive controls: the trusted ingress served the target app and the attacking Machine reached its own listener, while that Machine could not connect to its sibling's private address, the private ingress Flycast address, or the original organization-network Flycast address. A second QA company then used its own app network, separate ingress network, private Flycast address, app-scoped token and WireGuard peer. Both trusted ingresses served their own canary; A's ingress could not reach B's Flycast address and vice versa. Code executing in A could reach its own listener but neither B's private Machine nor B's ingress. Both publisher tokens received 403 for the other app's Machines API. The canaries contained no company data or application credentials.
Final review fixes preserve the latest ephemeral publication when always-on changes, retain legacy idle cleanup for existing standalone Fly apps, and persist changed always-on settings only after successful version application. Regression tests cover failed redeploy and rollback in both setting directions. Shared publishing now verifies dedicated nondefault ingress networks and all-Machine TCP22-only policies; narrowed selectors are rejected. Fleet provisioning additionally binds the exact company ingress network, rejects existing published Machines pending explicit migration, and validates that trusted ingress contains only a recoverable initializer.
After removing the QA app's obsolete default-network Flycast address, the trusted ingress still served version 2 with file and SQLite counters intact. Published-code probes could reach their own loopback listener but could not reach a sibling Machine or the trusted ingress address. This verifies address cleanup independently of final-image acceptance. Production SSO/browser qualification and the rebuilt final candidate remain outstanding.
The rebuilt image from source 6d51832edfa442019ee03005c2f0c8d0c3b73c35 (sha256:e7a431a6232b533fadedd8a43fb6b374cfdeef0835b178d78a9ed4b3cc68dce4) passed publishing, persisted ACL checks, upload/streaming and eight concurrent requests after forced suspension (871 ms). It failed the automatic-suspension assertion: the Machine remained running for the entire 360-second observation window. The task exited 1; its test Machine, exact disposable volume and task definition were cleaned up. Preserve this failed acceptance result; the earlier successful idle measurement does not qualify the rebuilt candidate. Investigate idle timing and active connections before activation.
A diagnostic run of the same immutable image extended observation without changing the production runtime. The disposable app added passive connection-count logging. Automatic suspension occurred after 536,868 ms (8m57s), and the subsequent request woke it in 987 ms with data intact. Update and archive/restore persistence passed afterward. ECS independently records both containers exiting 0 and the exact candidate digest. Twelve captured application-connection samples were zero; they cover part of the idle interval rather than continuous observation. The Machine, its exact volume, and the task definition were cleaned up. Independent review checked the success and earlier failure receipts. This establishes eventual automatic suspension and request-driven wake, with variable timing; it does not establish an upper bound or erase the earlier six-minute failure. A separate single-Machine comparison in a separate prepared company app passed both trusted peers and observed automatic suspension after 376,319 ms (6m16s). The comparison Machine was deleted, and live inventory confirmed the app empty afterward. This simpler fixture supports timing variability; it does not replace the full-runtime checks or establish a deadline.
Qa Resource Search Demo (`docs/qa/resource-search-demo.md`)
Cmd+K resource search demo
Build two self-contained HTML demos without starting the application:
npm ci
npm --prefix plugins/web-ui ci
node docs/qa/resource-search-demo.mjs
open /tmp/qm-resource-search-demo/resource-search-after.htmlThe files include all scripts and styles and work directly from disk. The Before and After links switch between the original chat-only palette and the current palette. Search for release, dashboard, or digest; use arrow keys and Enter, or click a result. Escape closes the palette; the Open search button reopens it.
The demo bundles the real search component, shared result formatting, icons, and palette CSS. Synthetic responses provide a release-review skill, weekly release digest cron, release dashboard app, release planning project, and a release-readiness conversation. API calls, session access, relative recency, and navigation are mocked; selecting a resource displays its destination instead of opening the application. Asking QM displays a local status message. No backend or network access is involved.
The default before source is commit e0966ba8. Optional arguments choose the output directory and before revision:
node docs/qa/resource-search-demo.mjs /tmp/search-review e0966ba8For the PR, link this document and attach the generated HTML pair if the review host allows HTML attachments. The generated bundles stay outside Git. DOM checks verified four resource results plus a conversation, before/after differences, and resource navigation. Browser verification confirmed the rendered demo and Enter navigation to /skills/demo-skill.
--- METRICS ---
- Files Extracted: 58
- Estimated Token Budget: ~123215 tokens
- Canonical Reference: https://codewiki.google/github.com/yc-software/qm