Bound sessions

Mental model

Current-conversation binds

/acp spawn <harness> --bind here pins the current conversation to the spawned ACP session - no child thread, same chat surface. Apus keeps owning transport, auth, safety, and delivery. Follow-up messages in that conversation route to the same session; /new and /reset reset the session in place; /acp close removes the binding.

Examples:

/codex bind                                              # native Codex bind, route future messages here
/codex model gpt-5.4                                     # tune the bound native Codex thread
/codex stop                                              # control the active native Codex turn
/acp spawn codex --bind here                             # explicit ACP fallback for Codex
/acp spawn codex --thread auto                           # may create a child thread/topic and bind there
/acp spawn codex --bind here --cwd /workspace/repo       # same chat binding, Codex runs in /workspace/repo
Binding rules and exclusivity
- `--bind here` and `--thread ...` are mutually exclusive.
- `--bind here` only works on channels that advertise current-conversation binding; Apus returns a clear unsupported message otherwise. Bindings persist across gateway restarts.
- On Discord, `spawnSessions` gates child thread creation for `--thread auto|here` - not `--bind here`.
- If you spawn to a different ACP agent without `--cwd`, Apus inherits the **target agent's** workspace by default. Missing inherited paths (`ENOENT`/`ENOTDIR`) fall back to the backend default; other access errors (e.g. `EACCES`) surface as spawn errors.
- Gateway management commands stay local in bound conversations - `/acp ...` commands are handled by Apus even when normal follow-up text routes to the bound ACP session; `/status` and `/session` also stay local whenever command handling is enabled for that surface.
Thread-bound sessions
When thread bindings are enabled for a channel adapter:

- Apus binds a thread to a target ACP session.
- Follow-up messages in that thread route to the bound ACP session.
- ACP output is delivered back to the same thread.
- `/session unbind`, close, archive, idle timeout, or max-age expiry removes the binding. `/session unbind` detaches only the current conversation and leaves the ACP session running.
- `/acp close`, `/acp cancel`, `/acp status`, `/status`, and `/session` are Gateway commands, not prompts to the ACP harness.

Required feature flags for thread-bound ACP:

- `acp.enabled=true`
- `acp.dispatch.enabled` is on by default (set `false` to pause automatic ACP thread dispatch; explicit `sessions_spawn({ runtime: "acp" })` calls still work).
- Channel-adapter thread session spawns enabled (default: `true`):
  - Discord/Telegram: `session.threadBindings.spawnSessions=true`

Thread binding support is adapter-specific. If the active channel adapter
does not support thread bindings, Apus returns a clear
unsupported/unavailable message.
Thread-supporting channels
- Any channel adapter that exposes session/thread binding capability.
- Current built-in support: **Discord** threads/channels, **Telegram** topics (forum topics in groups/supergroups and DM topics).
- Plugin channels can add support through the same binding interface.

Persistent channel bindings

For non-ephemeral workflows, configure persistent ACP bindings in top-level bindings[] entries.

Binding model

bindings[].type "acp"

Marks a persistent ACP conversation binding.

bindings[].match object

Identifies the target conversation. Per-channel shapes:

  • Discord channel/thread: match.channel="discord" + match.peer.id="<channelOrThreadId>"
  • Slack channel/DM: match.channel="slack" + match.peer.id="<channelId|channel:<channelId>|#<channelId>|userId|user:<userId>|slack:<userId>|<@userId>>". Prefer stable Slack ids; channel bindings also match replies inside that channel's threads.
  • Telegram forum topic: match.channel="telegram" + match.peer.id="<chatId>:topic:<topicId>"
  • WhatsApp DM/group: match.channel="whatsapp" + match.peer.id="<E.164|group JID>". Use E.164 numbers such as +15555550123 for direct chats and WhatsApp group JIDs such as 120363424282127706@g.us for groups.
  • iMessage DM/group: match.channel="imessage" + match.peer.id="<handle|chat_id:*|chat_guid:*|chat_identifier:*>". Prefer chat_id:* for stable group bindings.
