LemonGateway
Multi-engine execution gateway for Elixir. It sits behind router-owned conversations and handles execution-slot scheduling, per-conversation launch isolation, session resumption, and streaming engine output via the event bus.
Part of the lemon Elixir umbrella project.
Architecture
+-----------------------------------------+
| Router / Explicit Legacy Ingress |
| Email SMS Voice Webhook |
+-------------------+---------------------+
|
ExecutionCommand
|
v
+-------------------+---------------------+
| LemonGateway.Runtime |
| submit_execution/1 -> ExecutionRequest |
+-------------------+---------------------+
|
v
+-------------------+---------------------+
| LemonGateway.Scheduler |
| slot allocation + conversation-key |
| routing from router-supplied requests |
+-------------------+---------------------+
|
slot_granted
|
v
+-------------------+---------------------+
| LemonGateway.ThreadWorker |
| trivial per-conversation launcher |
| (no queue semantics) |
+-------------------+---------------------+
|
RunSupervisor
.start_run
|
v
+-------------------+---------------------+
| LemonGateway.Run |
| engine lifecycle, bus events, |
| streaming deltas, steer/cancel |
+-------------------+---------------------+
|
Engine.start_run
|
v
+-------------------+---------------------+
| Engine (behaviour) |
| Lemon | Claude | Codex | Opencode | Pi |
+-------------------+---------------------+
|
events & deltas
|
v
+-------------------+---------------------+
| LemonCore.Bus |
| topic "run:<run_id>" |
| -> LemonRouter -> LemonChannels |
+-----------------------------------------+
Flow
- Router-owned
SessionCoordinatordecides queue semantics (collect,followup,steer,interrupt) and hands queue-semantic-free%LemonCore.ExecutionCommand{}values toLemonGateway.Runtime. - Gateway-owned transports that still live in this app submit
%LemonCore.RunRequest{}throughLemonCore.RouterBridge, not directly into gateway internals. - The Scheduler routes each execution request by the router-supplied
conversation_keyand allocates a concurrency slot. - The ThreadWorker is only a per-conversation launcher/slot waiter. It does not own product queue semantics.
- On slot grant, the worker starts a Run via
RunSupervisor. The Run acquiresEngineLock, resolves the engine, and callsEngine.start_run/3. - The Engine executes the AI request and streams lifecycle events and deltas back to the Run process.
- The Run broadcasts all events to
LemonCore.Buson topic"run:<run_id>". Router and channels consume those events and handle semantic output plus channel rendering. - On completion, the Run stores chat state for future auto-resume, releases the engine lock and scheduler slot, and finalizes its lifecycle.
Supported Engines
| Engine ID | Module | Runner | Steering | Description |
|---|---|---|---|---|
lemon | CodingAgent.GatewayEngine (registered by coding_agent) | CodingAgent.Session via CodingAgent.GatewayEngine.SessionRunner | Yes | Native Elixir engine with full CodingAgent tool support, session persistence, and mid-run steering. Absent in a runtime without coding_agent |
claude | Engines.Claude | LemonCliRunners.ClaudeRunner | No | Claude Code CLI wrapper via CliAdapter |
codex | Engines.Codex | LemonCliRunners.CodexRunner | No | OpenAI Codex CLI wrapper via CliAdapter |
droid | Engines.Droid | LemonCliRunners.DroidRunner | No | Factory Droid CLI wrapper via CliAdapter |
opencode | Engines.Opencode | LemonCliRunners.OpencodeRunner | No | Opencode CLI wrapper via CliAdapter |
pi | Engines.Pi | LemonCliRunners.PiRunner | No | Pi CLI wrapper via CliAdapter |
echo | Engines.Echo | (in-process Task) | No | Test/debug engine that echoes the prompt back |
Engine Abstraction
All engines implement the LemonGateway.Engine behaviour:
id/0-- unique lowercase string identifierstart_run/3-- starts the AI run, returns{:ok, run_ref, cancel_ctx}cancel/1-- cancels an active runsupports_steer?/0-- whether mid-run message injection is supportedsteer/2-- inject text into an active run (optional callback)format_resume/1,extract_resume/1,is_resume_line/1-- resume token serialization
CLI-based engines (Claude, Codex, Droid, Opencode, Pi) delegate to Engines.CliAdapter, which provides shared logic for subprocess management, event stream consumption, resume token formatting, and cancellation. The lemon engine is not part of this app: it lives in coding_agent as CodingAgent.GatewayEngine and registers itself through EngineRegistry.register/1 at boot.
Engine Selection Priority
- Resume token engine (from router-resolved auto-resume or explicit resume)
- Inline directive (
/claude,/codex,/lemon, etc. viaEngineDirective) - Binding
default_engine(topic-level, then chat-level) - Project
default_engine - Global
default_enginefrom config (default:"lemon")
Composite engine IDs like "claude:claude-3-opus" are resolved by prefix fallback to "claude".
Transports
| Transport | Module / Location | Description |
|---|---|---|
| Telegram | lemon_channels (external app) | Telegram Bot API polling/webhooks |
| Discord | lemon_channels (external app) | Discord gateway via Nostrum |
| XMTP | lemon_channels (external app) | XMTP messaging via Node.js bridge |
lemon_channels (external app) | SMTP outbound + inbound webhook, as a channel plugin | |
| Webhook | Transports.Webhook | Generic HTTP webhook (sync/async modes) |
| Voice | Voice.* | Real-time phone calls via Twilio + Deepgram STT + ElevenLabs TTS |
| SMS | Sms.* | Twilio SMS webhooks with verification code tools |
Gateway transports implement the LemonGateway.Transport behaviour (id/0, start_link/1). They are registered in TransportRegistry and started under TransportSupervisor only when gateway ingress is explicitly enabled with config :lemon_gateway, gateway_ingress_enabled: true. Telegram, Discord, XMTP and email are owned by the lemon_channels sibling app. Voice and SMS are not registry transports; they are dedicated Twilio support services included in the same explicit ingress startup.
Webhook, SMS, and voice are gateway-owned by design, not pending migration: LemonChannels.Plugin.deliver/1 is fire-and-forget, so it cannot serve webhook's synchronous response, SMS has no reply path at all, and voice needs a live bidirectional session. Email was the one surface that genuinely was a channel, and it moved to lemon_channels in phase 2.4 — LemonChannels.Adapters.Email. See docs/platform/transport-unification.md.
Module Inventory
Core
| Module | File | Purpose |
|---|---|---|
LemonGateway | lemon_gateway.ex | Public API entry point (submit/1 delegates to submit_execution/1) |
LemonGateway.Application | application.ex | Execution runtime supervision tree with optional health and explicit legacy ingress children |
LemonGateway.IngressSupervisor | ingress_supervisor.ex | Supervisor for gateway-owned transport, command, SMS, and voice startup |
LemonGateway.Runtime | runtime.ex | Execution submission and cancellation API |
LemonGateway.Config | config.ex | TOML-backed runtime configuration GenServer |
LemonGateway.ConfigLoader | config_loader.ex | Loads and parses TOML config into typed structs |
LemonGateway.ExecutionRequest | execution_request.ex | Gateway-private scheduler adapter with no queue semantics |
LemonGateway.Types | types.ex | Legacy compatibility types (Job, engine_id, lane) |
LemonGateway.Event | event.ex | Run lifecycle events (plain tagged maps with guards) and Delta struct |
LemonCore.ChatState | ../lemon_core/lib/lemon_core/chat_state.ex | Session state struct for auto-resume tracking |
LemonGateway.Cwd | cwd.ex | Default working directory resolver |
LemonGateway.Project | project.ex | Project configuration struct (id, root, default_engine) |
LemonGateway.Shared | shared.ex | Shared utilities (config access, data normalization, IP parsing) |
LemonGateway.DependencyManager | dependency_manager.ex | Centralized app startup, module availability checks, safe bus/telemetry |
LemonGateway.AI | ai.ex | Direct HTTP chat completions for OpenAI and Anthropic APIs |
LemonGateway.Dev | dev.ex | Development helpers (recompile and hot-reload) |
Scheduling and Run Execution
| Module | File | Purpose |
|---|---|---|
LemonGateway.Scheduler | scheduler.ex | Concurrency-limited slot allocator keyed by router-supplied conversation keys |
LemonGateway.ThreadWorker | thread_worker.ex | Per-conversation launcher / slot waiter with no queue-mode logic |
LemonGateway.ThreadRegistry | thread_registry.ex | Registry for thread workers (unique key by thread_key) |
LemonGateway.ThreadWorkerSupervisor | thread_worker_supervisor.ex | DynamicSupervisor for thread workers |
LemonGateway.Run | run.ex | Individual run GenServer: engine lifecycle, bus events, steer/cancel |
LemonGateway.RunSupervisor | run_supervisor.ex | DynamicSupervisor for run processes (temporary restart) |
LemonGateway.EngineLock | engine_lock.ex | Per-session mutex with FIFO queueing, timeouts, and stale lock reaping |
Gateway action events preserve nested action.detail.result_meta metadata,
including safe failure fields such as error_type, timeout_ms, and
exit_code, so downstream router and control-plane consumers can classify tool
failures without parsing rendered command output.
Engine Layer
| Module | File | Purpose |
|---|---|---|
LemonGateway.Engine | engine.ex | Behaviour definition for engine plugins |
LemonGateway.EngineRegistry | engine_registry.ex | Engine registration, lookup, and resume token extraction |
LemonGateway.EngineDirective | engine_directive.ex | Parses /engine prefix directives from user input |
LemonGateway.Engines.CliAdapter | engines/cli_adapter.ex | Shared CLI subprocess runner for all CLI engines |
LemonGateway.Workspace | workspace.ex | Workspace directory for channel-bound files, configured rather than read from the agent |
LemonGateway.Engines.Claude | engines/claude.ex | Claude Code CLI adapter |
LemonGateway.Engines.Codex | engines/codex.ex | OpenAI Codex CLI adapter |
LemonGateway.Engines.Droid | engines/droid.ex | Factory Droid CLI adapter |
LemonGateway.Engines.Opencode | engines/opencode.ex | Opencode CLI adapter |
LemonGateway.Engines.Pi | engines/pi.ex | Pi CLI adapter |
LemonGateway.Engines.Echo | engines/echo.ex | Test/debug echo engine |
Transport Layer
| Module | File | Purpose |
|---|---|---|
LemonGateway.Transport | transport.ex | Behaviour for transport plugins |
LemonGateway.TransportRegistry | transport_registry.ex | Transport registration and enable/disable tracking |
LemonGateway.TransportSupervisor | transport_supervisor.ex | Supervisor for enabled transports |
LemonGateway.Transports.Webhook | transports/webhook.ex | HTTP webhook transport (sync/async) |
Binding and Legacy Rendering Helpers
| Module | File | Purpose |
|---|---|---|
LemonGateway.Binding | binding_resolver.ex | Struct mapping transport/chat/topic to project/engine/queue_mode |
LemonGateway.BindingResolver | binding_resolver.ex | Resolves engine, cwd, agent_id, queue_mode from ChatScope |
LemonGateway.Renderer | renderer.ex | Behaviour for event-to-text rendering |
LemonGateway.Renderers.Basic | renderers/basic.ex | Plain-text renderer with action lists and resume info |
Command System
| Module | File | Purpose |
|---|---|---|
LemonGateway.Command | command.ex | Behaviour for slash command plugins |
LemonGateway.CommandRegistry | command_registry.ex | Command registration and lookup |
LemonGateway.Commands.Cancel | commands/cancel.ex | Built-in /cancel command |
SMS
| Module | File | Purpose |
|---|---|---|
LemonGateway.Sms.Inbox | sms/inbox.ex | Store and query inbound SMS messages |
LemonGateway.Sms.WebhookServer | sms/webhook_server.ex | HTTP server for Twilio SMS webhooks |
LemonGateway.Sms.WebhookRouter | sms/webhook_router.ex | Plug router for SMS webhook requests |
LemonGateway.Sms.TwilioSignature | sms/twilio_signature.ex | Twilio webhook signature validation |
LemonGateway.Sms.Config | sms/config.ex | SMS configuration helpers |
Voice
| Module | File | Purpose |
|---|---|---|
LemonGateway.Voice.CallSession | voice/call_session.ex | Per-call GenServer managing STT/TTS pipeline |
LemonGateway.Voice.TwilioWebSocket | voice/twilio_websocket.ex | WebSocket handler for Twilio Media Streams |
LemonGateway.Voice.DeepgramClient | voice/deepgram_client.ex | WebSocket client for Deepgram STT |
LemonGateway.Voice.WebhookRouter | voice/webhook_router.ex | Voice webhook HTTP routing |
LemonGateway.Voice.RecordingManager | voice/recording_manager.ex | Starts dual-channel call recording via Twilio REST API |
LemonGateway.Voice.RecordingDownloader | voice/recording_downloader.ex | Downloads and saves Twilio recordings locally |
LemonGateway.Voice.AudioConversion | voice/audio_conversion.ex | PCM-to-mulaw and MP3 detection utilities |
LemonGateway.Voice.Config | voice/config.ex | Voice configuration (Twilio, Deepgram, ElevenLabs credentials) |
Gateway Tools (injected into Lemon engine runs)
| Module | File | Purpose |
|---|---|---|
LemonGateway.Tools.Cron | tools/cron.ex | Manage cron jobs and active cron runs via LemonAutomation.CronManager |
LemonGateway.Tools.SmsGetInboxNumber | tools/sms_get_inbox_number.ex | Get the Twilio inbox phone number |
LemonGateway.Tools.SmsWaitForCode | tools/sms_wait_for_code.ex | Block until a matching SMS verification code arrives |
LemonGateway.Tools.SmsListMessages | tools/sms_list_messages.ex | List recent SMS messages |
LemonGateway.Tools.SmsClaimMessage | tools/sms_claim_message.ex | Mark an SMS as claimed by the current session |
LemonGateway.Tools.TelegramSendImage | tools/telegram_send_image.ex | Queue an image for Telegram delivery (Telegram sessions only) |
LemonGateway.Tools.DiscordSendFile | tools/discord_send_file.ex | Queue a file for Discord delivery (Discord sessions only) |
Health
| Module | File | Purpose |
|---|---|---|
LemonGateway.Health | health.ex | Health check system with built-in and custom checks |
LemonGateway.Health.Router | health/router.ex | Plug router serving GET /health (port 4042) |
Engine Lifecycle
Start
Run.init/1acquires theEngineLockfor the session's thread key (or fails fast with:lock_timeout).Run.handle_continue(:start_run)resolves the engine fromEngineRegistry, resolves the working directory, and callsengine.start_run(job, opts, self()).- The engine returns
{:ok, run_ref, cancel_ctx}. For CLI engines,CliAdapterstarts a runner subprocess and spawns a linkedTaskthat consumes the runner's event stream. Lemon starts a privateSessionRunnerGenServer that subscribes toCodingAgent.Sessionevents.
Streaming
- Engines send
{:engine_delta, run_ref, text}messages for incremental text output. - The Run process assigns monotonic sequence numbers, builds
Event.Deltastructs, and broadcasts them toLemonCore.Bus. - First-token latency telemetry is emitted on the first delta.
Completion
- Engines send
{:engine_event, run_ref, completed_event}when done. - The Run process stores chat state for auto-resume, emits
:run_completedto the bus, finalizes the run inLemonCore.Store, releases the engine lock and scheduler slot, and notifies the worker andmeta.notify_pid. - On context-length overflow errors, the
ChatStateis automatically cleared so the next run starts fresh.
Steering
- Only the Lemon engine supports steering (
supports_steer?/0returnstrue). - Router-owned
SessionCoordinatordecides whether a submission should be steered, queued, or interrupted before anything reaches the gateway. - When the active run is already live, the gateway
Runonly handles the low-level steer attempt by callingengine.steer(cancel_ctx, text). - Any fallback from
:steer/:steer_backlogis router behavior, not gateway queue behavior.
Cancellation
Runtime.cancel_by_run_id/2looks up the run inRunRegistryand casts{:cancel, reason}to the run process.- The Run calls
engine.cancel(cancel_ctx), emits a failed completion event, and terminates normally.
Queue Semantics
Queue modes such as :collect, :followup, :steer, :steer_backlog, and :interrupt are router-owned conversation semantics. The gateway no longer decides those modes for execution requests.
Gateway queue configuration only applies to legacy transport/binding compatibility paths that still emit router-facing run requests before SessionCoordinator takes over. Execution submission into the gateway is keyed by router-supplied conversation_key.
Voice Call System
Incoming Call -> Twilio -> Voice.WebhookRouter -> CallSession GenServer
|
TwilioWebSocket
(mulaw audio frames)
|
DeepgramClient
(raw audio -> text)
|
LemonGateway.AI
(LLM chat completion)
|
ElevenLabs TTS API
(text -> audio)
|
Twilio <- audio
RecordingManagerstarts dual-channel recording via the Twilio REST API when a call connects.RecordingDownloadersaves recordings as WAV files organized by date (~/.lemon/recordings/<date>/).- Audio conversion handles PCM-to-mulaw transcoding and MP3/ID3 detection for ElevenLabs responses.
SMS Inbox
- Twilio sends SMS webhooks to
Sms.WebhookServer(validates signatures viaTwilioSignature). Sms.Inboxstores messages with extracted verification codes (4-8 digit sequences).- Lemon engine runs can use injected tools (
sms_wait_for_code,sms_list_messages,sms_claim_message) to interact with the inbox. - Messages can be "claimed" to prevent cross-session conflicts.
Binding System
Bindings map transport + chat_id + topic_id to a project, agent, engine, and queue mode:
[[gateway.bindings]]
transport = "telegram"
chat_id = 123456789
topic_id = 42
project = "myproject"
agent_id = "coder"
default_engine = "claude"
queue_mode = "steer"
BindingResolver delegates to LemonCore.BindingResolver and provides:
resolve_binding/1-- most specific matching bindingresolve_engine/3-- engine with priority cascaderesolve_cwd/1-- project root directoryresolve_agent_id/1-- agent identifierresolve_queue_mode/1-- queue mode from binding
Configuration
Configuration loads from ~/.lemon/config.toml (the [gateway] section) via LemonCore.GatewayConfig.load/0 and LemonGateway.ConfigLoader.
Core Options
| Key | Default | Description |
|---|---|---|
max_concurrent_runs | 2 | Maximum concurrent AI runs across all threads |
default_engine | "lemon" | Engine when no hint or resume token present |
default_cwd | nil | Default working directory (falls back to $HOME) |
auto_resume | false | Automatically resume sessions from stored ChatState |
require_engine_lock | true | Acquire per-session mutex before engine runs |
engine_lock_timeout_ms | 60000 | Timeout for engine lock acquisition |
Startup Options
| Key | Default | Description |
|---|---|---|
gateway_ingress_enabled | false | Start gateway-owned transports, command registry, SMS inbox/webhook server, and voice supervisors. Default gateway startup is execution-only. |
Transport Enable Flags
| Key | Default | Description |
|---|---|---|
enable_telegram | false | Enable Telegram adapter (via lemon_channels) |
enable_discord | false | Enable Discord adapter (via lemon_channels) |
enable_xmtp | false | Enable XMTP transport |
enable_webhook | false | Enable webhook transport |
There is no enable_email gate. The [gateway] email block itself is still meaningful — the
channel adapter reads it, so existing relay credentials, sender address and webhook token keep
working. Receiving mail now depends on LemonChannels.InboundHttp being enabled and a
webhook token being set; see LemonChannels.Adapters.Email.
Discord and email are not gateway transports. If a discord or email module is added to
:transports, TransportRegistry ignores it and logs a warning; ownership lives in
lemon_channels.
Legacy Queue Options ([gateway.queue])
| Key | Default | Description |
|---|---|---|
mode | nil | Legacy default queue mode used only while building router-facing submissions from old transport/binding config |
cap | nil | Legacy queue cap for compatibility paths that still rely on transport-level queue config |
drop | nil | Legacy drop policy when that compatibility queue cap is exceeded |
TOML Example
[gateway]
max_concurrent_runs = 2
default_engine = "lemon"
auto_resume = true
require_engine_lock = true
[gateway.queue]
mode = "followup"
cap = 50
drop = "oldest"
[gateway.telegram]
bot_token = "your-token"
allowed_chat_ids = [123456789]
deny_unbound_chats = true
[gateway.projects.myproject]
root = "/path/to/project"
default_engine = "lemon"
[[gateway.bindings]]
transport = "telegram"
chat_id = 123456789
project = "myproject"
agent_id = "coder"
default_engine = "claude"
queue_mode = "steer"
[gateway.sms]
inbox_number = "+1234567890"
webhook_port = 4045
[gateway.engines.lemon]
enabled = true
[gateway.engines.claude]
enabled = true
cli_path = "/usr/local/bin/claude"
Event Protocol
Engines emit events to the Run process as {:engine_event, run_ref, event} messages where events are plain tagged maps:
| Event Tag | Key Fields | Description |
|---|---|---|
:started | engine, resume, title, meta | Run began, includes resume token |
:action_event | engine, action, phase, ok, message | Tool/action progress |
:completed | engine, ok, answer, error, resume, usage | Run finished |
Streaming text is sent as {:engine_delta, run_ref, text} with monotonic sequence numbers assigned by the Run process.
The Run re-emits all events to LemonCore.Bus as plain maps on topic "run:<run_id>". Bus event types: :run_started, :run_completed, :delta, :engine_started, :engine_completed, :engine_action.
Health Check
The health endpoint runs on port 4042 (configurable via :health_port or
LEMON_GATEWAY_HEALTH_PORT). GET /health returns JSON with built-in checks for:
- Supervisor process liveness
- Scheduler state (in_flight count, waitq length, max slots)
- RunSupervisor active children
- EngineLock process liveness
- XMTP transport status (when enabled)
Custom health checks can be registered via the :health_checks application environment.
Dependencies
Umbrella Apps
| App | Purpose |
|---|---|
agent_core | CLI runner infrastructure, tool types (AgentTool, AgentToolResult), event stream |
coding_agent | Native Lemon AI engine (CodingAgent.Session, CodingAgent.Session.Presentation) |
lemon_core | Shared primitives: Store, Bus, Telemetry, ResumeToken, ChatScope, Binding, Secrets, GatewayConfig |
External Libraries
| Library | Purpose |
|---|---|
jason | JSON encoding/decoding |
uuid | UUID generation for run IDs |
toml | TOML configuration parsing |
plug + bandit | HTTP servers (health port 4042, SMS webhooks, voice webhooks) |
gen_smtp + mail | SMTP email handling |
earmark_parser | Markdown-to-Telegram entity rendering |
websockex + websock_adapter | WebSocket clients (Deepgram STT, Twilio Media Streams) |
Testing
# Run all gateway tests
mix test apps/lemon_gateway
# Run a specific test file
mix test apps/lemon_gateway/test/run_test.exs
# Run with verbose output
mix test apps/lemon_gateway --trace
Tests use async: false by default due to shared GenServer state (Config, Scheduler, EngineRegistry). The test helper sets up an isolated lock directory to avoid collisions with running development instances.