Operator guide
This guide is for whoever runs the Me server: today, a Windows PC with the local install (ADR 0023). People who only use Me need Getting started.
- install.ps1
- What runs where
- The Cloudflare tunnel (public webhooks)
- The Microsoft app
- Backups
- Configuration keys
- Environment variables
install.ps1
Section titled “install.ps1”pwsh deploy/local/install.ps1 [-Root <folder>] [-Port <n>] [-Services postgres,temporal] [-TunnelHostname <host>] [-NoStart]Run it from your own terminal, in the repo root. It is idempotent: re-running it after git pull publishes the
new build and keeps the secrets, data, tokens, port and tunnel.
| Parameter | Default | What it does |
|---|---|---|
-Root |
%LOCALAPPDATA%\Me |
Where to install. Use a folder outside %LOCALAPPDATA% when you run the script inside a packaged app, such as a Claude Code session in the desktop app, where Windows redirects that folder. |
-Port |
5041 |
The server’s loopback port. Windows’ Connected Devices Platform service holds 5040. A re-run keeps an existing install’s port unless you pass -Port. |
-Services |
postgres, temporal |
Which containers to start. postgres alone runs without workflows: they are refused, and everything else works. |
-TunnelHostname |
(none) | A public hostname, such as me.example.com, in a zone of your Cloudflare account, for webhooks. A re-run keeps it. |
-NoStart |
off | Stop after writing the settings (step 3 below): no containers, no scheduled tasks, no tunnel. |
What it does, in order:
- Generates secrets once: the database password and the kernel’s ES256 call-token signing key, in
config\secrets.json. Theconfigfolder is restricted to you and SYSTEM, by SID. - Publishes the server, each production plugin and the
meCLI into-Root. The CLI is JIT-compiled, so the machine needs no Visual Studio C++ tools. - Writes the server’s settings.
server\appsettings.Production.jsonholds the connection string, the plugin list, the kernel URL and the signing key. With-TunnelHostnameit also holdsMe:WebhookBaseUrl, even with-NoStart. The file is restricted the same way. With-NoStart, the script stops here. - Checks the images and starts the containers. It confirms each image exists, pulls it if needed, and starts
Postgres and Temporal with
podman composefromdeploy/local/compose.yaml. - Registers the scheduled task “Me server” (at logon, restarts up to 5 times, one minute apart) and starts it.
Neither the task nor the settings set
ASPNETCORE_ENVIRONMENT, so the server runs as Production:Me:CallTokenPrivateKeyis required andMe:DevAuthis refused. - Sets up the tunnel, with
-TunnelHostname.
Then, once, create the first tenant and log in (see Getting started):
& "<Root>\server\Me.Server.exe" setup --tenant "<name>" --owner "<you>" [--days 90] [--another]setup refuses to run once a tenant exists, unless you pass --another, which adds a separate tenant.
Plugins installed. The production plugins only; simulators and samples are left out. com.me.claude isn’t in the
list yet. The full list is the $plugins array in install.ps1.
Images. ME_POSTGRES_IMAGE overrides the pinned Postgres image, for example to use one already pulled. If a pull
fails with proxyconnect tcp, a proxy leaked into the Podman machine. The script prints the fix:
podman machine ssh 'env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy podman pull <image>'.
What runs where
Section titled “What runs where”| Piece | Where | Notes |
|---|---|---|
| Server (kernel + API) | <Root>\server\Me.Server.exe, scheduled task Me server |
127.0.0.1:<Port> |
| Plugins | <Root>\plugins\<Project>\ |
Started and supervised by the server, each on its own loopback port, with database credentials for its own schema only |
| CLI | <Root>\cli\me.exe |
Put it on PATH |
| Postgres 17 | Podman, compose project me-local |
127.0.0.1:55432, volume me-local_me-local-postgres |
| Temporal | Podman, compose project me-local |
127.0.0.1:7243, UI at http://127.0.0.1:8243, volume me-local_me-local-temporal |
| Tunnel (optional) | cloudflared, scheduled task Me tunnel |
Exposes /webhooks/* only |
Everything binds to loopback. The only way in from outside is the tunnel, and it carries only webhooks.
Stop-ScheduledTask 'Me server'; Start-ScheduledTask 'Me server' # restart; allow ~40 spodman ps --filter name=me-local # the containersA graceful stop takes the plugin processes with it. Stop-Process -Force on the server leaves them running.
The Cloudflare tunnel (public webhooks)
Section titled “The Cloudflare tunnel (public webhooks)”Providers that push events need a public address: Telnyx (SMS, receipts, calls), Cloudflare mail, Graph mail, and
Microsoft Graph change notifications for Teams and Microsoft 365 mail (without the tunnel, those two fall back to
polling every 2 minutes).
-TunnelHostname sets up a named Cloudflare Tunnel, which keeps the same address across restarts:
winget install Cloudflare.cloudflaredcloudflared tunnel login # once: opens the browser; pick the zone of your hostnamepwsh deploy/local/install.ps1 -TunnelHostname me.example.comThe script then:
- Creates the tunnel
me-local(or reuses it). - Routes DNS for the hostname to it. It never overwrites an existing record: if one exists, it warns, and the
record must be a CNAME to
<tunnel id>.cfargotunnel.com. - Writes
config\cloudflared.yml. Its ingress passes only paths shaped like/webhooks/conn_<26 characters>[/…], with no dot segments, so/webhooks/../v1/…can’t reach the API. Everything else answers 404, and the API, the plugin API and OAuth stay on loopback. - Registers the scheduled task “Me tunnel” and starts it.
The public address, Me:WebhookBaseUrl, was already written with the settings (step 3 of the install).
me integration add prints each new connection’s full webhook URL from it. me integration list doesn’t show webhook
URLs, so note the URL when you add the connection.
Coming soon:
-TunnelHostnameinstallscloudflaredand runs its login for you (PR #141).
The Microsoft app
Section titled “The Microsoft app”On main, whoever connects Microsoft 365 registers a public-client Entra app (no secret) and passes its client id.
The client id is a setting of each connection, so several people can share one app by passing the same id. See the Microsoft 365 guide.
Coming soon: the Microsoft app becomes operator infrastructure. The operator registers it once (with a
register-microsoft-app.ps1script), and people only sign in, without a client id (PR #140). This section will describe the script once it is merged.
Backups
Section titled “Backups”There is no backup command yet.
- A verified backup exists in code (
KernelBackup). It dumps the database, restores the dump into a scratch database, and checks there that every migration is applied and every tenant’s audit chain is unbroken. Nothing calls it outside the tests. me backup create|verify|restoreis specified (spec 05) but not built.- The manual procedure below skips both checks.
What to keep:
| What | Where | Why |
|---|---|---|
| The database | Postgres volume | Everything: kernel and plugin schemas, the encryption key ring for connection secrets, and stored files (artifacts) |
<Root>\config\secrets.json |
The install | The database password and the call-token signing key |
| Temporal’s data | Volume me-local_me-local-temporal |
In-flight workflow runs and their timers |
A database backup with Postgres’ own tools, from PowerShell (checked on a live install, including a restore into a scratch database):
The Postgres container’s name depends on the compose provider (me-local_postgres_1 with podman-compose,
me-local-postgres-1 with docker-compose), so look it up once, by its compose labels:
$pg = podman ps --filter label=com.docker.compose.project=me-local --filter label=com.docker.compose.service=postgres --format '{{.Names}}'$stamp = Get-Date -Format yyyyMMdd-HHmmpodman exec $pg pg_dump -U me_owner -d me --format=custom -f /tmp/me.dumppodman cp "${pg}:/tmp/me.dump" "D:\Backups\me-$stamp.dump"podman exec $pg rm /tmp/me.dumpCheck that a backup restores, into a scratch database that you then drop:
podman cp "D:\Backups\me-$stamp.dump" "${pg}:/tmp/me.dump"podman exec $pg createdb -U me_owner me_restore_checkpodman exec $pg pg_restore -U me_owner --no-owner --exit-on-error -d me_restore_check /tmp/me.dumppodman exec $pg psql -U me_owner -d me_restore_check -tAc "select count(*) from kernel.tenants"podman exec $pg dropdb -U me_owner me_restore_checkpodman exec $pg rm /tmp/me.dumpThe dump holds every secret’s ciphertext and the key ring that decrypts it, so store it as carefully as
secrets.json.
Restoring onto a fresh Postgres isn’t fully covered yet (milestone M9):
- The kernel’s own roles (
me_app,me_system) are created only by the first migration, and the restoredpublic.me_schema_versionsmarks it applied, so the server doesn’t recreate them, andpg_restore --exit-on-errorstops at the first grant to them. Create them before restoring:podman exec $pg psql -U me_owner -d postgres -c "CREATE ROLE me_app NOLOGIN; CREATE ROLE me_system NOLOGIN BYPASSRLS; GRANT me_app, me_system TO me_owner;" - Each plugin’s login role is created only when the server provisions that plugin.
--no-ownerleaves every restored table owned byme_owner, so the plugin tables lose their owners.
Configuration keys
Section titled “Configuration keys”The server reads <Root>\server\appsettings.Production.json, which install.ps1 writes. Re-running the script
rewrites the file, so keep your own changes in a note and re-apply them, or pass them as environment variables in the
ASP.NET form (Me__PublicUrl, for example).
| Key | Set by install.ps1 | Meaning |
|---|---|---|
ConnectionStrings:Kernel |
yes | Postgres, as the owner role |
Kestrel:Endpoints:Http:Url |
yes | http://127.0.0.1:<Port> |
Me:KernelUrl |
yes | Where plugins call the kernel back |
Me:ContractsPath |
yes | The contract files (<Root>\server\contracts) |
Me:CallTokenPrivateKey |
yes | ES256 key that signs every kernel→plugin call. Required outside Development |
Me:Plugins |
yes | The plugins to launch, each with Id, FileName, Arguments, WorkingDirectory and Environment (install.ps1 leaves out Arguments). A plugin starts from an empty environment, apart from a few system variables (PATH, TEMP, USERPROFILE…), so a variable reaches it only through its Environment |
Me:DevAuth |
false |
Development headers. Refused outside Development |
Me:PluginInstallApi |
false |
Installing plugins over the API. Off by default |
Me:WebhookBaseUrl |
with -TunnelHostname |
The public origin for /webhooks/<conn> |
Me:PublicUrl |
no | A hosted server’s public origin. The OAuth redirect becomes <PublicUrl>/v1/oauth/callback, and the sign-in page then shows whose system an account would join before connecting it. A server not on loopback refuses sign-ins without it |
Me:Coverage:<plugin id> |
no | Raises what a provider may receive, to Confidential or Restricted, once a BAA covers it (D-45). Without it, nothing above Internal goes to that plugin’s model or channel |
Me:Approvals:Origin, Me:Approvals:RpId, Me:PublicBaseUrl |
no | Passkey approvals: the WebAuthn origin (default https://localhost), the relying-party id (default: the origin’s host) and the base URL of approval links (default: the origin, without a trailing /). Me:PublicBaseUrl is not Me:PublicUrl, which sets the OAuth redirect |
Me:MigrateOnStartup |
no (default true) |
Apply database migrations at start |
Me:RunBackgroundLoops |
no (default true) |
Run the kernel’s dispatch, timer and relay loops in this process |
Environment variables
Section titled “Environment variables”| Variable | Read by | Meaning |
|---|---|---|
ME_CONFIG |
me |
The CLI’s config file (default %APPDATA%\me\config.json). One per login, such as one for you and one for an agent |
ME_TOKEN |
me |
A token to use instead of the stored one |
ME_OUTPUT |
me |
json or human |
ME_NO_BROWSER |
me integration add |
Print sign-in links instead of opening the browser |
ME_CLIENT_SESSION |
me |
An id that groups me calls into one session (for example, one per agent session) |
ME_SCOPE |
me |
Continues an interaction scope, such as the overlay that me attention open returns |
ME_POSTGRES_IMAGE |
install.ps1 |
Another Postgres image |
ME_DB_PASSWORD |
deploy/local/compose.yaml |
Required when you run podman compose by hand: the dbPassword from secrets.json. install.ps1 sets it only while compose runs |
ME_WEBHOOK_URL |
Cloudflare mail worker (wrangler.toml) |
The connection’s webhook URL (Cloudflare mail) |
ME_TEAMS_POLL_SECONDS |
Teams plugin | Poll interval when there is no push (default 120, minimum 30). Set it in the plugin’s Environment |
ME_MAIL_POLL_SECONDS |
M365 mail plugin | Poll interval when there is no push (default 120, minimum 30). Set it in the plugin’s Environment |
ME_CLAUDE_PATH |
Claude model plugin | The claude executable for subscription tokens (default: claude on the server user’s PATH; not a batch file). Set it in the plugin’s Environment |
ME_TEMPORAL_ADDRESS, ME_TEMPORAL_NAMESPACE |
Workflow plugin | Temporal’s address (install: 127.0.0.1:7243) and namespace (default default) |
The server sets ME_PLUGIN_ID, ME_KERNEL_URL, ME_PLUGIN_DB, ME_KERNEL_PUBLIC_KEY and ASPNETCORE_URLS for each
plugin after its Environment, so values for them in Me:Plugins are overwritten.