LemonChannels

Channel adapter layer for the Lemon AI assistant platform. Provides a pluggable adapter system for external messaging platforms (Telegram, Discord, X/Twitter, XMTP), a router-facing semantic delivery dispatcher, channel-owned presentation state, a reliable outbox delivery queue with retry, chunking, deduplication, and rate limiting, and inbound message normalization that submits canonical LemonCore.RunRequest structs to the router.

This app depends only on lemon_core (in-umbrella), plus jason, earmark_parser, req, and nostrum (runtime: false).

Architecture Overview

Inbound Path
+--------------------------------------+
| |
[Platform] | Transport -> normalize_inbound() |
(Telegram, | (polling/ -> RunRequest | -> LemonRouter
Discord, | webhook) |
etc.) | |
+--------------------------------------+
Semantic Outbound Path
+--------------------------------------+
| |
[Router] ->| Dispatcher -> Renderer -> |
| PresentationState -> Outbox |
| |
| -> deliver() | -> [Platform]
+--------------------------------------+
Direct Outbound Path
+--------------------------------------+
| |
[Caller] ->| Outbox -> Chunker -> Dedupe -> |
| (queue) (split) (ETS) |
| |
| -> RateLimiter -> deliver() | -> [Platform]
| (token bucket) |
+--------------------------------------+

Inbound: Each adapter's transport receives raw events from the external platform, normalizes them into LemonCore.InboundMessage structs locally, converts them with the internal LemonChannels.RunRequestBuilder, and submits only LemonCore.RunRequest structs to the session engine through LemonCore.RouterBridge.submit_run/1.

Channel adapters also use LemonCore.RouterBridge for busy-session and active-run queries. They must not read router-internal session registries or read models directly.

Semantic outbound: Router emits LemonCore.DeliveryIntent values into LemonChannels.Dispatcher. Channel renderers decide truncation, send-vs-edit, buttons, media batching, and other platform UX details, while LemonChannels.PresentationState tracks message ids, pending creates/edits, deferred chunk sets, and post-edit follow-up chunks per {route, run, surface} so coalesced Telegram and Discord updates do not lose overflow chunks, leak superseded tails, detach long-answer follow-up chunks from the original prompt thread, enqueue long-answer tails before the final edit ack, or strand those tails or deferred final edits if the ack arrives before they finish staging. Discord streaming snapshots are truncated to one editable message; finalized Discord text is split at the safe 1,900-character outbound size and delivered as an edit plus ordered follow-ups, and repeated identical finals are suppressed when a newer sequence replays the same answer. Final idempotency includes auto-send file metadata so a later final with the same text but newly attached files still delivers those attachments.

Direct outbound: Adapter helpers and other low-level callers may still enqueue OutboundPayload structs into the Outbox. The Outbox applies chunking (splitting long messages at sentence/word boundaries), deduplication (idempotency keys with a 1-hour TTL), and rate limiting (token bucket per channel/account). Messages are then delivered via the adapter's deliver/1 callback with exponential-backoff retry on transient failures.

Script send: LemonChannels.ScriptSend gives shell scripts, cron jobs, and CI a Hermes-style notification path for the promoted Telegram and Discord platforms. It builds direct text or file OutboundPayload structs and calls the platform outbound adapter directly without starting inbound transports.

Delivery Groups

The Outbox preserves FIFO ordering within each "delivery group" while allowing full concurrency across independent groups. A delivery group is identified by the tuple {channel_id, account_id, peer.kind, peer.id, peer.thread_id}. When a long message is chunked into multiple parts, all chunks share the same group and are delivered sequentially to prevent reordering.

Supervision Tree

LemonChannels.Application
+-- LemonChannels.Registry (GenServer - plugin registry)
+-- LemonChannels.PresentationState (GenServer - message-id/send-vs-edit state)
+-- LemonChannels.Outbox.RateLimiter (GenServer - token bucket rate limiter)
+-- LemonChannels.Outbox.Dedupe (GenServer - ETS-based deduplication)
+-- Outbox.WorkerSupervisor (Task.Supervisor)
+-- LemonChannels.Outbox (GenServer - delivery queue)
+-- LemonChannels.AdapterSupervisor (DynamicSupervisor)
+-- Telegram.Supervisor (if configured)
| +-- Telegram.AsyncSupervisor (Task.Supervisor)
| +-- Telegram.Transport (GenServer - long-polling)
+-- Discord.Supervisor (if configured)
| +-- Discord.Transport (Nostrum consumer)
+-- XApi.TokenManager (if configured, GenServer from apps/x_api)
+-- XMTP.Transport (if configured, GenServer + Port)

