> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mibyanai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Running Many Gateways at Once

Operate multiple [profiles](/desktop/user-guide/profiles) — each with its own bot tokens,
sessions, and memory — as managed services on a single machine. This page
covers the operational concerns: starting them all together, viewing logs
across profiles, preventing the host from sleeping, and recovering from common
launchd/systemd quirks.

If you only run one Mibyan agent, you don't need this page — see
[Profiles](/desktop/user-guide/profiles) for the basics. And if your instances live on
*different* machines that one desktop app should reach simultaneously, see
[Connecting Desktop to Many Mibyan Instances](/desktop/user-guide/multi-connection-desktop).

## When to use this

You want this setup when you have two or more Mibyan agents that should all
be online at the same time. Common reasons:

* A personal assistant on one Telegram bot and a coding agent on another
* One agent per family member or one per Slack workspace
* Sandbox + production instances of the same configuration
* A research agent + a writing agent + a cron-driven bot — each with isolated
  memory and skills

Every profile already gets its own per-platform supervisor entry: a LaunchAgent
(`ai.mibyan.gateway-<name>.plist`), a systemd user service
(`mibyan-gateway-<name>.service`), a systemd **system** service when installed with
`sudo mibyan gateway install --system` (runs as the invoking user via `User=`), a
Windows Scheduled Task, or an s6/Docker service — and the Desktop app spawns its own
per-profile `mibyan serve` backend. This guide adds the patterns for managing them
collectively.

## Quick start

```bash theme={null}
# Create profiles (once)
mibyan profile create coder
mibyan profile create personal-bot
mibyan profile create research

# Configure each
coder setup
personal-bot setup
research setup

# Install each gateway as a managed service
coder gateway install
personal-bot gateway install
research gateway install

# Start them all
coder gateway start
personal-bot gateway start
research gateway start
```

That's it — three independent agents, each on its own process, restarting
automatically on crash and on user login.

## Alternative: one gateway for all profiles (multiplexing)

The model above runs **one process per profile**. The alternative is a
**single multiplexing gateway**: one gateway process — whichever profile
launched it — becomes the sole inbound process and serves messages for *every*
profile on the box.

The default profile's lifecycle verbs target that process. Named profiles can
stop or restart just their own bots without stopping the host:

* `mibyan -p <name> gateway run` while it is live **attaches** instead of
  starting a second process: it prints the host gateway's PID and served set and
  exits 0. If `<name>` is not served yet, it asks the host gateway to re-scan
  `profiles/` and attaches once the answer includes it; it refuses (non-zero)
  only when the host gateway cannot be made to serve it.
* `mibyan gateway start --all` / `restart --all` mean *the one host
  multiplexer*. They never sweep every gateway process on the box; a profile
  that still runs its own gateway is reported, never killed, with the
  `mibyan gateway migrate --multiplex` one-liner.
* `mibyan gateway run --replace` takes over the process **serving this
  profile**, whichever profile launched it. When the host owner is another
  profile's standalone gateway (an unmigrated per-profile fleet) it never serves
  this profile, so `--replace` starts beside it exactly as a plain `run` does,
  instead of refusing and respawn-storming under the supervisor. An older Mibyan
  wrote a systemd drop-in (`mibyan-gateway.service.d/20-replace.conf`) that
  forced `--replace` onto the unit; `mibyan update` / `mibyan gateway restart`
  now remove that file. `mibyan gateway run --force` starts a separate gateway
  without asking the host process at all (the escape hatch when it is wedged or
  answering wrongly).
* Under a service supervisor the attach exits 75, not 0 — systemd, s6 and
  launchd all restart a 75 after a short delay, so the unit keeps retrying and
  takes over by itself the moment the host process goes away.
* Two units started at once can both see no host process yet; the host lock
  decides which one runs, and the loser exits 75 and attaches on the retry.
  `--replace` does not skip that check (every generated unit carries it), only
  `--force` does.

Multiplexing is **on by default** (`gateway.multiplex_profiles` defaults to
`true`), with one safety rule: an *unset* flag is a request the default gateway
settles at boot, never a verdict. Each start it runs the same preflight as
[`mibyan gateway migrate --multiplex`](#migrating-from-per-profile-gateways) and
multiplexes only when the fold would have been safe — two
or more profiles, no secondary still running its own gateway (live process or
installed service, or under s6 a per-profile slot that is actually *up*), no
duplicate bot credential, and no port-binding platform without a `/p/<profile>/`
ingress. **Unset means on**: when nothing blocks, the gateway multiplexes and
writes `gateway.multiplex_profiles: true` into the default profile's
`config.yaml` (comments preserved) so the file says what the runtime does.
Otherwise it comes up serving the default profile only and says so **loudly** on
a host with other profiles: a boxed warning at gateway start naming the profiles
that are not served, the blocker, and the fix; the same box in the `mibyan
update` summary and `mibyan gateway status`; a banner in the dashboard
(`/api/status` carries `multiplex_standalone_reason`). A single-profile install
is not warned — there is nothing to serve. Nothing is written on a refusal.

An **explicit** `true` bypasses migration preflight, except for a launching
profile that opts out with `gateway.standalone: true`:

* `gateway.multiplex_profiles: true` (what the migration writes) multiplexes
  regardless of the preflight — you, or the migration, made the call.
* `gateway.multiplex_profiles` has **one valid value right now: `true`**, and
  it is written for you. An unset key resolves on and is made explicit in the
  default profile's `config.yaml`. `false` is **retired**: the gateway rewrites
  it to `true` in place and prints a one-time boxed notice at that start and in
  the next `mibyan update` summary — never a silent flip. A per-profile gateway
  is `gateway.standalone: true` in that profile's own config (a temporary shim,
  not a supported topology) or `--force` for the boundary cases below.
* `GATEWAY_MULTIPLEX_PROFILES` in the process environment overrides the
  unset-key decision the same way an explicit `true` does.
