You don’t need to understand the technical details to use this feature. The configuration examples below are copy-paste ready.
How delivery works
Every agent response passes through the same pipeline before reaching the chat:- The agent finishes writing its response (or streams it as it thinks)
- The response is split into blocks based on your chosen chunk mode
- Typing indicators show that the bot is active
- Each block is delivered to the platform with pacing delays between them
- If a delivery fails, the retry engine handles it automatically
Block streaming
Long responses are split into smaller blocks before sending. This prevents hitting platform message limits and creates a more natural reading experience.Chunk modes
Comis offers 4 ways to split responses into blocks:
Code fences are never split. If your agent writes a code block, it stays in one
message even if it exceeds the normal block size.
The maximum characters per block defaults to each platform’s message limit (for
example, 2,000 for Discord or 4,096 for Telegram). You can override this with
the
chunkMaxChars setting.
Streaming methods
Different platforms support different ways of delivering blocks as the agent writes: Edit (Discord, Telegram, Slack): Sends the first block, then keeps editing the same message with new text as the agent writes. This creates a “live typing” effect similar to ChatGPT. Edit throttle rates prevent overwhelming the platform API: Discord at 500 ms, Telegram at 300 ms, Slack at 400 ms. Block (WhatsApp, Signal): Sends each block as a separate message. These platforms cannot edit sent messages, so each block arrives as its own message. WhatsApp throttles at 600 ms, Signal at 500 ms. None (iMessage, LINE, IRC): No streaming. The full response is sent after the agent finishes writing. Long responses are still split by the block chunker, but all blocks are sent at once rather than progressively.Block pacing
Block pacing controls the timing between messages. Instead of flooding the chat with all blocks at once, Comis spaces them out at intervals you can configure.Pacing modes
The first block is always sent immediately (unless you configure
firstBlockDelayMs to add a delay). Short consecutive blocks are automatically
combined into one message to prevent notification spam.
If a new message arrives from the user while blocks are being delivered, the
remaining blocks are sent immediately. This means the bot does not keep typing
an old response when the user has already moved on.
Typing indicators
Typing indicators are the “User is typing…” status that appears in chat applications. Comis uses them to show users that the bot is active and working on a response.Typing modes
Typing indicators are fire-and-forget. If showing the typing status fails (for
example, due to a network hiccup), it never blocks or delays message delivery.
Typing lifecycle
Comis uses a dual idle signal to ensure typing indicators accurately reflect the bot’s activity. Rather than stopping typing as soon as the agent finishes thinking, typing persists through the entire delivery phase:- The agent finishes generating its response — a run-complete signal fires
- The delivery pipeline sends all message blocks to the platform
- When delivery finishes, a dispatch-idle signal fires
- Typing stops only after both signals have fired
Per-platform refresh intervals
Different platforms expire typing indicators at different rates. Comis automatically uses optimal refresh intervals for each platform:
These defaults are applied automatically. You can override the refresh interval
per channel with
typingRefreshMs in the per-channel streaming config.
Typing resilience
Typing indicators include built-in resilience mechanisms that prevent runaway indicators and handle platform failures gracefully: Circuit breaker: If sending the typing indicator failstypingCircuitBreakerThreshold times consecutively (default: 3), the indicator
is permanently stopped for that message. This prevents log noise from persistent
platform failures. The counter resets on any successful send.
TTL (Time-to-Live): Typing indicators automatically stop after typingTtlMs
milliseconds (default: 60,000 ms / 1 minute). This prevents indefinite typing
indicators if the agent execution hangs or takes unusually long. The TTL refreshes
on each content signal (text delta from the model), so active generation keeps the
indicator alive.
Sealed state: Once a typing indicator stops (via explicit stop, TTL expiry, or
circuit breaker trip), it cannot be restarted for that message. This prevents
flickering indicators.
Lifecycle coordination: The typing lifecycle controller wraps the base
typing controller with dual idle signal tracking. In normal operation, the
dispose() method is called in a finally block to guarantee cleanup even
on errors. The lifecycle controller does not interfere with the sealed state,
circuit breaker, or TTL mechanisms — those continue to operate on the
underlying controller.
Sub-agent typing
When an agent delegates work to a sub-agent, you normally see 30 seconds to several minutes of silence while the sub-agent executes. Comis solves this by showing a typing indicator on the parent channel for the entire duration of sub-agent execution. How it works:- A sub-agent starts executing on behalf of the parent agent
- A typing indicator appears on the channel where the user sent their message
- Typing persists continuously while the sub-agent runs (tool calls, LLM reasoning, file operations)
- When the sub-agent finishes, typing stops and the completion announcement arrives
Retry logic
If sending a message fails due to a network error, rate limit, or server error, Comis retries automatically using exponential backoff with jitter. Exponential backoff means the bot waits longer between each retry attempt. The first retry might wait 500 ms, the second might wait 1,000 ms, the third might wait 2,000 ms, and so on — up to a maximum of 30 seconds. Jitter adds a small random amount to each wait time so that multiple bots hitting the same server do not all retry at the exact same moment. Default retry behavior:- 3 attempts per message
- Starting delay of 500 ms
- Maximum delay of 30 seconds
- Jitter enabled by default
Delivery strategies
When a multi-chunk response encounters a send failure, Comis can either stop or keep going. The delivery strategy controls this behavior.
In all-or-abort mode (the default), a failed chunk stops the entire delivery.
The remaining chunks are never sent. This is the safest choice because users
see either the full response or nothing, never a confusing partial message.
In best-effort mode, each chunk is sent independently. If chunk 2 of 5 fails,
chunks 3, 4, and 5 are still delivered. The failed chunk is logged and reported
via the
delivery:complete event, but delivery continues. Failed chunks are
marked as terminal in the delivery queue and are not retried later.
Best-effort mode is not the default. If you want partial delivery, enable it
explicitly in your streaming configuration.
Stopping delivery
The/stop command cancels both the agent’s thinking and any in-flight message
delivery. When a user sends /stop:
- The agent execution is aborted (the LLM stops generating)
- Any blocks waiting for pacing delays are cancelled immediately
- The current chunk (if mid-send) completes naturally — Comis never interrupts a platform API call in progress
- Remaining unsent chunks are skipped entirely
/stop is responsive even during long deliveries with pacing delays
between blocks. There is no waiting for the next block timer to expire.
What happens to the delivery queue
Comis uses abort-is-clean semantics. When delivery is cancelled:- Chunks already delivered to the platform remain delivered (cannot be recalled)
- Chunks that were queued but not yet sent are acknowledged in the delivery queue with an abort marker — they are not retried later
- No orphaned entries remain in the queue after a
/stop
delivery:aborted event fires with the abort reason and a count of how many
chunks were delivered before cancellation.
The retry engine also respects the abort signal. If a retry sleep is in progress
when
/stop fires, the sleep resolves immediately rather than waiting for the
full backoff period.Markdown rendering
Agent responses are written in Markdown. Comis automatically converts them to each platform’s native format before sending:Delivery hooks
Comis supports two delivery lifecycle hooks that plugins can register to intercept or observe outbound messages. These hooks run as part of the delivery pipeline — before the message is formatted and sent, and after delivery completes.Hook lifecycle
Every outbound message passes through the hooks in this order:before_delivery (modifying) — Fires before each outbound message is
formatted and sent. Your handler receives the raw text and can modify it or
cancel delivery entirely. Handlers run sequentially in priority order (higher
priority first), and results are merged using last-writer-wins for each field.
after_delivery (void) — Fires after delivery completes, whether it
succeeded or failed. Handlers run in parallel, fire-and-forget. Errors in
after_delivery hooks are caught and logged without affecting the response.
Cancelling delivery
If abefore_delivery handler returns { cancel: true }, the message is not
sent. A delivery:hook_cancelled event is emitted so observability subscribers
can track cancellations. The cancellation reason (if provided) is included in
the event and logged at INFO level.
Registering hooks
Register delivery hooks through the plugin system. Each plugin callsregisterHook during its register() method:
PluginPort interface, and
registration lifecycle.
Use cases
- Content moderation — Filter or redact sensitive content before sending (PII, profanity, secrets)
- Compliance logging — Create an audit trail of all outbound messages with timestamps, channels, and agent identifiers
- Analytics — Track delivery latency, success rates, and message volumes per channel
- Session mirroring — Record outbound messages so the agent remembers what it sent (see Session Mirroring below)
Hook result fields
Thebefore_delivery handler can return any combination of these fields:
Return values are validated against a Zod schema before merging. Invalid returns
are logged and ignored, protecting the pipeline from malformed hook output.
Session mirroring
Agents remember what they sent. After a message is delivered to a chat channel, the delivered text is recorded to thedelivery_mirror SQLite table. On the
next inbound user message, pending mirror entries are injected as synthetic
assistant context in the dynamic preamble so the agent has continuity of what it
previously said — even after a daemon restart.
Session mirroring is powered by the
after_delivery hook. It runs automatically
when enabled and requires no plugin installation — it is a built-in daemon
subsystem.Recording flow
When the agent sends a message, the mirror records it in four steps:- Message is delivered to the channel via
deliverToChannel() - The
after_deliveryhook fires (void, fire-and-forget) - The built-in
comis:delivery-mirrorplugin handler records the entry to thedelivery_mirrortable - An idempotency key (session + text hash + 1-second time bucket) prevents
duplicates via
INSERT OR IGNORE
Injection flow
When the user sends their next message, mirror entries are injected into the agent prompt:- User sends a message, agent execution starts
assembleExecutionPrompt()callsdeliveryMirror.pending(sessionKey)to fetch unacknowledged entries- Pending entries are formatted as “Your Recent Outbound Messages” in the dynamic preamble section of the prompt
- Budget caps are applied (max entries, then max characters)
- Injected entries are acknowledged so they will not appear again
Configuration
All session mirroring settings live under thedeliveryMirror key in your
config file:
Lifecycle
Each mirror entry follows this lifecycle:- Record — The
after_deliveryhook writes the entry with statuspending - Pending — The entry waits in the
delivery_mirrortable until the next agent execution for that session - Inject —
assembleExecutionPrompt()fetches pending entries, applies budget caps, and includes them in the prompt - Acknowledge — Injected entries are marked as
acknowledgedso they are not injected again - Prune — A periodic timer deletes entries older than
retentionMs, regardless of status
pending and
will be included in the next prompt (if they still fit the budget).
Crash resilience
Mirror entries are stored in SQLite with WAL mode. If the daemon crashes or restarts:- Unacknowledged entries survive. On the next inbound message after restart, the agent will see its previous outbound messages in the prompt, maintaining context continuity.
- The prune timer restarts automatically. Old entries are cleaned up on the regular schedule after the daemon comes back up.
- No manual intervention is needed. The mirror subsystem initializes during daemon boot and resumes from the persisted state.
Reply modes
By default, Comis replies to the triggering message on the first chunk of each response in group chats. This threads the conversation so users can follow which response belongs to which question. Reply modes let you control this threading behavior per channel and per chat type.Available modes
Configuration
Reply modes can be set at three levels, from broadest to most specific. Global default — Applies to all channels unless overridden:Resolution order
Comis resolves the reply mode using a three-level chain. The most specific match wins:replyModeByChatType.forum = "all". If there were
no forum entry, it would fall back to the channel-level replyMode of "first",
then to defaultReplyMode, and finally to the hardcoded default of "first".
System messages
System messages (compaction notices, system announcements, internal notifications) always thread regardless of the reply mode setting. This prevents system messages from appearing as standalone messages in group conversations where they could be confusing without context.Chat types
The following normalized chat types can be used as keys inreplyModeByChatType.
Each platform’s native conversation types are mapped to these categories
automatically:
Full configuration reference
Here is a complete example showing all delivery-related options:perChannel
section. Options not specified in perChannel fall back to the defaults above.
All Channels
Compare all 10 supported platforms side by side.
Plugins
Hook registration and plugin development.
Configuration Reference
Full configuration options for every Comis setting.
Troubleshooting
Common issues and how to fix them.