Adapters are selected from config :lemon_channels, :adapters during application boot. Each adapter runs under the AdapterSupervisor DynamicSupervisor; adapter modules may still no-op internally when credentials or transport-specific enablement are absent.

config :lemon_channels,
adapters: [
LemonChannels.Adapters.Telegram,
LemonChannels.Adapters.Discord,
LemonChannels.Adapters.Xmtp
]

Plugin System

All channel adapters implement the LemonChannels.Plugin behaviour, which defines six callbacks:

CallbackReturnPurpose
id/0String.t()Unique channel identifier (e.g. "telegram", "discord")
meta/0map()Label, capabilities map, and docs URL
child_spec/1Supervisor.child_spec()OTP child spec for the adapter's supervision subtree
normalize_inbound/1{:ok, InboundMessage.t()} | {:error, term()}Convert raw platform data to normalized inbound message
deliver/1{:ok, term()} | {:error, term()}Deliver an OutboundPayload to the external platform
gateway_methods/0[map()]Control plane methods exposed through the gateway
defmodule MyAdapter do
@behaviour LemonChannels.Plugin
@impl true
def id, do: "my_adapter"
@impl true
def meta do
%{
label: "My Adapter",
capabilities: %{
chunk_limit: 4096,
rate_limit: 30,
edit_support: true,
delete_support: false,
voice_support: false,
image_support: false,
file_support: false,
reaction_support: false,
thread_support: false
},
docs: "https://example.com/docs"
}
end
@impl true
def child_spec(opts) do
%{id: __MODULE__, start: {MyAdapter.Supervisor, :start_link, [opts]}, type: :supervisor}
end
@impl true
def normalize_inbound(raw), do: {:ok, %LemonCore.InboundMessage{...}}
@impl true
def deliver(payload), do: {:ok, delivery_ref}
@impl true
def gateway_methods, do: []
end

Capabilities

Each adapter declares its capabilities in meta/0. The LemonChannels.Capabilities module defines:

FieldTypeDefaultDescription
chunk_limitinteger4096Maximum characters per message
rate_limitinteger or nilnilMessages per time window
edit_supportbooleanfalseCan edit sent messages
delete_supportbooleanfalseCan delete sent messages
voice_supportbooleanfalseCan handle voice/audio
image_supportbooleanfalseCan send/receive images
file_supportbooleanfalseCan send/receive files
reaction_supportbooleanfalseCan add reactions
thread_supportbooleanfalseSupports threaded conversations

Registry

The LemonChannels.Registry GenServer manages adapter registration and lookup:

# Register / unregister
LemonChannels.Registry.register(MyAdapter)
LemonChannels.Registry.unregister("my_adapter")
LemonChannels.Registry.logout("telegram") # stop + unregister
# Lookup
LemonChannels.Registry.get_plugin("telegram") # module | nil
LemonChannels.Registry.get_meta("telegram") # meta map | nil
LemonChannels.Registry.get_capabilities("telegram") # capabilities map | nil
# Status
LemonChannels.Registry.list_plugins() # [module()]
LemonChannels.Registry.list() # [{channel_id, info_map}]
LemonChannels.Registry.status() # %{configured: [...], connected: [...]}

Adapter runtime status (running/stopped and connected) is derived from live AdapterSupervisor children by matching each plugin's child start module.

Top-Level Facade

The LemonChannels module delegates to the Registry and Outbox:

LemonChannels.get_plugin("telegram") # Registry.get_plugin/1
LemonChannels.list_plugins() # Registry.list_plugins/0
LemonChannels.enqueue(payload) # Outbox.enqueue/1

Script Notifications

Use mix lemon.send or the source wrapper ./bin/lemon send to send a text notification or up to 10 file attachments from scripts:

./bin/lemon send --to telegram:<chat_id> "deploy finished"
echo "RAM 92%" | ./bin/lemon send --to telegram:<chat_id>
./bin/lemon send --to telegram:<chat_id>:<thread_id> --subject "[CI]" --file report.txt
./bin/lemon send --to discord:<channel_id> "deploy finished"
./bin/lemon send --to discord:#ops "deploy finished"
./bin/lemon send --to discord:#ops:deploys "deploy finished"
./bin/lemon send --account work --to discord:#ops "deploy finished"
./bin/lemon send --to discord:#ops --thread deploys "deploy finished"
./bin/lemon send --to discord:#ops --reply-to 123456789 "deploy finished"
./bin/lemon send --to telegram:<chat_id> --attach report.txt --attach trace.log "deploy report"
./bin/lemon send --dry-run --to discord:#ops --attach report.txt "validate only"
./bin/lemon send --list --json
./bin/lemon send --list telegram
./bin/lemon send --account work --list telegram