* `gateway.standalone: true` in a **named** profile's own `config.yaml`
  (`profiles/<name>/config.yaml`) is a **temporary compatibility shim** (see
  [below](#temporary-gatewaystandalone-true)): the host gateway does not serve
  that profile, and the profile runs its own gateway without `--force`. Its gateway serves only itself, even if
  `multiplex_profiles: true` is also set (see
  [No new per-profile gateways](#no-new-per-profile-gateways)). Set on the
  default profile it is ignored with a warning — the default profile is the
  host gateway. There is no environment variable for this key.

Other processes (`mibyan -p <name> gateway start`, the dashboard, `mibyan gateway
migrate`) never guess how an unset flag was settled: they read the running
default gateway's `served_profiles` record, and fall back to the explicit flag
only when no gateway runs.

### When to prefer multiplexing

* A container/VPS deployment where N supervisor units, N ports, and N PID files
  are a burden.
* Many low-traffic profiles that don't each justify a full process.
* You want a single thing to start, monitor, and restart.

One-process-per-profile is no longer a topology to *choose* implicitly: a named
profile's `gateway install` / `gateway start` refuses without `--force` (see
[No new per-profile gateways](#no-new-per-profile-gateways)). A profile that
still needs its own gateway while a multiplexing gap is open can set the
temporary `gateway.standalone: true` in its own `config.yaml`; where a real
boundary blocks the fold — a fleet split across
UNIX users, or a `mibyan_HOME` outside `<default home>/profiles/` — every
profile keeps `--force` as its path.

### Pinning the flag

With the flag unset, the default gateway decides at each boot (above). To pin
it, set it on the profile whose gateway runs as the host process (usually the
**default profile**) and restart its gateway — `true` forces multiplexing even
where the boot preflight would have held back (`false` is retired and ignored):

```bash theme={null}
mibyan config set gateway.multiplex_profiles true
mibyan gateway restart
```

Equivalently, in the default profile's `~/.mibyan/config.yaml`:

```yaml theme={null}
gateway:
  multiplex_profiles: true
```

(The flag is also accepted as a top-level `multiplex_profiles: true` for
convenience.) When multiplexing, the default gateway enumerates every profile,
brings up each profile's enabled platforms under that profile's own
credentials, and routes each inbound message to the profile it belongs to. Each
turn resolves the routed profile's config, skills, memory, SOUL, **and provider
keys** — credentials are never shared across profiles.

The host automatically serves unparked secondary profiles. Use `gateway start`
on a parked profile to bring it back online.

### Stopping one profile without stopping the host

For a named profile served by the host multiplexer:

```bash theme={null}
mibyan -p coder gateway stop     # park coder; other profiles keep running
mibyan -p coder gateway start    # unpark coder and serve it again
mibyan -p coder gateway restart  # reconnect coder with its current configuration
```

`stop` writes `gateway.parked` in the profile home before asking the host to stop
that profile's adapters and exclude its cron jobs from subsequent ticks. The
marker persists across host restarts. Its contents are ignored; an empty file
is sufficient. Provisioning can pre-create
`<profiles-root>/coder/gateway.parked` so an installed profile stays offline.
Parking does not delete the profile, its sessions, or its scheduled jobs.

`start` removes the marker, then asks a running host to serve the profile.
Without a running host it removes the marker and follows the normal start
path; start the host from the default profile if prompted. `restart` unserves
and serves the profile without writing a parked marker, re-reading its config.
On a **parked** profile with no live per-profile gateway, `restart` behaves as
`start`: it removes the marker and hot-serves the profile (a gateway started
with `--force` beside the marker keeps its own restart instead).
These operations do not terminate work already dispatched by a cron tick.

The host also rescans every 30 seconds: adding the marker by hand unserves the
profile; removing it by hand makes it eligible again. If the control socket
does not confirm the request, the CLI says so and the next rescan applies the
marker state. Adapter teardown or connection can take additional time.
`mibyan -p coder gateway status` reports
`parked (mibyan -p coder gateway start)` while the marker exists.

The launch profile cannot be unserved. The default profile's marker is ignored
with a warning; its lifecycle verbs and the `--all` variants retain their
whole-host behavior. A separately running `--force` gateway retains its own
process lifecycle.

The dashboard and Desktop **Stop** / **Start** buttons for a served profile do the
same thing: Stop parks it (`/api/gateway/stop?profile=coder` spawns
`mibyan -p coder gateway stop`), Start unparks it while a host gateway is live,
and `/api/status` lists `parked_profiles`. Start on a named profile that is
*not* parked still answers `409` — it would need a gateway of its own.

#### Parked vs `gateway.standalone: true`

The two never apply to the same profile at the same time, and neither one
implies the other:

| Profile | Host serves it | `-p X gateway stop` | `-p X gateway start` |
| - | - | - | - |
| Served (default case) | yes | parks it (marker + `unserve-profile`) | already served |
| Parked (`gateway.parked` exists) | no, until unparked | already parked | unparks (marker removed + `serve-profile`) |
| Standalone (`gateway.standalone: true`) | never | stops **its own** gateway process; writes no marker | starts its own gateway |

`gateway.standalone` wins: the host never writes `gateway.parked` for a
standalone profile and never serves it, parked or not, so `stop` and `start` on
that profile keep their per-process meaning. Parking is the multiplex-native way
to take one profile offline — it is what closes the "per-profile stop/restart"
gap the [temporary shim](#temporary-gatewaystandalone-true) was kept open for.

### No new per-profile gateways

By default, one host gateway serves every profile, and a named profile does
not get a gateway of its own. Without the opt-out below,
`mibyan -p coder gateway install` (or `start`, `run`, and
the service step of `mibyan -p coder setup`) refuses with exit 78 whether or not
a host gateway is running right now:

```
❌ Profile 'coder' does not get a gateway of its own.
  Exactly one gateway per host is the inbound process for every
  profile. Starting a separate gateway for this profile would
  double-bind its platforms (two pollers on one bot token, port
  conflicts).

  Install or start the host gateway from the default profile; it serves this one too:

    mibyan gateway install

  Or fold an existing per-profile fleet onto one host gateway:

    mibyan gateway migrate --multiplex

  A separate per-profile gateway (for a fleet split across UNIX users or a
  mibyan_HOME outside profiles/) needs --force:  mibyan -p coder gateway install --force

  Temporary compatibility path while multiplexing gaps are closed: set
  gateway.standalone: true in profiles/coder/config.yaml,
  then wait for the host gateway to rescan (<=30s) or send its rescan-profiles control verb.
  (gateway.standalone is a temporary compatibility shim while multiplexing gaps are fixed;
  it will be removed once they are — plan to fold this profile with `mibyan gateway migrate --multiplex`.)
```

When the host gateway is already running and serves the profile, the first
line reads `The host gateway already serves profile 'coder'.` with the owner's
PID and served set, and the pointer is `mibyan -p default gateway restart`.
The dashboard's **Start** button for a named profile returns the same refusal.

### Temporary: `gateway.standalone: true`

<Warning>
  **Temporary backwards compatibility, not a topology we keep**

  Multiplex-only is the direction: one gateway per host serves every profile. The
  switch landed before every gap was closed — the WhatsApp bridge and relay on
  secondary profiles, and dashboard scoping are the open ones; per-profile
  stop/start/restart is closed by
  [parking](#stopping-one-profile-without-stopping-the-host) — and fleets that
  relied on per-profile gateways lost them overnight.
  `gateway.standalone: true` exists so those fleets keep working **while those
  gaps are fixed**. It will be removed once they are, with a release-notes notice
  ahead of time; every surface that prints it says so. Do not build new setups on
  it: if you are starting fresh, run the host multiplexer. If a gap blocks you
  today, set the key, and file or upvote the issue for the gap so we can remove
  the shim sooner.
</Warning>

Set `gateway.standalone: true` in the profile's own `config.yaml`:

```yaml theme={null}
# profiles/coder/config.yaml
gateway:
  standalone: true
```

The host gateway then does not serve the profile, and the boot log
records `profile 'coder' is standalone (gateway.standalone: true); not served
by this gateway`. `mibyan -p coder gateway install|start|run` works without
`--force`. If the running host still lists the profile in its served set,
the command refuses until it rescans: wait for the next rescan (at most 30
seconds under normal operation), or send the `rescan-profiles` control verb
to the host gateway. No host restart is required.

Removing the key makes the profile eligible for the host again. While the
profile's own gateway is live, the host skips adding it and logs that it must
be stopped first. Stop that gateway; the host takes the profile on its next
rescan.

A standalone profile's adapters, cron, webhook ingress and Kanban
notifications run only while its own gateway runs, not under the host
multiplexer or `mibyan serve`. Point webhook clients at the standalone
gateway's own listener; the host's `/p/<profile>/` ingress no longer serves
it. That listener resolves its port from the profile's own `.env` or
`config.yaml`, so when both it and the host gateway enable the API server or
webhook ingress, give the profile its own `API_SERVER_PORT` / `WEBHOOK_PORT`;
two gateways left on the defaults both try to bind them. The cron destination picker still lists standalone profiles as
`bot-chat:<name>` targets, but the host cannot deliver to those targets.

`mibyan -p coder gateway status` prints `standalone by config
(gateway.standalone: true)` before the profile's own gateway state, and
`mibyan gateway status` (default) lists it as `standalone by config: coder`
after the served set. `mibyan gateway migrate --multiplex` leaves the profile
alone and prints it as `Standalone by config (gateway.standalone: true), left
alone`. The WhatsApp bridge and relay run in the profile's own gateway, as in
any standalone gateway.

`--force` is not the path for this shim; it remains the escape for the two
boundary cases the refusal names (a fleet split across UNIX users, a
`mibyan_HOME` outside `profiles/`): it installs a real per-profile service, and
that service (its `ExecStart` carries no `--force`) keeps starting normally
afterwards.

### What changes when multiplexing is on

Multiplexing changes how a few things behave. None of these apply to a profile
that opts out with `gateway.standalone: true` or runs a separate `--force`
gateway on a blocked host.

#### 1. Secondary profiles must not start their own gateway

With a multiplexer running, a named profile's `gateway run` attaches to it;
`gateway install` refuses to create another process (exit code 78). The CLI
refuses before touching a service manager, preventing a permanently failed
systemd unit or a launchd respawn loop. Use the per-profile `stop`, `start`, and
`restart` commands above to manage a satellite inside the host. `mibyan gateway
stop` on the default profile still takes every served profile offline.
The dashboard and Desktop app follow the CLI: for a served profile the "Stop" action
parks it and "Start" unparks it (see above); "Start" on an unparked named profile
answers `409` with the same explanation (rendered as an inline notice on the System
page). "Restart" restarts the multiplexer (the process that actually serves the
profile) instead of spawning a `-p coder gateway restart` that
could only fail. Because that restart reconnects every bot on the device, both apps
first ask *"Restart the shared gateway? All bots on this device reconnect: default,
coder, research"* (the list is the running gateway's `served_profiles`) and report
*"Shared gateway restarted (3 bots)"* when it completes. A standalone profile keeps
the plain restart. `/api/status?profile=coder` carries the same list as
`gateway_shared_with` (null for a standalone gateway).
"Served" is read from the running gateway's own record (`served_profiles` in the
default home's `gateway_state.json`), so it stays correct when the multiplexer was
enabled only through `GATEWAY_MULTIPLEX_PROFILES` in the default profile's
environment, or when profiles were added after the gateway started.

The setup flows follow the same rule: `mibyan -p coder setup gateway`, `mibyan -p coder setup`,
`mibyan -p coder gateway setup` and `mibyan -p coder import` configure the profile's bots but
skip the "install the gateway background service" step for a served profile, printing
*"Profile 'coder' is already served by the default multiplexer"* instead of registering a
stray unit or plist that could only sit dead. Add the bot token and the running multiplexer
picks it up.

The multiplexer is the single inbound process; a second profile gateway would
double-bind that profile's platforms. A profile that deliberately wants a
separate process opts out with `gateway.standalone: true` (see
[No new per-profile gateways](#no-new-per-profile-gateways)); pass `--force`
(accepted by `run`, `start`, `install` and `restart`) only where a boundary
blocks the fold. The cross-profile
lifecycle wrapper script earlier on this page is therefore **not** used in
multiplex mode — manage the host or its named profiles directly.

#### 2. HTTP-inbound platforms are reached via a `/p/<profile>/` URL prefix

HTTP-inbound traffic for a secondary profile arrives on the default profile's
**one** listener under a profile prefix, **not** a second port:

```
# default profile
POST http://host:8644/webhooks/<route>
# the "coder" profile, same listener
POST http://host:8644/p/coder/webhooks/<route>
```

An unknown or unconfigured profile in the prefix returns `404`. The shared
listener is the default profile's `api_server` port (or its `webhook` port when
no API server is enabled); it serves three kinds of profile-prefixed paths:

* **`api_server` and `webhook` are mirrored**, never duplicated. `/p/coder/v1/...`
  and `/p/coder/webhooks/<route>` are answered by the default profile's own
  adapter under coder's scope. A secondary must therefore **not** enable
  `api_server` or `webhook` itself (the dashboard refuses with `409`; an
  `API_SERVER_KEY` or `WEBHOOK_ENABLED` in the secondary's `.env` wires the
  credential without starting a listener).
* **Every other inbound-port platform runs in shared-listener mode.** A
  secondary that configures Twilio SMS, LINE, Teams, BlueBubbles, Microsoft
  Graph, WhatsApp Cloud, WeCom callback or Feishu webhook mode gets its **own**
  adapter instance built without a port; the default listener forwards
  `/p/<profile>/<the adapter's usual path>` to it. See
  [Inbound-port platforms under the multiplexer](#inbound-port-platforms-under-the-multiplexer).
* **WhatsApp (bridge) and Relay are shared ingress owned by the default profile.**
  The multiplexer never starts them for a secondary: `WHATSAPP_ENABLED=true` in
  `profiles/work/.env` does nothing on its own. Enable and configure them on the
  default profile (their inbound is routed to profiles via `profile_routes`), or
  disable them in the secondary. The gateway logs one INFO line per skipped
  secondary platform, and if **no** profile runs it a WARNING says the platform
  is not being served; `mibyan gateway status --profile work` shows
  `whatsapp: not served under multiplex (shared ingress owned by default)`.
  The one exception is a profile that opted out with `gateway.standalone:
  true` — it runs its own WhatsApp bridge and relay in its own gateway, as any
  standalone gateway does.

Authentication follows the profile named in the URL. Unprefixed endpoints keep
using the default listener's existing credentials.

* `/p/coder/...` API-server requests must use `API_SERVER_KEY` from
  `~/.mibyan/profiles/coder/.env`; the default listener key is rejected. Under
  the multiplexer that key only authenticates the prefix — it does not turn on a
  second `api_server` listener in the secondary profile, so you do not need to
  pin `platforms.api_server.enabled: false` in the secondary's `config.yaml`.
* A webhook route that targets `coder` must declare `profile: coder` beside
  its existing route-specific `secret` in the default profile's
  `config.yaml`. That secret is then accepted only at
  `/p/coder/webhooks/<route>` and is rejected on every other profile prefix.
* Webhook routes without `profile` remain default-profile routes and are not
  reachable through a named profile prefix. Dynamic subscriptions bind the same
  way: `mibyan webhook subscribe <name> --route-profile coder` writes
  `profile: coder` into the default gateway's `webhook_subscriptions.json` and
  prints the `/p/coder/webhooks/<name>` URL (`mibyan webhook ls` shows the
  binding). Use `--route-profile`, not the global `-p coder`: `-p` would write
  the subscription into coder's own subscriptions file, which the default
  gateway's webhook adapter never reads.
* Delivery follows the same binding. A `profile: coder` route's reply (or
  `deliver_only` message) goes out through **coder's** adapter for the
  `deliver` platform, falls back to **coder's** home channel when
  `deliver_extra.chat_id` is unset, and a `github_comment` delivery runs `gh`
  with `GH_TOKEN` / `GITHUB_TOKEN` from `profiles/coder/.env`. If coder has no
  adapter for that platform the delivery fails (502) rather than posting as
  another profile's bot; a default route likewise never borrows a platform that
  is enabled only on a secondary profile.
* `/p/coder/api/platforms/<platform>/events` callbacks are verified and
  dispatched by coder's adapter; when coder has none the callback is a 503.

Named API requests fail closed when the target profile has no
`API_SERVER_KEY`. Security configuration errors remain fatal: for example, an
`open` own-policy platform without `GATEWAY_ALLOW_ALL_USERS` or its
platform-specific allow-all opt-in still aborts gateway startup rather than
silently dropping the unsafe profile.

#### Inbound-port platforms under the multiplexer

A standalone `mibyan -p coder gateway run` binds coder's Twilio, LINE, Teams,
… webhook servers on their own ports. Under the multiplexer those adapters are
still coder's — same credentials from `profiles/coder/.env`, same
`config.yaml`, replies sent through coder's channel — but they bind **no port**.
The default profile's shared listener forwards `/p/coder/<path>` to them, where
`<path>` is exactly the path the adapter would serve standalone. The request is
verified by **coder's** adapter with **coder's** secret (Twilio auth token, LINE
channel secret, Teams app credentials, BlueBubbles password, …) and runs under
coder's runtime scope; the default profile's own `/path` is untouched, and a
profile that has no adapter for a path gets `404`, never another profile's bot.

| Platform | Secondary profile's callback URL on the shared listener | Verified with the named profile's |
| - | - | - |
| Twilio SMS (`sms`) | `https://<host>/p/<profile>/webhooks/twilio` | `TWILIO_AUTH_TOKEN` signature (`SMS_WEBHOOK_URL` must be this URL) |
| LINE (`line`) | `https://<host>/p/<profile>/line/webhook` (media: `/p/<profile>/line/media/...`) | `LINE_CHANNEL_SECRET` |
| Microsoft Teams (`teams`) | `https://<host>/p/<profile>/api/messages` | Bot Framework token for `TEAMS_CLIENT_ID` |
| BlueBubbles (`bluebubbles`) | `http://<host>/p/<profile>/bluebubbles-webhook` (registered with the server automatically) | `BLUEBUBBLES_PASSWORD` |
| Microsoft Graph (`msgraph_webhook`) | `https://<host>/p/<profile>/msgraph/webhook` | `extra.client_state` |
| WhatsApp Cloud (`whatsapp_cloud`) | `https://<host>/p/<profile>/whatsapp/webhook` | `WHATSAPP_CLOUD_APP_SECRET` / verify token |
| WeCom callback (`wecom_callback`) | `https://<host>/p/<profile>/wecom/callback` | the app's callback token / AES key |
| Feishu webhook mode (`feishu`) | `https://<host>/p/<profile>/feishu/webhook` | `FEISHU_VERIFICATION_TOKEN` / `FEISHU_ENCRYPT_KEY` |

`<host>` is the public hostname (tunnel, reverse proxy) in front of the default
profile's listener; a custom `webhook_path` in the profile's config moves the
path after `/p/<profile>` accordingly. The gateway logs the exact URL at
startup:

```
[sms] profile 'coder' is served on the default profile's shared listener:
http://127.0.0.1:8642/p/coder/webhooks/twilio (point the vendor's callback URL at this path ...)
```

and every status surface repeats it, so you know what to paste into the vendor
console:

```
$ mibyan -p coder gateway status
✓ Gateway is running via the default-profile multiplexer
  Manage it from the default profile: mibyan gateway status

Inbound callback URLs on the shared listener:
  line: http://127.0.0.1:8642/p/coder/line/webhook
  sms: http://127.0.0.1:8642/p/coder/webhooks/twilio
```

`mibyan gateway status` and `mibyan status` on the default profile list the same
URLs per served profile, and the dashboard's Channels page and the Desktop
Messaging page show them as each platform's `ingress_url` when viewing that
profile. The default's own `api_server` and `webhook` are reported the same way
for a served profile — as **connected** with `ingress_url`
`http://127.0.0.1:8642/p/coder/v1` (respectively `.../p/coder/webhooks/<route>`) —
since the profile has no adapter of its own for them; it is the default's listener
answering under the `/p/coder/` prefix. A per-profile
`SMS_WEBHOOK_PORT`, `LINE_PORT`, `TEAMS_PORT`, … in a secondary's `.env` is
ignored under the multiplexer (nothing binds); it applies again the moment that
profile runs its own standalone gateway.

#### 3. Per-credential platforms still need their own token per profile

Polling/connection platforms (Telegram, Discord, Slack, Matrix, Signal, …) work
fine multiplexed, but each profile that enables one must supply its **own** bot
token — the same token cannot be polled by two profiles at once. If two profiles
configure the same `(platform, token)`, the gateway logs an error naming both
profiles and parks the **duplicate** adapter (it shows as `fatal /
duplicate_credential` in runtime status) while the first claimant and every
other profile keep running — the gateway itself does not exit. The default
profile's adapters connect first and claim their credentials, so the parked
adapter is always the secondary's (see
[Token-conflict safety](#token-conflict-safety) — the rule is unchanged, it's
just enforced inside the one process now).

#### 4. Session keys are namespaced by profile

Each profile's sessions live under an `agent:<profile>:…` namespace so two
profiles on the same platform/chat never collide in the shared session store.
The **default** profile keeps the historical `agent:main:…` namespace
byte-for-byte, so existing default-profile sessions are unaffected — no
migration, no orphaned history. Every gateway path that reads a key back —
delegation completions after a restart, shutdown notices, a per-user-thread
`/stop` of a sibling's run, `/undo`, QQ approval buttons — accepts the
`agent:<profile>:…` shape too, so secondary profiles get the same behaviour
as the default one. The one profile name that would collide with the default's
namespace, a profile literally called `main`, is keyed `agent:main~:…` so it
keeps its own sessions and its own `profiles/main/state.db`.

Each profile's rows land in **its own** `state.db`: a named profile's under
`profiles/<name>/state.db`, the default profile's under the launch home — even
when the write happens inside another profile's routed turn or background tick.
The Desktop/TUI backend's own store is likewise pinned to the home it launched
under, and a Bot Chat's side agents (`prompt.background`) persist next to their
parent conversation.

#### 5. One PID/lock and one status surface

There is a single process-level PID and lock (the multiplexer, under the default home). `mibyan status` on the default profile reports the multiplexer and lists the profiles it serves (`Serves: coder, research`). `mibyan -p coder status` and `mibyan -p coder gateway status` report "running via the default-profile multiplexer" instead of "stopped". The dashboard's `/api/status?profile=coder` / Channels page report the multiplexer as coder's running gateway, with coder's own adapters as its platforms. The single `gateway_state.json` lives under the default home: secondary adapters appear there as `<profile>:<platform>` entries beside `served_profiles`; no per-profile gateway status file is written.

`mibyan -p coder cron status` names the single host gateway and the profiles it serves — `Scheduler host: the host gateway (PID 4211) serving profiles default, coder` — then checks coder's own ticker heartbeat and last successful tick. A missing or stale heartbeat produces a warning rather than an unconditional running verdict. `cron list` and `cron create` also warn when a served profile has no fresh heartbeat. `cron status` adds tick-failure details that those lightweight checks do not read.

When no gateway owns the host role, `cron status` tells you to start the **one** host gateway (`mibyan --profile default gateway install` / `gateway run`) and to make sure it serves this profile. Installing a per-profile service is shown only under `LEGACY (pre-multiplex topology, not recommended)`: it would start a second gateway process on the host. `mibyan doctor` follows the same rule — under s6 it reports `Host gateway: the host gateway (PID 4211) serving profiles default, coder` instead of a per-profile slot count, flags any still-supervised per-profile slot as LEGACY, and checks the host systemd unit's linger even when you run doctor from a served profile. The `state.db` holder lines name the shared host process too, so "3 process(es) holding the DB open" says which gateway and which profiles stopping it would affect.

#### What does **not** change

Per-profile `.env` credential isolation is preserved and, if anything,
stricter: a profile's keys are resolved from its own scope and are never unioned
into a shared environment. Subprocesses like MCP servers and Kanban workers only
ever see their own profile's secrets — including credentials injected by an
external secret source (1Password, Bitwarden, …): a stdio MCP server started for
profile B receives B's value for such a name, or nothing if B has none, never the
default profile's. MCP servers are connected **per profile**: two profiles that
both name a server `github` with their own token get two connections and each
sees only its own tools; profiles whose `mcp_servers` entry is identical (same
route *and* credentials, including mTLS `client_cert`/`client_key`) share one
connection, and an owner's `/reload-mcp`
re-registers the sharing profiles' tools without them reloading. `auth: oauth`
servers are never shared across profiles: each profile holds its own token under
its own `mcp-tokens/` and opens its own connection. Startup connects profiles one
after another and, within a profile, at most `mcp.discovery_concurrency` servers at
once (default 4, `0` = unlimited), so a fleet of profiles with many stdio servers
no longer spawns every helper process in the same instant. Trust policy stays per
profile: a `trust: untrusted` profile sharing a `trust: full` profile's
connection is still asked before every write-capable call, and
`supports_parallel_tool_calls` applies only to the profile that set it. Terminal settings
(`terminal.backend`, `terminal.cwd`, `terminal.docker_volumes`,
`terminal.docker_shared_container_key`, SSH targets, …) are likewise resolved
per profile on every routed turn: a profile that omits a terminal key gets the
documented default, never the launch profile's value, and a profile whose
`config.yaml`/`.env` cannot be parsed has terminal execution refused rather than
run under another profile's sandbox policy. The media-delivery credential
guard (the denylist behind `MEDIA:` attachments — `.env`, `auth.json`,
`config.yaml`, `state.db`, session transcripts, OAuth token stores) covers every
profile under `profiles/`, so no profile's turn can attach another profile's
secrets or chat history to a reply. Authorization is per profile too:
`GATEWAY_ALLOW_ALL_USERS`, `GATEWAY_ALLOWED_USERS` and every platform allowlist
or allow-all opt-in are read from the owning profile's `.env` — the default
profile opting into open access never opens a secondary profile's bot, and a
secondary that opts in only in its own `.env` is honored. The same holds for
per-bot behaviour written in a profile's `config.yaml` (`require_mention`,
`mention_patterns`, `allow_bots`, `reactions`, `auto_thread`, `free_response_auto_thread`, `dm_policy`,
`ignored_channels`, Matrix `session_scope`, …): a secondary profile's YAML never
lands in the shared process environment, so it cannot become the default
profile's policy, and the default profile's YAML never governs a secondary
bot. The `terminal.env_passthrough` allowlist, the Yuanbao auto-designated
home channel, and the write guards protecting each profile's own `config.yaml`
are resolved per profile as well. Kanban, profile-scoped skills/memory/SOUL, and
model routing all behave per-profile exactly as they do with separate gateways.

Outbound identity is per profile too. A turn running for profile `P` that calls
the `send_message` tool (send, react, media) posts through `P`'s own bot;
so do the "Gateway shutting down/restarted" and `/update` notices for `P`'s
sessions, `/loop` wakeups set from `P`'s chats, and the Discord
unauthorized-slash operator alert of `P`'s Discord bot (to `P`'s home
channel). If `P` has no connected bot for that platform the send fails with a
clear error — it never falls back to the default profile's bot.

Tool and memory-provider credentials follow the same rule. Hosted OCR
(`FIRECRAWL_API_KEY`), Modal / Browser Use cloud gates, the mem0 OSS OpenAI
key, xAI video, and every memory-provider identity (`MEM0_USER_ID`,
`SUPERMEMORY_CONTAINER_TAG`, `RETAINDB_PROJECT`, `OPENVIKING_ACCOUNT/USER`,
`HINDSIGHT_BANK_ID`, `mibyan_HONCHO_HOST`) are read from the routed profile's
`.env`, so a secondary profile's memories land in **its** account/bank/project
(or the provider's per-profile default), never the default profile's. Custom
endpoints travel with their keys — `OPENAI_BASE_URL`, `XAI_BASE_URL`,
`NOUS_INFERENCE_BASE_URL`, `GATEWAY_PROXY_URL`, Firecrawl / Browserbase /
RetainDB / Supermemory / Honcho / Hindsight URLs — so a profile's key is never
sent to another profile's proxy or self-hosted server. `WEIXIN_HOME_CHANNEL`,
`mibyan_LANGUAGE` and `display.language`, and `hooks.outbound[].secret_env` are
likewise per profile, and end-of-session memory extraction for an evicted
secondary session runs under that profile's scope.

Per-turn runtime settings follow the routed profile as well: `agent.max_turns`,
`fallback_providers`, `file_read_max_chars`, `tool_output.*`, `browser.*`
timeouts, `timezone` (including the `TZ` handed to `execute_code` sandboxes),
the media-delivery policy (`gateway.strict`, `media_delivery_allow_dirs`,
`trust_recent_files*`) and the Nous `auth.json` used for auxiliary calls are all
read from the profile serving the turn, never from the profile the gateway was
launched under. The same holds for per-profile state files (`processes.json`,
`checkpoints/`, sandbox snapshot stores, Feishu comment rules/pairing) and for
gateway hooks: each profile's `hooks/` directory is loaded on its own and fires
only for that profile's events. Shell hooks run with the routed profile's
`mibyan_HOME`, without the default profile's secrets in their environment, and
their stdin payload carries a `profile` field naming the profile that fired them.

#### What is isolated per profile

A quick reference for what a multiplexed turn resolves from **its own**
profile and never shares with the default or any sibling:

| Concern | Resolved from | Behaviour when the profile lacks it |
| - | - | - |
| Provider keys, bot tokens, `${VAR}` refs in `config.yaml` | The profile's own `.env` (its secret scope) | Unresolved / no adapter — never the default profile's value |
| Authorization (`GATEWAY_ALLOW_ALL_USERS`, `GATEWAY_ALLOWED_USERS`, per-platform allowlists and allow-all opt-ins) | The owning profile's `.env` and `config.yaml` | Closed — a default-profile opt-in never opens a secondary's bot |
| Slash-command gating (`allow_admin_from`, `user_allowed_commands`, `group_allow_admin_from`; see [Slash commands](/desktop/reference/slash-commands)) | The profile whose bot received the message — a secondary's own platform `extra` block governs its bots, not the default profile's | Fail closed: a served profile whose config the multiplexer has not loaded is gated with an **empty** admin list and no user-enabled commands, so only the always-allowed floor (`/help`, `/whoami`) runs — never the default profile's open policy |
| HTTP endpoints (`/p/<profile>/api/...`, `/p/<profile>/webhooks/...`, platform event callbacks) | The named profile's `API_SERVER_KEY`, `profile:`-bound webhook routes, and its own adapter | `401`/`404`; delivery without an adapter is `502`/`503`, never another profile's bot |
| Inbound-port platforms (`/p/<profile>/webhooks/twilio`, `/p/<profile>/line/webhook`, `/p/<profile>/api/messages`, …) | The named profile's own adapter and its secret (Twilio auth token, LINE channel secret, Teams app, BlueBubbles password, …); replies leave through that adapter | `401`/`403` on a wrong secret, `404` when the profile has no such adapter — never the default profile's adapter |
| Adapter settings (`*_REQUIRE_MENTION`, `*_REACTIONS`, `*_ALLOW_BOTS`, `*_PROXY`, Discord `allow_mentions`, Matrix `allowed_users` / `ignore_user_patterns`, webhook host/port/URL, Matrix thread/session/E2EE policy, Discord backfill/attachment caps, Buzz reply mode, A2A agent card / public URL, WhatsApp bridge policy, Yuanbao home channel) | The owning profile, in this order: explicit `.env` value → its `config.yaml` → the adapter's default | The adapter's documented default — never the default profile's setting. Single-profile installs keep env-over-YAML exactly as each platform page documents |
| `MEDIA:` attachment denylist | Every home under `profiles/` plus the default home, enumerated at check time | A turn can never attach another profile's `.env`, `auth.json`, `state.db`, sessions or token stores |
| stdio MCP child environment | Safe baseline + the profile's scoped values for secret-source names + the server's own `env:` | A name the profile lacks is absent from the child — no default-profile fallthrough |
| Outbound egress (`send_message`, shutdown/restart/`/update` notices, `/loop` wakeups, `profile:`-bound webhook delivery, `github_comment` tokens) | The profile's own connected adapter and `.env` | Clear failure; never posts through the default profile's bot |
| Session namespace | `agent:<profile>:…` (default keeps `agent:main:…`) | Two profiles on the same chat never share history |
| Logs | `agent.log` / `errors.log` / `gateway.log` under the profile's own home | — |
| Terminal sandbox settings (`terminal.*`, SSH targets) | The profile's `config.yaml` | Documented default; unparsable config → execution refused |
| Working directory of a turn (unset `terminal.cwd`) | Same rule as a standalone gateway: `$HOME` for the local backend, sandbox default otherwise | Never the directory the multiplexer process was launched from |
| Command approvals (`command_allowlist`, "always" choices) | The profile's own `config.yaml` | A default-profile "always" never pre-approves a secondary's command; a secondary's choice is saved to its own config |
| Sandbox credential-file mounts (`terminal.credential_files`), `security.redact_secrets`, `browser.*` engine/headed flags, `lsp.*`, auxiliary-provider health marks, `logs/mcp-stderr.log` | The profile's own `config.yaml` / `.env` | Documented default — never the launch profile's cached value |
| Cloud-SDK credential clients (Bedrock boto3 clients + model discovery, Azure Entra credential), credential-fetched catalogs (DeepInfra, Copilot context limits, Nous reasoning caps, Ramp Router efforts, xAI / OpenRouter image models, custom-endpoint `/models`), Camofox VNC address, computer-use aux-vision routing, skill-sync push, remote-backend probe text, learned image token costs, `display.skin`, guest-mint back-off, banner skills, Yuanbao "active" adapter, Langfuse client | The profile's own `.env` / `config.yaml` / `<home>/cache` | Documented default — never the launch profile's cached value or its credentials |
| Session-search knobs (`sessions.cjk_fts`, `sessions.search_slow_ms`) | The profile's `config.yaml` | Documented default — never the default profile's bridged value |
| RoomLink capability catalog and the signed execution policy it advertises to a remote Bot (`approvals.mode`, `agent.max_turns`, `platform_toolsets.api_server`) | The served profile named by the request (`/p/<profile>/v1/room-members/...`, the RPC `profile` param); `target_profile` is **required** on every catalog — there is no `mibyan_PROFILE` fallback | Invitation/capabilities fail with the offending `target_profile` named; a profile that does not exist is refused, never resolved from the launch profile's config |
| Platform proxies (`TELEGRAM_PROXY`, `DISCORD_PROXY`, `HTTPS_PROXY`, …) | The profile's own `.env` | Direct connection — never the default profile's proxy |
| MCP discovery in the Desktop/dashboard backend | Once per served profile home | A profile selected after another has already built an agent still discovers its own `mcp_servers` |
| Settings changed from a Desktop / TUI session (`/busy`, `/verbose`, `/approval`, `/cwd`, theme and display toggles) | The `config.yaml` of the profile that owns the session, even when the RPC carries only the session id | The session's own profile is written; the launch profile's `config.yaml` and its `TERMINAL_CWD` are never touched |
| MCP connections in the Desktop/dashboard backend and the per-profile cron ticker | Keyed per served profile even with `gateway.multiplex_profiles` off — same rule as the multiplexer | A same-named `mcp_servers` entry with other credentials is its own connection; a served profile never calls a server as another profile |
| Dashboard actions (`mibyan -p <name> …` spawned by the Desktop/dashboard) | A scrubbed child env pinned to that profile's `mibyan_HOME` | The child loads its own `.env`; the dashboard profile's tokens and ports are not inherited |
| Every child that acts for a served profile (slash worker, Bot Chat delivery, A2A forward, `key_cmd` helper, browser driver) | That profile's own `.env` + secret sources over a credential-scrubbed base — with or without `gateway.multiplex_profiles` (the Desktop/dashboard `?profile=` route counts) | Absent from the child — a key that reached the launch process only through systemd / Compose / the shell is never inherited by another profile's child |
| Authorization gates in a child spawned for another profile (`*_ALLOWED_USERS` / `*_ALLOWED_CHANNELS` / `*_IGNORED_CHANNELS` / `*_ALLOW_ALL_USERS` / `*_ALLOW_BOTS`, `GATEWAY_ALLOW*`) — dashboard `mibyan -p <name>` actions, kanban workers, Bot Chat delivery, the post-update per-profile `gateway restart` | The child's own `.env` / `config.yaml`, loaded by the child itself | Closed (the adapter's documented default) — a gate exported into the spawning process by a unit file or the shell is dropped before the child starts, so profile B never enforces profile A's channel or user list; a same-profile child keeps it |
| Routed-profile detection in an embedding host that mirrors the served profile into the live `mibyan_HOME` env var for legacy readers (Mibyan WebUI) | The launch home the host pinned with `mibyan_constants.pin_process_mibyan_home()`; MCP connection keys, the launch-env strip for a served profile's children, the bridged allow-all seed and the `terminal.*` env-bridge guard all compare against it | Without a pin the live env var is the launch home, exactly as before — a host that never mutates `mibyan_HOME` needs nothing |
| The launch (default) profile's own credentials in a `mibyan serve` / dashboard process that also serves another profile | Its `.env` + secret sources over the process env **frozen the moment the first other profile is served**; not re-read afterwards | A credential rotated only in the process env (`systemctl set-environment`, a refreshed `op run` wrapper that did not re-exec) is not picked up until the process restarts — put rotating keys in `.env` or a secret source, or restart after rotating |
| Cron `.env` tuning (`mibyan_CRON_TIMEOUT`, `mibyan_MODEL` fallback, `mibyan_CRON_MAX_PARALLEL`, prefill file), worker / Bot Chat child env | The profile's own `.env`; children never inherit the default profile's `.env` settings or bridged `TERMINAL_*` policy | Cron defaults / model refusal, exactly as a standalone `mibyan -p <name> gateway run` |
| Kanban workers and notifications for a profile's tasks | The assignee's `.env` + `config.yaml` (toolset pin, terminal backend, media policy, display language) | — |
| `/loop` ticks, `background_process_notifications` gate, `notice_delivery`, background-process checkpoint recovery | The owning profile's `state.db` / `config.yaml` / `processes.json` | — |

What is **shared** by design: the process, its PID/lock and `gateway_state.json`
(default home), the one HTTP listener, and the `profile_routes` table (declared
on the default profile).

### Which profiles are served

`gateway.multiplex_profiles: true` serves the default profile plus **every**
live named profile under `profiles/`, except the ones that opted out with
`gateway.standalone: true` in their own `config.yaml` — a standalone profile
runs its own gateway and is not enumerated by the host (see
[No new per-profile gateways](#no-new-per-profile-gateways)).
(The former `gateway.multiplex_profile_allowlist` key is retired; a config
migration removes it from `config.yaml`, and a profile you do not want served
but that has not opted out is archived or deleted instead —
`mibyan profile delete <name>`, or move the
directory out of `profiles/`.) Deleted profiles leave a tombstone and are never
enumerated; a profile whose directory is gone is never recreated by a served
turn, the cron ticker or log routing.

The served set controls `/p/<profile>/` API and webhook prefixes, runtime
status, profile-route eligibility, and which profiles the in-process cron
scheduler ticks (the Desktop backend's ticker re-enumerates the same set on
every cycle — a profile created or deleted while Desktop runs joins or leaves
the ticked set without a restart — and stands down for any profile a running
multiplexer or its own gateway already serves). A
multiplexer started as `mibyan -p <name> gateway run` always ticks its own
profile's cron store as well.

The served set is **live**. A profile created while the multiplexer is running
(`mibyan profile create`, the dashboard, Desktop or the TUI) is served at once:
the creator pings the multiplexer over its control socket, and the multiplexer
also rescans `profiles/` every 30 seconds as a safety net. The new profile's
adapters are built the moment its `config.yaml`/`.env` carries a bot token
(creators usually create first, then add the token), `served_profiles` in the
default profile's `gateway_state.json` is updated, and `mibyan -p <name> gateway
status` reports it as served — no restart, and the other profiles' adapters and
in-flight turns are untouched. Deleting a profile stops and unroutes its
adapters the same way, and `mibyan profile rename` unroutes the old name before
the directory moves and hot-serves the new one (the old name is not resurrected
by the adapters or the cron ticker that were still bound to it). The
one-credential-one-poller rule still applies: a
hot-added profile that reuses another profile's token is parked with a
`duplicate_credential` error, never started as a second poller.

### Routing shared-bot chats to profiles (`profile_routes`)

Multiplexing selects a profile per **credential** (each profile's own bot
token) or per **URL prefix** (`/p/<profile>/` for HTTP platforms). When several
communities share **one** bot token — for example one Discord bot serving many
guilds — you can additionally route specific users/guilds/channels/threads to
different profiles with `gateway.profile_routes`:

```yaml theme={null}
gateway:
  multiplex_profiles: true
  profile_routes:
    # An entire Discord server → one profile
    - name: acme-server
      platform: discord
      guild_id: "1234567890"
      profile: acme

    # One channel in that server → a different profile
    - name: acme-support
      platform: discord
      guild_id: "1234567890"
      chat_id: "9876543210"
      profile: acme-support

    # A Telegram group (no guild concept — chat_id only)
    - name: tg-group
      platform: telegram
      chat_id: "-1001234567890"
      profile: tg-profile

    # A WhatsApp DM — write the phone number; JID and LID forms also match
    - name: owner-whatsapp
      platform: whatsapp
      chat_id: "15551234567"
      profile: owner

    # One Teams user across DMs, groups, and channels (exact sender id)
    - name: teams-owner
      platform: teams
      user_id: "00000000-0000-0000-0000-000000000000"
      profile: owner
```

Routes are matched by additive specificity: `user_id` = 16, `thread_id` = 8,
`chat_id` = 4, and `guild_id` = 2. Thus `user_id + chat_id` (20) outranks
`user_id` alone (16), which outranks every location-only route (at most 14).
All declared fields must hold (AND), equal scores keep declaration order, and a
route keyed on a channel also matches threads/forum posts whose parent is that
channel. Messages that match no route stay on the default/active profile. The
routed profile gets the full per-profile isolation described above (config,
skills, memory, credentials, session namespace). Routing works on every
platform adapter, not just Discord.

`user_id` is the **sender** of the inbound message, compared for exact equality. It is only
as trustworthy as the adapter that reports it, so treat it as an authorization input only on
platforms whose ingress authenticates the sender. Sender ids are also namespaced per tenant
on some platforms — a Slack user id is workspace-local — so on a gateway serving more than
one workspace or server, pair `user_id` with the `guild_id` of that scope (Discord guild,
Slack workspace, Matrix server) rather than relying on the id alone.

Omitting `user_id` keeps the route unconstrained by sender for backward compatibility.
Setting it to `null`, an empty string, or whitespace invalidates that route instead of
broadening it to every sender on the platform.

Sender routing selects a profile; it is not deny-by-default authorization. A sender that
matches no route falls through to the default/active profile, exactly like an unrouted
channel. To give one person a privileged profile and everyone else a restricted one, declare
the privileged sender route first, add a platform-wide catch-all route to the restricted
profile after it, and keep the platform's own ingress allowlist in place.

A route applies only to messages received by the **default profile's bot**
unless it names another bot with `bot_profile: <profile>`. Telegram DMs use the
same `chat_id` for every bot (the user's id), so without this a
`chat_id` route meant for the shared bot would also capture that user's DMs
with a secondary profile's dedicated bot. Messages arriving at a secondary
profile's own bot stay in that profile:

```yaml theme={null}
    # Pin one user's DM with team_b's OWN bot to a third profile
    - name: teamb-owner-dm
      platform: telegram
      bot_profile: team_b
      chat_id: "72719239"
      profile: ops-for-team-b
```

Authorization for a routed message is always decided by the **receiving bot's
profile** (its token and allowlist), including follow-ups sent while the agent
is busy and mid-turn checks such as `/topic` or `/stop`; the routed profile
itself needs no copy of the allowlist. A routed profile without a bot of its
own also receives background notifications (process completions, heartbeats,
async delegation results) through the shared bot after a gateway restart.

On WhatsApp and WhatsApp Cloud, a `chat_id` route matches across user-identity
forms: a bare phone number (`15551234567`), a JID
(`15551234567@s.whatsapp.net`), and a LID (`…@lid`) all refer to the same
person once the bridge has paired them (the same canonicalization session keys
and adapter allowlists already use). You can put the phone number in
`profile_routes` and inbound DMs still match whether WhatsApp delivers a JID or
a LID. Without a LID mapping yet, the number form still matches a JID (the
suffix is stripped) but cannot resolve an unknown LID — that inbound falls
through to the default profile until the mapping appears. Group chats
(`…@g.us`) are not sender identities and still match exactly. Telegram numeric
ids are unchanged.

`profile_routes` requires `gateway.multiplex_profiles: true`; with
multiplexing off the routes are ignored. If an explicit route matches but its
target profile is not installed (or was deleted), the gateway rejects that ingress and logs the route and target. It does not run
the default profile. Traffic that matches no route keeps the historical
default-profile behavior.

Cron jobs owned by a routed profile deliver through the shared bot too, but
only to targets an enabled route with a `chat_id`/`thread_id` maps to that
profile (a `guild_id + chat_id` route qualifies its channel) — a routed
profile's job targeting an unrouted chat (or a chat routed to another profile)
is never sent through the shared bot. Guild-only routes do not qualify a cron
target; add a `chat_id` route for the delivery channel. Routes declaring
`user_id` do not qualify either: cron has no authenticated inbound sender, so
they need a separate location-only route. The routed profile does not need its
own `platforms.<platform>` block for this: the shared bot's authorization comes
from the route, not from the satellite's config.

## Start, stop, or restart all gateways at once

The CLI ships with single-profile lifecycle commands. To act across every
profile, wrap them in a shell loop. Put the snippet below in
`~/.local/bin/mibyan-gateways` and `chmod +x` it:

```sh theme={null}
#!/bin/sh
set -eu

# Add or remove profile names here as you create / delete profiles.
profiles="default coder personal-bot research"

usage() {
  echo "Usage: mibyan-gateways {start|stop|restart|status|list}"
}

run_for_profile() {
  profile="$1"
  action="$2"
  if [ "$profile" = "default" ]; then
    mibyan gateway "$action"
  else
    mibyan -p "$profile" gateway "$action"
  fi
}

action="${1:-}"
case "$action" in
  start|stop|restart|status)
    for profile in $profiles; do
      echo "==> $action $profile"
      run_for_profile "$profile" "$action"
    done
    ;;
  list)
    mibyan gateway list
    ;;
  *)
    usage
    exit 2
    ;;
esac
```

Then:

```bash theme={null}
mibyan-gateways start      # start every configured profile
mibyan-gateways stop       # stop every configured profile
mibyan-gateways restart    # restart all
mibyan-gateways status     # status across all
mibyan-gateways list       # delegates to `mibyan gateway list`
```

<Tip>
  The `default` profile is targeted with `mibyan gateway <action>` (no `-p`),
  not `mibyan -p default gateway <action>`. The wrapper above handles both forms.
</Tip>

## Manage one profile

The shortcut commands every profile installs:

```bash theme={null}
coder gateway run        # foreground (Ctrl-C to stop)
coder gateway start      # start the managed service
coder gateway stop       # stop the managed service
coder gateway restart    # restart
coder gateway status     # status
coder gateway install    # create the LaunchAgent / systemd unit
coder gateway uninstall  # remove the service file
```

These are equivalent to `mibyan -p coder gateway <action>` — useful if a
profile alias is not on `PATH` or if you target profiles dynamically from a
script.

## Service files

Each profile installs its own service with a unique name, so installations
never clash:

| Platform | Path |
| - | - |
| macOS | `~/Library/LaunchAgents/ai.mibyan.gateway-<profile>.plist` |
| Linux | `~/.config/systemd/user/mibyan-gateway-<profile>.service` |

The default profile keeps the historical names: `ai.mibyan.gateway.plist` /
`mibyan-gateway.service`.

## Viewing logs

Each profile writes to its own log files:

```bash theme={null}
# Default profile
tail -f ~/.mibyan/logs/gateway.log
tail -f ~/.mibyan/logs/gateway.error.log

# Named profile
tail -f ~/.mibyan/profiles/<name>/logs/gateway.log
tail -f ~/.mibyan/profiles/<name>/logs/gateway.error.log
```

Stream every profile's log simultaneously:

```bash theme={null}
tail -f ~/.mibyan/logs/gateway.log ~/.mibyan/profiles/*/logs/gateway.log
```

The CLI also has a structured log viewer:

```bash theme={null}
mibyan logs -f                  # follow default profile
mibyan -p coder logs -f         # follow one profile
mibyan logs --help              # filters, levels, JSON output
```

## Identify what's actually running

```bash theme={null}
mibyan profile list             # profiles + model + gateway state
mibyan-gateways status          # full status across every profile
launchctl list | grep mibyan    # macOS — PIDs and labels
systemctl --user list-units 'mibyan-gateway-*'   # Linux — units
```

## Editing configuration

Every profile keeps its config inside its own directory:

```
~/.mibyan/profiles/<name>/
├── .env              # API keys, bot tokens (chmod 600)
├── config.yaml       # model, provider, toolsets, gateway settings
└── SOUL.md           # personality / system prompt
```

The default profile uses `~/.mibyan/` directly with the same three files.

Edit them with any editor or via the CLI:

```bash theme={null}
mibyan config set model.model anthropic/claude-sonnet-4    # default profile
coder config set model.model openai/gpt-5                  # named profile
```

After editing `.env` or `config.yaml`, restart the affected gateway:

```bash theme={null}
coder gateway restart
# or, for everything:
mibyan-gateways restart
```

## Keeping the host awake

The gateway process can run all day, but the operating system will still try
to sleep when idle. Two patterns:

### macOS — `caffeinate`

`caffeinate` is built into macOS and prevents sleep while it runs. No install.

```bash theme={null}
caffeinate -dis                    # block display, idle, and system sleep
caffeinate -dis -t 28800           # same, auto-exit after 8 hours
caffeinate -i -w $(cat ~/.mibyan/gateway.pid) &   # awake while default gateway runs

# Persistent: run in background and forget
nohup caffeinate -dis >/dev/null 2>&1 &
disown

# Inspect / stop
pmset -g assertions | grep -iE 'caffeinate|prevent|user is active'
pkill caffeinate
```

| Flag | Effect |
| - | - |
| `-d` | block display sleep |
| `-i` | block idle system sleep (default) |
| `-m` | block disk sleep |
| `-s` | block system sleep (AC-powered Macs only) |
| `-u` | simulate user activity (prevents screen lock) |
| `-t N` | auto-exit after `N` seconds |
| `-w P` | exit when PID `P` exits |

<Warning>
  **Lid-close still sleeps the Mac**

  `caffeinate` cannot override the hardware-driven lid-close sleep on MacBooks.
  For lid-closed operation, change your Energy Saver / Battery preferences or
  use a third-party tool.
</Warning>

### Linux — `systemd-inhibit` or `loginctl`

```bash theme={null}
# Inhibit suspend while a command runs
systemd-inhibit --what=idle:sleep --who=mibyan --why="gateways running" \
  sleep infinity &

# Allow user services to keep running after logout (recommended)
sudo loginctl enable-linger "$USER"
```

After enabling lingering, your systemd user units (including
`mibyan-gateway-<profile>.service`) continue running across SSH disconnects
and reboots.

## Token-conflict safety

Each profile must use unique bot tokens for each platform. If two profiles
share a Telegram, Discord, Slack, WhatsApp, or Signal token, the second
gateway refuses to start with an error naming the conflicting profile. Under
[multiplexing](#alternative-one-gateway-for-all-profiles-multiplexing) the same
rule parks only the duplicate profile's adapter and the shared gateway keeps
running.

To audit:

```bash theme={null}
grep -H 'TELEGRAM_BOT_TOKEN\|DISCORD_BOT_TOKEN' \
     ~/.mibyan/.env ~/.mibyan/profiles/*/.env
```

## Migrating from per-profile gateways

If your profiles each run their own gateway today (one systemd unit or launchd
agent per profile, from a release before multiplex-only), the default gateway's
boot preflight keeps it standalone until they are folded (the unset default
never double-binds a running fleet). `mibyan update` folds them for you unless a
real boundary blocks it (below); the same fold is one command, and re-running it
on a half-migrated host (flag on, a unit left behind, a crash between the two)
finishes the job instead of reporting "already multiplexed":

```bash theme={null}
mibyan gateway migrate --multiplex --dry-run   # print the plan and any blockers; changes nothing
mibyan gateway migrate --multiplex             # apply (asks for confirmation on a TTY; -y skips)
```

There is no `--standalone` reverse command: a per-profile fleet is not a
supported target. A blocked fleet keeps running as it is, and each profile
keeps `mibyan -p <name> gateway install --force` as its path — or opts out of
the host gateway with `gateway.standalone: true` (see
[No new per-profile gateways](#no-new-per-profile-gateways)), which
`mibyan gateway migrate --multiplex` respects.

### Docker / Mibyan Cloud (s6-supervised container)

Inside the official image every profile has an s6 slot
(`/run/service/gateway-<profile>`). The container's boot registers every *named*
slot down and folds its autostart intent into the root slot, so a fresh boot
already multiplexes. An **in-place** update no longer needs a container restart
to converge either: `mibyan gateway migrate --multiplex` (and the hook `mibyan
update` runs) parks any named slot that is still up (`s6-svc -d` plus a `down`
file so a supervisor restart does not revive it), folds its intent into the root
slot through the same rule the boot uses, and restarts the root slot. A
registered-down slot is never a blocker — only a slot that is actually up is.
The one thing the command still cannot do from inside is create a root slot the
boot never registered; that case names itself and asks for a container restart.

### What `mibyan update` does

After a successful update, when the install has two or more profiles, at least
one secondary profile runs its own gateway (a live process or an installed
service) and `gateway.multiplex_profiles` is off, `mibyan update` runs the same
preflight:

* **Nothing blocks it** → the migration runs automatically (the same code path
  as `mibyan gateway migrate --multiplex --yes`) and prints what it did. This
  is deterministic and never prompts, so it also runs on headless/cron updates.
* **Something blocks it** → a warning block lists each blocker with its exact
  fix and the one-liner to run later. Nothing is changed.

Single-profile installs are never migrated (there is nothing to gain), and an
install that is already multiplexing is left alone. `mibyan update` also does
nothing when no secondary profile runs its own gateway — it never flips modes
on an install where nothing was running.

### Boundaries `mibyan update` never crosses on its own

The unattended hook only folds profiles that share **one UNIX user, one service
domain and one `profiles/` tree** — the shape `mibyan profile create` produces.
A standalone secondary behind any of these boundaries stops the automatic path:

| boundary | example |
| - | - |
| different service manager or scope | default on user systemd, a secondary on **system** systemd (or launchd), or the default detached with a service-managed secondary |
| more than one installed unit on a profile | a user **and** a system unit for the same profile (the explicit command removes both) |
| different UNIX user | a system unit with its own `User=`, or a live gateway owned by another uid; a system unit whose `User=` this host cannot resolve — on the secondary **or** on the default — counts as unknown, never as "same user" |
| `mibyan_HOME` outside `<default home>/profiles/` | a unit pinning `mibyan_HOME=/opt/mibyan/profiles/emma` |

In that case `mibyan update` prints the boundary it found plus
`mibyan gateway migrate --multiplex`, and changes nothing — no unit is removed
and the per-profile gateways keep running (`--force` remains their path where
a boundary like these blocks the fold; a profile free of them opts out with
`gateway.standalone: true`). Collapsing such a fleet replaces a
kernel-enforced boundary (file ownership, `User=`) with in-process isolation,
which is an operator's decision. The explicit command still makes it: the same
findings appear as **notices** in `mibyan gateway migrate --multiplex --dry-run`
so you can read them first, and `--multiplex` proceeds when you confirm.

### Opting out of the automatic migration

Set `gateway.auto_multiplex_migration: false` on the **default** profile to keep
the automatic fold from ever running on this install:

```bash theme={null}
mibyan config set gateway.auto_multiplex_migration false
```

`mibyan update` then leaves per-profile gateways exactly as they are, with no
output and no changes, however eligible the install looks. The setting lives in
config, so it survives updates — the decision is made once rather than
re-litigated on every release. It is read from the effective config like every
other setting, so a value pinned in the managed scope (`/etc/mibyan/config.yaml`)
wins over the profile's own file. It governs the **automatic** path only:
`mibyan gateway migrate --multiplex` is an explicit request and still migrates
(and is the supported way to opt back in). Absent or `true` keeps the default
behaviour described above.

The explicit command is different: `mibyan gateway migrate --multiplex` with
two or more profiles and **no** standalone secondary gateway still applies the
one remaining step — it sets `gateway.multiplex_profiles: true` and (re)starts
the default gateway. You asked for multiplex; you get multiplex.

<Tip>
  **Clones do not carry channels**

  `mibyan profile create --clone` leaves the source's bot tokens and allowlists
  behind (see [Profiles → messaging channels are never cloned](/desktop/user-guide/profiles#messaging-channels-are-never-cloned---clone-channels-to-opt-in)),
  so a fleet of clones no longer trips the duplicate-credential blocker below.
  Older clones that still carry them are flagged by `mibyan profile list`.
</Tip>

### What the migration does

1. Stops each secondary profile's standalone gateway and uninstalls its
   service (systemd user/system unit or launchd agent). What was removed is
   recorded in `~/.mibyan/gateway_migration.json` for rollback.
2. Sets `gateway.multiplex_profiles: true` in the **default** profile's
   `config.yaml`.
3. Restarts the default gateway — or installs and starts it on the same service
   manager the secondaries were using, so a systemd-managed fleet stays
   systemd-managed.
4. Waits for the default gateway to record `served_profiles` covering every
   profile, then prints a summary.

### Blockers and fixes

| Blocker | Why | Fix |
| - | - | - |
| Two profiles configure the same platform credential (e.g. the same `TELEGRAM_BOT_TOKEN`) | Under one process a bot token can only be polled once; the multiplexer would park the duplicate and that profile's bot would go silent | Remove the token from the second profile, or keep it in `default` and route that profile's chats with [`profile_routes`](#routing-shared-bot-chats-to-profiles-profile_routes) |
| A secondary profile enables a port-binding platform that has **no** `/p/<profile>/` ingress on the default listener | The multiplexer skips that whole profile (see [rule 2](#2-http-inbound-platforms-are-reached-via-a-pprofile-url-prefix)) | Disable the platform in that profile (`platforms.<name>.enabled: false`), or run the profile standalone: set `gateway.standalone: true` in its own `config.yaml` and wait for the host to rescan (at most 30 seconds), or send its `rescan-profiles` control verb. Use `mibyan -p <name> gateway install --force` only where a boundary blocks the fold. |

The credential check reuses the gateway's own conflict detection, so its verdict
matches what the multiplexer does at startup. Which port-binding platforms have
a `/p/<profile>/` ingress is read from the adapters themselves (each declares
`serves_profile_prefix`), so the preflight stays correct as new HTTP-inbound
adapters gain the prefix.

### What changes for inbound-port profiles

A secondary profile that used `api_server` or `webhook` on its own port is
**not** blocked — but its URL changes. The preflight prints the exact new URL,
for example:

```
Profile 'coder': api_server moves onto the default listener at
http://127.0.0.1:8642/p/coder/v1/... (its key/secret is unchanged; update
clients that call the old per-profile port).
```

The profile's own `API_SERVER_KEY` / webhook secret keeps authenticating the
prefixed URL; nothing else about the key changes.

### Profiles created after the migration

A profile created while the multiplexer runs is served without a restart (see
above). `mibyan profile create` confirms this when the live multiplexer picked the
profile up; it prints the `mibyan gateway restart` reminder only when it could not
reach the multiplexer (for example, a gateway started from an older build).

### Failure handling and resuming

The migration is transactional. Failures it can see
coming from the plan (a system unit that would have to run as root without a
recorded `User=`, a config file it cannot rewrite) are refused before any
per-profile gateway is stopped. Anything that fails after the manifest is
written — the flag write, a later secondary's stop or unit removal, the
default's install or start — rolls back through the manifest on the spot, so no
profile is left without a gateway. Should the process die anywhere in that
window, the next `mibyan gateway migrate --multiplex` sees the flag on, the
manifest, and no live multiplexer serving the migrated profiles (an installed
but stopped default unit does not count) and resumes from the manifest instead
of reporting "already multiplexed". A manifest on disk always means
*unfinished*: it is the resume record, not a rollback command — there is no
`--standalone` reverse, and the compensator above only ever runs inside a
single failed apply so that no profile is left without a gateway.

Not covered automatically: s6-supervised containers — they converge on the next
container start (the per-profile slots are registered down and the root gateway
multiplexes; a `gateway.standalone: true` profile boots its own slot from its own
run intent instead). Windows Scheduled Tasks are folded by the command. The dashboard's
System page offers the same migration as a button when the preflight finds an
eligible install.

## Updating the code

`mibyan update` pulls the latest code once and syncs new bundled skills into
every profile:

```bash theme={null}
mibyan update
mibyan-gateways restart
```

Running gateways are restarted by the update itself; on an install that still
runs one gateway per profile, the update then runs the
[migration to a single multiplexed gateway](#migrating-from-per-profile-gateways)
— automatically when nothing blocks it, otherwise as a warning naming the
boundary (different UNIX user, `mibyan_HOME` outside `profiles/`) and the
one-liner to run yourself.

User-modified skills are never overwritten.

## Troubleshooting

### "Could not find service in domain for user gui: 501"

You ran `mibyan gateway start` after a previous `mibyan gateway stop`. The
CLI's `stop` does a full `launchctl unload`, which removes the service from
launchd's registry. The CLI catches this specific error on `start` and
automatically re-loads the plist (`↻ launchd job was unloaded; reloading
service definition`). The service starts normally. Nothing to fix.

### Stale PID after a crash

If a profile's gateway shows `not running` but a process is still alive:

```bash theme={null}
ps -ef | grep "mibyan_cli.*-p <profile>"
cat ~/.mibyan/profiles/<profile>/gateway.pid
kill -TERM <pid>          # graceful
kill -KILL <pid>          # if that fails after a few seconds
<profile> gateway start
```

### Forcing a hard reset of one service

```bash theme={null}
# macOS
launchctl unload ~/Library/LaunchAgents/ai.mibyan.gateway-<profile>.plist
launchctl load   ~/Library/LaunchAgents/ai.mibyan.gateway-<profile>.plist

# Linux
systemctl --user restart mibyan-gateway-<profile>.service
```

### Health check

```bash theme={null}
mibyan doctor                  # default profile
mibyan -p <profile> doctor     # one profile
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.