Inbound messages move through routing, dedupe/debounce, an agent run, and outbound delivery:

Inbound message
  -> routing/bindings -> session key
  -> dedupe + debounce
  -> queue (if a run is already active)
  -> agent run (streaming + tools)
  -> outbound replies (channel limits + chunking)

Key config surfaces:

See Configuration for the full schema.

Inbound dedupe

Channels can redeliver the same message after a reconnect. Apus keeps an in-memory cache keyed by agent scope, channel route (channel + peer + account + thread), and message id, so a redelivered message does not trigger a second agent run. The cache entry expires after 20 minutes or once 5000 entries are tracked, whichever comes first.

Inbound debouncing

Rapid text messages from the same sender can be batched into one agent turn via messages.inbound. Debouncing is scoped per channel + conversation and uses the most recent message for reply threading/IDs. It is a quiet-window heuristic, not a guarantee that every part of a long message will arrive in one turn.

{
  messages: {
    inbound: {
      debounceMs: 2000,
      byChannel: {
        discord: 1500,
        slack: 1500,
        whatsapp: 5000,
      },
    },
  },
}

Changes to messages.inbound.debounceMs and messages.inbound.byChannel apply without reconnecting Discord, Feishu, iMessage, Mattermost, Microsoft Teams, Signal, Slack, Telegram, or WhatsApp. Newly admitted inbound work uses the committed delay. A config change alone does not reschedule a pending batch; later messages can update its idle delay within the original maximum deadline. Explicit transport timing overrides remain fixed. Telegram's forwarded-message collection window remains separate.

Sessions and devices

Sessions are owned by the gateway, not by clients.

Multiple devices/channels can map to the same session, but history is not fully synced back to every client. Use one primary device for long conversations to avoid divergent context. The Control UI and TUI always show the gateway-backed session transcript, so they are the source of truth.

If a run fails or times out before an assistant reply is saved, its transcript receives one visible failure notice. Nested runs retain their own failure notices even after the requesting turn has ended.

Details: Session management.

Prompt bodies and history context

Channel plugins populate several text fields on the inbound context, from most to least preferred:

Field Purpose
BodyForAgent Model-facing text for the current turn. Falls back to CommandBody / RawBody / Body when unset.
BodyForCommands Clean text used for directive/command parsing. Falls back to CommandBody / RawBody / Body when unset.
CommandBody Legacy intermediate body; prefer BodyForCommands.
RawBody Deprecated alias for CommandBody.
Body Legacy prompt body; may include channel envelopes and history wrappers.

When a channel supplies history, it wraps it with:

For non-direct chats (groups/channels/rooms), the current message body is prefixed with the sender label, matching the style used for history entries. Directive stripping only applies to the current-message section, so history stays intact. Channels that wrap history should set BodyForCommands (or the legacy CommandBody / RawBody) to the original message text and keep Body as the combined prompt.

History buffers are pending-only: they include group messages that did not trigger a run (for example, mention-gated messages) and exclude messages already in the session transcript. Structured history, reply, forwarded, and channel metadata render as untrusted user-role context blocks during prompt assembly.

Configure history size with messages.groupChat.historyLimit (global default) or per-channel overrides such as channels.slack.historyLimit and channels.telegram.accounts.<id>.historyLimit (set 0 to disable).

Tool result metadata

Tool result content is the model-visible result; details is runtime metadata for UI rendering, diagnostics, media delivery, and plugins.

When a tool-error warning is the agent's only reply, WebChat displays and retains it. The warning does not by itself change a completed agent run into a runtime failure; the failed tool result remains recorded separately.

Queueing and followups

When a run is already active, inbound messages steer into it by default. messages.queue controls the mode:

Mode Behavior
steer (default) Inject the new prompt into the active run.
followup Run the message after the active run finishes.
collect Batch compatible messages into one later turn.
interrupt Abort the active run, then start the newest prompt.

Messages with separate durable ingress admission, including Discord and Telegram messages, stay in separate followup turns even in collect mode. Compatible Gateway chat.send inputs can still combine.

The queue uses a built-in 500ms debounce for steer, followup, and collect batching. messages.queue.cap defaults to 20 queued messages, and messages.queue.drop defaults to summarize (old and new are also available). Configure per-channel overrides via messages.queue.byChannel and messages.queue.debounceMsByChannel.

Details: Command queue and Steering queue.

Channel run ownership

Channel plugins may preserve ordering, debounce input, and apply transport backpressure before a message enters the session queue. They should not impose a separate timeout around the agent turn itself. Once a message is routed to a session, the session, tool, and runtime lifecycle govern long-running work so all channels report and recover from slow turns consistently.

Once a turn is durably accepted, an unexpected failure before its answer produces a compact error reply in direct chats and explicitly addressed conversations where automatic replies are enabled. Progress acknowledgments do not replace that final outcome. The turn remains failed and is not replayed as a new inbound message; delivery policies and replies already sent through the message tool still apply.

With the Apus runtime, an assistant turn that errors or is aborted after producing partial text, without tool calls, appears as a short failure marker in the next model request. Its unfinished text is not replayed, and the stored failed turn stays unchanged. Empty and placeholder-only failures remain excluded; failed tool calls keep their existing pairing rules. The marker does not establish whether an earlier action completed.

Streaming, chunking, and batching

Block streaming sends partial replies as the model produces text blocks; chunking respects channel text limits and avoids splitting fenced code.

Details: Streaming + chunking.

Reasoning visibility and tokens

Details: Thinking + reasoning directives and Token use.

Prefixes, threading, and replies

Details: Configuration and channel docs.

Silent replies

The silent token NO_REPLY (case-insensitive, so no_reply also matches) is reserved for sessions connected to external message channels and is never delivered as user-visible text. Subagents, the Control UI, and other internal sessions must return a result or continue unfinished work; a silent token cannot complete their task. When a turn also has pending tool media, such as generated TTS audio, Apus strips the silent text but still delivers the media attachment.

Silence policy resolves by conversation type:

Defaults live under agents.defaults.silentReply.group; surfaces.<id>.silentReply.group can override group policy per surface. Doctor removes the retired internal setting during config migration.

Generic internal runner failures stay quiet for optional turns that have not shown visible output, including groups explicitly configured to allow silence. Required turns still receive an error. Classified recovery guidance, such as missing-auth, rate-limit, or overload notices, remains deliverable, and visible progress receives a failure outcome rather than being left unfinished. Direct chats show compact failure copy by default; raw runner details show only when /verbose full is enabled.

Reply requirements come from admission, not model control tokens. Confirmed delivery or still-owned delivery prevents duplicate recovery; see Reply shaping.

Related