Target forms are telegram:<chat_id>[:thread_id] and discord:<channel_id>[:thread_id]. Platform-only targets use LEMON_TELEGRAM_DEFAULT_CHAT_ID, LEMON_DISCORD_DEFAULT_CHANNEL_ID, and optional LEMON_TELEGRAM_DEFAULT_THREAD_ID / LEMON_DISCORD_DEFAULT_THREAD_ID first, then durable config fallbacks from [gateway.telegram] default_chat_id, default_thread_id, default_topic_id, [gateway.discord] default_channel_id, and default_thread_id. Default account ids use LEMON_TELEGRAM_DEFAULT_ACCOUNT_ID / LEMON_DISCORD_DEFAULT_ACCOUNT_ID first, then [gateway.telegram] default_account_id and [gateway.discord] default_account_id, so named-target resolution can be account-scoped without repeating --account. --thread <id-or-name> and Telegram-friendly --topic <id-or-name> set the thread/topic separately from --to; specifying a thread in both places fails as a usage error. --reply-to <message-id> sets OutboundPayload.reply_to so Telegram/Discord adapters can reply under an existing platform message. --account <id> selects the channel account for outbound payloads and scopes known-target listing/name resolution so duplicate names in other bot/workspace accounts do not make a valid account-scoped target ambiguous. List mode reports env/config defaults plus the recent Telegram/Discord known-target windows — Telegram chats/topics captured in LemonChannels.Telegram.KnownTargetStore and Discord channels/threads captured in LemonChannels.Discord.KnownTargetStore — as bounded known_targets metadata with known_target_count, known_targets_truncated, and exact reusable aliases for named targets. Telegram can resolve unique known names with telegram:#chat, telegram:@username, telegram:#chat:topic-name, or telegram:<chat_id>:topic-name; Discord can resolve unique known names with discord:#channel, discord:#channel:thread-name, or discord:<channel_id>:thread-name; missing or ambiguous names fail as usage errors. --dry-run validates target parsing, known-name/default resolution, body/caption resolution, and attachment metadata without sending or requiring Telegram/Discord credentials. --file reads the message body from a file and does not upload an attachment. --attach uploads a local file through the existing Telegram/Discord file-delivery adapters; repeated --attach uploads up to 10 files, using positional text, --file, or stdin as the caption. Use --file - to force stdin. --json returns bounded delivery metadata, including dry_run, message_id, extra_message_ids, attachment_filename, attachment_filenames, attachment_count, and attachment_bytes when available, but not the raw message body, raw attachment paths, or full platform response. Batch Telegram file sends preserve the first delivered id as message_id and remaining ids as extra_message_ids. Exit code 0 means send/list/help succeeded, 1 means platform delivery failed, and 2 means usage, argument, or local config/input failed.

Semantic Rendering

LemonChannels.Dispatcher is the router-facing entrypoint for semantic delivery. It selects a renderer from DeliveryIntent.route.channel_id, lets that renderer turn semantic output into platform operations, and relies on LemonChannels.PresentationState to keep platform message-id tracking inside lemon_channels.

This ownership boundary matters: channels own truncation, send-vs-edit behavior, tool-status rendering, file/media batching, and Telegram message-id indices. Rendering should be a projection of canonical events plus channel capabilities. Router owns run semantics, session defaults, and prompt rewriting. In particular, pending compaction prompt mutation happens in the router submission path, not in channel transports.

Outbox Pipeline

The Outbox (LemonChannels.Outbox) is a GenServer-based delivery queue.

Enqueue

alias LemonChannels.{OutboundPayload, Outbox}
payload = OutboundPayload.text(
"telegram", # channel_id
"default", # account_id
%{kind: :dm, id: "123456", thread_id: nil}, # peer
"Hello, world!", # content
idempotency_key: "msg-123"
)
{:ok, ref} = Outbox.enqueue(payload)

Payload Kinds

KindConstructorDescription
:textOutboundPayload.text/5Send a new text message
:editOutboundPayload.edit/6Edit an existing message
:delete--Delete a message
:reaction--Add a reaction
:file--Send a file
:voice--Send a voice message

Delivery Acknowledgment

Set notify_pid and notify_ref on a payload to receive a message when delivery completes or fails:

ref = make_ref()
payload = %{payload | notify_pid: self(), notify_ref: ref}
{:ok, _} = Outbox.enqueue(payload)
receive do
{:outbox_delivered, ^ref, result} -> result
end

Subcomponents