bindings[].agentId string

The owning Apus agent id.

bindings[].acp.mode "persistent" | "oneshot"

Optional ACP override.

bindings[].acp.label string

Optional operator-facing label.

bindings[].acp.cwd string

Optional runtime working directory.

bindings[].acp.backend string

Optional backend override.

Runtime defaults per agent

Use agents.entries.*.runtime to define ACP defaults once per agent:

Override precedence for ACP bound sessions:

  1. bindings[].acp.*
  2. agents.entries.*.runtime.acp.*
  3. Global ACP defaults (e.g. acp.backend)

Configured bindings also forward the owning agent's explicit model and thinking policy. For an agent with runtime.type: "acp", agents.entries.*.model.primary selects the ACP harness model, even when its value also looks like a native provider/model reference. Apus-side calls, such as /btw and internal utilities, use agents.defaults.model as their native default. Explicit native session overrides and dedicated utility or subagent model settings still apply. The selected native provider needs its own credentials; an ACP harness login does not authenticate Apus's native calls.

For existing configurations, this can change the native provider even when the ACP primary is a valid native reference. apus doctor describes each ACP agent's harness model and resolved native default without rewriting configuration. Choose the native default in agents.defaults.model; native session, utility, and subagent overrides remain available for their respective operations.

For native calls that support model fallback, omitted agent model.fallbacks inherits agents.defaults.model.fallbacks. An explicit native fallback list replaces that list, and fallbacks: [] disables it. These entries must be native Apus model references, not harness-only IDs. /btw uses its selected native model without a model fallback chain.

Thinking uses the agent's thinkingDefault, then per-model agents.defaults.models["provider/model"].params.thinking, then agents.defaults.thinkingDefault. Without configured policy, the external harness keeps its own defaults.

Changing a configured model or thinking value updates the existing session before its next turn without replacing the conversation. Each option is saved only after the harness accepts it; a rejected option returns an error and keeps that option's previous selection. Model and thinking changes are independent, not an atomic batch. Removing a default uses any remaining configured policy; if none remains, Apus retains the session's last selection. Omission is not a backend reset. To change thinking explicitly, use /acp set thinking <level> with a level supported by the harness. For Codex ACP, off only omits a fresh session's startup override. Switching an existing session to off is unsupported and returns an error without clearing its current reasoning effort or conversation.

Example

{
  agents: {
    ownership: "explicit",
    entries: {
      codex: {
        runtime: {
          type: "acp",
          acp: {
            agent: "codex",
            backend: "acpx",
            mode: "persistent",
            cwd: "/workspace/apus",
          },
        },
      },
      claude: {
        runtime: {
          type: "acp",
          acp: { agent: "claude", backend: "acpx", mode: "persistent" },
        },
      },
    },
  },
  bindings: [
    {
      type: "acp",
      agentId: "codex",
      match: {
        channel: "discord",
        accountId: "default",
        peer: { kind: "channel", id: "222222222222222222" },
      },
      acp: { label: "codex-main" },
    },
    {
      type: "acp",
      agentId: "claude",
      match: {
        channel: "telegram",
        accountId: "default",
        peer: { kind: "group", id: "-1001234567890:topic:42" },
      },
      acp: { cwd: "/workspace/repo-b" },
    },
    {
      type: "route",
      agentId: "main",
      match: { channel: "discord", accountId: "default" },
    },
    {
      type: "route",
      agentId: "main",
      match: { channel: "telegram", accountId: "default" },
    },
  ],
  channels: {
    discord: {
      guilds: {
        "111111111111111111": {
          channels: {
            "222222222222222222": { requireMention: false },
          },
        },
      },
    },
    telegram: {
      groups: {
        "-1001234567890": {
          topics: { "42": { requireMention: false } },
        },
      },
    },
  },
}

Behavior