ModulePurpose
Outbox.ChunkerSplits long messages at sentence/word boundaries respecting per-channel chunk_limit
Outbox.DedupeETS-based deduplication using idempotency keys with a 1-hour TTL and periodic cleanup
Outbox.RateLimiterToken bucket algorithm (GenServer), per channel/account, default 30 msg/sec with burst of 5

Retry Behavior

Stats

Outbox.stats()
# %{queue_length: 0, processing_count: 0, queue_depth: 0, max_queue_size: 5000, enqueued_total: 42}

Rate Limit Status

LemonChannels.Outbox.RateLimiter.status("telegram", "default")
# %{tokens: 28.5, rate: 30, burst: 5}
LemonChannels.Outbox.RateLimiter.check("telegram", "default")
# :ok | {:rate_limited, wait_ms}

Supported Channels

Telegram

Plugin ID: "telegram" | Chunk limit: 4096 | Rate limit: 30 msg/sec

The most mature adapter. Supports edit, delete, voice, images, files, reactions, and threads.

Module Layout

Adapter modules (lib/lemon_channels/adapters/telegram/):

ModulePurpose
Telegram (plugin)Plugin behaviour implementation, id/meta/child_spec/deliver
Telegram.SupervisorStarts AsyncSupervisor (Task.Supervisor) and Transport
Telegram.TransportLong-polling GenServer via getUpdates, command handling, inbound routing
Telegram.Transport.ApprovalRequestApproval-request rendering and callback payload helpers for exec approvals
Telegram.Transport.CallbackHandlerInline keyboard callback handling for approvals and model-picker flows
Telegram.Transport.ChatPreferencesTrigger gating plus /trigger, /thinking, and /cwd command handling
Telegram.Transport.PollerPoll loop and update dispatch extracted from Transport
Telegram.Transport.CommandRouterCommand/message decision tree extracted from Transport
Telegram.Transport.CommandsPure functions for command detection, scope keys, message joining
Telegram.Transport.FileOperations/file put/get commands, auto-put for document uploads, media group file handling
Telegram.Transport.InboundActionsRouter submission path for normal inbound messages, including progress reactions and session metadata
Telegram.Transport.InboundContextNormalized transport event context shared across normalize/pipeline/action-runner
Telegram.Transport.MediaGroupsMedia group coalescence with debounce timer
Telegram.Transport.MemoryReflection/new memory-reflection transcript assembly and prompt generation
Telegram.Transport.MessageBufferDebounce buffering for rapid-fire user messages
Telegram.Transport.ModelPicker/model picker flow, provider/model pagination, and selection-state transitions
Telegram.Transport.NormalizeRaw update/timer normalization into Telegram-local inbound context
Telegram.Transport.PerChatStateTelegram per-thread state, generation bookkeeping, and last-engine helpers
Telegram.Transport.PipelineTelegram-local ingress coordinator for normalized events and emitted actions
Telegram.Transport.RuntimeStateHelper for adapter-owned runtime state without a dedicated struct migration
Telegram.Transport.SessionRoutingSession-key derivation, reply routing, and parallel-session bookkeeping
Telegram.Transport.TopicCommand/topic command handler extracted from the transport shell
Telegram.Transport.UpdateProcessorAuthorization, dedup, routing pipeline, known-target indexing, engine directive parsing
Telegram.Transport.VoiceHandlerVoice-download and transcription orchestration before normal inbound routing
Telegram.RendererTelegram semantic renderer for stream snapshots, finals, edits, and presentation-state-aware delivery
Telegram.StatusRendererTelegram-specific tool-status formatting and controls rendering
Telegram.FileBatcherTelegram media/file batching strategy for renderer-owned outbound UX
Telegram.InboundNormalizes raw Telegram updates to InboundMessage
Telegram.OutboundDelivers via Bot API with retry for rate limits and transient errors
Telegram.VoiceTranscriberOpenAI-compatible audio transcription

Support modules (lib/lemon_channels/telegram/):

ModulePurpose
Telegram.APIRaw Bot API calls: send_message, edit_message_text, get_updates, send_document, send_photo, send_media_group, etc.
Telegram.DeliveryHigh-level enqueue helpers: enqueue_send/3, enqueue_edit/3 backed by Outbox
Telegram.FormatterConverts markdown to plain text + Telegram entities (avoids fragile MarkdownV2 escaping)
Telegram.MarkdownEarmarkParser-based AST renderer producing Telegram entity format with UTF-16 offsets
Telegram.ResumeIndexStoreTyped wrapper for Telegram message-id resume/session indices
Telegram.StateStoreTyped wrapper for Telegram per-session/per-topic preference state
Telegram.TruncateTruncates messages to 4096 chars preserving resume lines
Telegram.TriggerModePer-chat/topic :all vs :mentions mode (ETS-backed)
Telegram.OffsetStorePersists getUpdates offset via LemonCore.Store
Telegram.PollerLockGlobal + file-based lock to prevent duplicate pollers for the same account/token
Telegram.TransportSharedShared deduplication helpers across transport modules

Transport Commands

CommandDescription
/newStart a new session (acknowledges immediately, cleans up async)
/resumeResume a previous session
/modelInteractive provider/model picker via reply keyboard
/goalPreview durable goal status/set with optional max-continuation budget/pause/resume/continue/loop controls, opt-in auto loop scheduling, and clear for the current session
/kanbanPreview durable kanban board/task/archive/dispatcher controls with redacted board/task output
/checkpointPreview checkpoint status with redacted lifecycle event counts, redacted event history, redacted diff count, pushed active-run checkpoint event notices, and restore via /checkpoint restore <id> confirm
/rollbackHermes-style alias for preview checkpoint rollback, including /rollback diff <id> and /rollback <id> confirm
/mediaPreview generated-media job status with redacted type/status/artifact counts and cleanup policy

The Telegram picker should only surface models that are healthy enough for the current transport UX. Known-bad variants may remain in provider registries for manual use, but the picker should filter them out and prefer task-capable Google preview variants over dead or weaker direct IDs. | /thinking | Toggle extended thinking | | /trigger | Switch between :all and :mentions mode | | /cwd | Set working directory | | /file | File put/get operations | | /topic | Topic management | | /cancel | Cancel the current run |

Generation-Scoped Indexing

Session and resume indices are scoped by a generation counter. /new increments the generation for {account_id, chat_id, thread_id}, instantly invalidating stale reply mappings without full-table scans.

/model Picker Behavior

/model uses a reply keyboard (bottom keyboard) flow for per-user selection in a chat/topic: provider -> model -> scope (This session or All future sessions). This session writes the current session override; All future sessions writes a chat-wide default even when invoked from a topic. Selection messages are intercepted by transport state and are not routed as normal inbound prompts. Provider/model lists are paginated in-keyboard with << Prev / Next >>, plus < Back / Close. The model step accepts either the exact button label or raw model ids such as gpt-5.4 and provider:model_id.

Ownership Note

Telegram transport owns Telegram-specific command UX, buffering, auth, and routing preparation. It does not own pending-compaction prompt mutation; router is the sole owner of rewriting the next inbound prompt for compaction recovery.

Delivery Helpers

alias LemonChannels.Telegram.Delivery
Delivery.enqueue_send(chat_id, "Hello", thread_id: topic_id)
# With delivery notification
ref = make_ref()
Delivery.enqueue_send(chat_id, "Hello", notify: {self(), ref})
receive do
{:outbox_delivered, ^ref, result} -> result
end

Formatter

Avoids fragile MarkdownV2 escaping -- renders markdown to plain text with Telegram entities:

{text, opts} = LemonChannels.Telegram.Formatter.prepare_for_telegram(markdown_string)
# opts is nil or %{entities: [...]} suitable for Telegram.API.send_message/4

TriggerMode

Controls whether the bot responds to all messages or only mentions in a chat/topic:

scope = %LemonCore.ChatScope{transport: :telegram, chat_id: 123, topic_id: 456}
LemonChannels.Telegram.TriggerMode.set(scope, account_id, :mentions)
%{mode: :mentions, source: :topic} =
LemonChannels.Telegram.TriggerMode.resolve(account_id, chat_id, topic_id)

Voice Transcription

Configured via transport config. Uses OpenAI-compatible API:

LemonChannels.Adapters.Telegram.VoiceTranscriber.transcribe(%{
audio_bytes: binary,
api_key: key,
base_url: "https://api.openai.com/v1",
model: "gpt-4o-mini-transcribe",
mime_type: "audio/ogg"
})

Configuration

Add LemonChannels.Adapters.Telegram to config :lemon_channels, :adapters. Required: Telegram bot token (via LemonCore.Secrets or env vars).

Discord

Plugin ID: "discord" | Chunk limit: 2000

Supports edit, delete, images, files, and threads.

Module Layout

ModulePurpose
Discord (plugin)Plugin behaviour implementation
Discord.SupervisorStarts transport if bot_token is configured
Discord.TransportNostrum consumer, slash command handling, component interactions, and inbound RunRequest submission
Discord.InboundNormalizes Discord message events to InboundMessage, handles attachments
Discord.RendererSemantic renderer for status controls, thread-aware replies, and finalize-time auto-send files
Discord.StatusRendererBuilds Discord button rows for cancel/keep-waiting UX
Discord.OutboundDelivers via Nostrum.Api.Message (create, edit, delete, reaction, multipart file upload)

Slash Commands

Delivery Notes

Configuration

Add LemonChannels.Adapters.Discord to config :lemon_channels, :adapters. Required: Discord bot token. Uses the nostrum library (declared as runtime: false dep; runtime availability is expected from the deployment environment).

X (Twitter) API

Plugin ID: "x_api" | Chunk limit: 280 | Rate limit: 2400/day

Supports edit, delete, images, threads, mentions, and read-only recent public search. Primarily outbound (posting tweets). Uses X API v2.

Module Layout

ModulePurpose
LemonChannels.Adapters.XAPIPlugin behaviour and outbound payload delivery
LemonChannels.Adapters.XAPI.GatewayMethodsControl plane methods: x_api.post_tweet, x_api.get_mentions, x_api.reply_to_tweet
XApiReusable X API config, auth detection, HTTP client, OAuth helpers, and token manager in apps/x_api

Authentication

The adapter supports two auth methods and auto-detects which to use:

Read-only search: X_API_BEARER_TOKEN is enough for x_search.

OAuth 2.0: X_API_CLIENT_ID, X_API_CLIENT_SECRET, X_API_ACCESS_TOKEN, X_API_REFRESH_TOKEN, X_API_BEARER_TOKEN

OAuth 1.0a: X_API_CONSUMER_KEY, X_API_CONSUMER_SECRET, X_API_ACCESS_TOKEN, X_API_ACCESS_TOKEN_SECRET

Common: X_DEFAULT_ACCOUNT_ID, X_DEFAULT_ACCOUNT_USERNAME

Config can be set via config :x_api, XApi. Existing config :lemon_channels, LemonChannels.Adapters.XAPI settings remain supported as a compatibility fallback. Secrets are resolved through LemonCore.Secrets by default.

XMTP

Plugin ID: "xmtp" | Chunk limit: 2000

Web3 messaging adapter. Supports threads only (no edit, delete, voice, images, files, or reactions).

Module Layout

ModulePurpose
XMTP (plugin)Plugin behaviour implementation
XMTP.TransportGenServer for message send/receive, normalize_inbound_message/1, deliver/1
XMTP.BridgeCommunication with the Node.js bridge (connect, poll, send_message)
XMTP.PortServerPort process management for the Node.js bridge subprocess

XMTP uses a Node.js bridge process managed via an Erlang Port. The bridge handles the XMTP protocol specifics while the Elixir side manages lifecycle, message normalization, and delivery through the standard plugin interface.

Configuration

Add LemonChannels.Adapters.Xmtp to config :lemon_channels, :adapters. The XMTP transport still checks enable_xmtp: true before starting its bridge.

Adding a New Channel Adapter

  1. Create the adapter module in lib/lemon_channels/adapters/my_channel.ex implementing all 6 LemonChannels.Plugin callbacks.

  2. Create the supervisor and transport if the adapter needs to receive inbound messages (polling or webhook).

  3. Configure the adapter in config :lemon_channels, :adapters:

config :lemon_channels,
adapters: [
LemonChannels.Adapters.MyChannel
]
  1. Add configuration in gateway config for adapter-specific settings.

  2. Add tests following the existing adapter test patterns in test/lemon_channels/adapters/.

Configuration

Gateway Config

Adapter module startup is selected by config :lemon_channels, :adapters. Runtime gateway settings still come from LemonChannels.GatewayConfig, a thin delegation to LemonCore.GatewayConfig.

Common gateway config keys:

KeyTypeDescription
enable_xmtpbooleanEnable/disable the XMTP bridge after the adapter is configured
default_enginestringDefault session engine
bindingslistChat scope to project/engine/agent bindings
projectsmapProject definitions

Binding Resolution

LemonChannels.BindingResolver maps chat scopes to projects, engines, agents, and working directories. It delegates to LemonCore.BindingResolver after converting channels-local structs to core types.

scope = %LemonCore.ChatScope{transport: :telegram, chat_id: 123, topic_id: 456}
binding = LemonChannels.BindingResolver.resolve_binding(scope)
# %Binding{project: "my_project", agent_id: "coder", default_engine: "claude", ...}
engine = LemonChannels.BindingResolver.resolve_engine(scope, engine_hint, resume)
agent = LemonChannels.BindingResolver.resolve_agent_id(scope)
cwd = LemonChannels.BindingResolver.resolve_cwd(scope)
mode = LemonChannels.BindingResolver.resolve_queue_mode(scope)

Engine resolution priority: resume token > engine hint > binding default > project default > global default.

Engine Registry

LemonCore.EngineCatalog is the shared validation/normalization boundary for known engine IDs. LemonChannels.EngineRegistry remains only as a compatibility shim for resume parsing that may defer to LemonGateway.EngineRegistry when custom engine modules are present.

LemonCore.EngineCatalog.known?("claude") # true
LemonCore.EngineCatalog.normalize(" Claude ") # "claude"
{:ok, %ResumeToken{engine: "claude", value: "abc123"}} =
LemonChannels.EngineRegistry.extract_resume("claude --resume abc123")
LemonCore.ResumeToken.format_plain(%ResumeToken{engine: "claude", value: "abc123"})
# "claude --resume abc123"

Default known engines: lemon, echo, codex, claude, droid, opencode, pi, kimi. Override the shared list via config :lemon_core, :known_engines.

Runtime Bridge

The internal LemonChannels.Runtime module provides thin wrappers to interact with router-owned run lifecycle APIs without a hard compile-time dependency. Busy checks go through LemonCore.RouterBridge.session_busy?/1 rather than reaching into router internals directly:

LemonChannels.Runtime.cancel_session(session_key)
LemonChannels.Runtime.cancel_by_run_id(run_id)
LemonChannels.Runtime.cancel_by_progress_msg(session_key, progress_msg_id)
LemonChannels.Runtime.keep_run_alive(run_id, :continue | :cancel)
LemonChannels.Runtime.session_busy?(session_key)

Application Lifecycle

# Register and start an adapter (idempotent)
LemonChannels.Application.register_and_start_adapter(MyAdapter)
LemonChannels.Application.register_and_start_adapter(MyAdapter, opts)
# Start/stop without re-registering
LemonChannels.Application.start_adapter(MyAdapter)
LemonChannels.Application.stop_adapter(MyAdapter)

Adapters run under LemonChannels.AdapterSupervisor (DynamicSupervisor).

Telemetry Events

EventMeasurementsMetadata
[:lemon, :channels, :deliver, :start]%{system_time: ...}channel_id, account_id, chunk_index
[:lemon, :channels, :deliver, :stop]%{duration: ...}channel_id, account_id, ok
[:lemon, :channels, :deliver, :exception]%{duration: ...}channel_id, kind, reason, stacktrace
[:lemon, :channels, :outbox, :queue]%{depth: ..., count: 1}event, chunk_count
[:lemon, :channels, :outbox, :rejected]%{count: 1, queue_depth: ...}reason, channel_id
[:lemon, :channels, :inbound]%{count: 1}channel_id

Module Inventory

Top-Level

ModulePurpose
LemonChannelsPublic API: get_plugin/1, list_plugins/0, enqueue/1
LemonChannels.ApplicationOTP application, supervision tree, adapter lifecycle
LemonChannels.DispatcherRouter-facing semantic delivery entrypoint
LemonChannels.PluginBehaviour definition (6 callbacks)
LemonChannels.RegistryGenServer plugin registry with status tracking
LemonChannels.CapabilitiesCapability type definitions and defaults
LemonChannels.PresentationStateChannels-owned message-id/send-vs-edit state per {route, run, surface}
LemonChannels.OutboundPayloadCore delivery struct with constructors
LemonChannels.BindingResolverChat scope to binding resolution (delegates to LemonCore)
LemonChannels.EngineRegistryCompatibility resume-token parsing shim for custom gateway engines
LemonChannels.GatewayConfigThin delegation to LemonCore.GatewayConfig
LemonChannels.Runtime (internal)Runtime bridge for session/run cancel, keepalive, and busy checks via LemonCore.RouterBridge
LemonChannels.Cwd (internal)Working directory resolution
LemonChannels.TypesChatScope and other shared type definitions

Outbox

ModulePurpose
LemonChannels.OutboxGenServer delivery queue with retry
LemonChannels.Outbox.ChunkerMessage splitting at sentence/word boundaries
LemonChannels.Outbox.DedupeETS-based deduplication (1h TTL)
LemonChannels.Outbox.RateLimiterToken bucket rate limiter

Telegram

ModulePurpose
Adapters.TelegramPlugin behaviour implementation
Adapters.Telegram.SupervisorStarts AsyncSupervisor and Transport
Adapters.Telegram.TransportLong-polling GenServer, command handling
Adapters.Telegram.Transport.PollerPoll loop + update dispatch
Adapters.Telegram.Transport.CommandRouterCommand/message routing tree
Adapters.Telegram.Transport.CommandsCommand detection, scope keys
Adapters.Telegram.Transport.FileOperationsFile put/get, document uploads
Adapters.Telegram.Transport.MediaGroupsMedia group coalescence
Adapters.Telegram.Transport.MessageBufferDebounce buffering
Adapters.Telegram.RendererTelegram semantic renderer for send-vs-edit delivery
Adapters.Telegram.StatusRendererTelegram tool-status controls and text rendering
Adapters.Telegram.FileBatcherTelegram-specific file/media batching
Adapters.Telegram.Transport.UpdateProcessorAuth, dedup, routing pipeline
Adapters.Telegram.InboundNormalize to InboundMessage
Adapters.Telegram.OutboundDeliver via Bot API
Adapters.Telegram.VoiceTranscriberAudio transcription
Telegram.APIRaw Bot API calls
Telegram.DeliveryHigh-level enqueue helpers
Telegram.FormatterMarkdown to entities
Telegram.KnownTargetStoreTyped wrapper for Telegram known-target chat/topic metadata
Telegram.MarkdownAST renderer for entities
Telegram.TruncateMessage truncation
Telegram.TriggerModePer-chat trigger mode
Telegram.OffsetStoregetUpdates offset persistence
Telegram.PollerLockDuplicate poller prevention
Telegram.TransportSharedShared dedupe helpers

Discord

ModulePurpose
Adapters.DiscordPlugin behaviour implementation
Adapters.Discord.SupervisorStarts transport if configured
Adapters.Discord.TransportNostrum consumer, slash commands, component interactions, debounce buffering
Adapters.Discord.InboundNormalize to InboundMessage
Adapters.Discord.RendererSemantic delivery renderer
Adapters.Discord.StatusRendererButton/control rendering helpers
Adapters.Discord.OutboundDeliver via Nostrum API, including multipart file upload

X API

ModulePurpose
Adapters.XAPIPlugin behaviour and outbound payload delivery
Adapters.XAPI.GatewayMethodsControl plane methods
XApi.*Reusable X API client, OAuth helpers, and token manager in apps/x_api

XMTP

ModulePurpose
Adapters.XMTPPlugin behaviour implementation
XMTP.TransportMessage send/receive GenServer
XMTP.BridgeNode.js bridge communication
XMTP.PortServerPort process management

Testing

# All channel tests
mix test apps/lemon_channels
# Specific adapter tests
mix test apps/lemon_channels/test/lemon_channels/adapters/telegram
mix test apps/lemon_channels/test/lemon_channels/outbox_test.exs

Key Test Files

TestCoverage
outbox_test.exsQueue, retry, delivery
outbox_architecture_test.exsPer-group ordering, concurrency
outbox_retry_behavior_test.exsRetry logic, non-retryable errors
outbox_rate_limiting_test.exsRate limiter
outbox_chunking_test.exsChunking via outbox
chunker_test.exsChunker unit tests
dedupe_test.exsIdempotency
telegram/inbound_test.exsInbound normalization
telegram/outbound_test.exsOutbound delivery
telegram/voice_transcription_test.exsVoice transcription
telegram/delivery_test.exsDelivery helper
telegram/markdown_test.exsMarkdown to entities
telegram/transport_*_test.exsTransport behaviors (cancel, offset, auth, dedupe, parallel)
telegram/transport_topic_test.exs/topic command behavior
telegram/file_transfer_test.exsFile handling
media_status_message_test.exsTelegram /media command recognition and redacted status formatting
capabilities_test.exsIncludes X adapter capability lookup

Mock Adapter Pattern

defmodule TestAdapter do
@behaviour LemonChannels.Plugin
def id, do: "test"
def meta, do: %{label: "Test", capabilities: %{chunk_limit: 100}}
def child_spec(_), do: %{id: __MODULE__, start: {Task, :start_link, [fn -> :ok end]}}
def normalize_inbound(_), do: {:ok, %LemonCore.InboundMessage{}}
def deliver(_), do: {:ok, :sent}
def gateway_methods, do: []
end

Dependencies

DependencyVersionPurpose
lemon_corein_umbrellaShared primitives: InboundMessage, Store, Secrets, RouterBridge, Dedupe.Ets, Telemetry
jason~> 1.4JSON encoding/decoding
earmark_parser~> 1.4Markdown parsing (used by Telegram.Markdown for rendering to Telegram entities)
req~> 0.5.0HTTP client (used by Telegram API, X API, voice transcription)
nostrum~> 0.9Discord library (runtime: false -- expected from deployment environment)

Important Notes