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

  1. Router-owned SessionCoordinator decides queue semantics (collect, followup, steer, interrupt) and hands queue-semantic-free %LemonCore.ExecutionCommand{} values to LemonGateway.Runtime.
  2. Gateway-owned transports that still live in this app submit %LemonCore.RunRequest{} through LemonCore.RouterBridge, not directly into gateway internals.
  3. The Scheduler routes each execution request by the router-supplied conversation_key and allocates a concurrency slot.
  4. The ThreadWorker is only a per-conversation launcher/slot waiter. It does not own product queue semantics.
  5. On slot grant, the worker starts a Run via RunSupervisor. The Run acquires EngineLock, resolves the engine, and calls Engine.start_run/3.
  6. The Engine executes the AI request and streams lifecycle events and deltas back to the Run process.
  7. The Run broadcasts all events to LemonCore.Bus on topic "run:<run_id>". Router and channels consume those events and handle semantic output plus channel rendering.
  8. 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 IDModuleRunnerSteeringDescription
lemonCodingAgent.GatewayEngine (registered by coding_agent)CodingAgent.Session via CodingAgent.GatewayEngine.SessionRunnerYesNative Elixir engine with full CodingAgent tool support, session persistence, and mid-run steering. Absent in a runtime without coding_agent
claudeEngines.ClaudeLemonCliRunners.ClaudeRunnerNoClaude Code CLI wrapper via CliAdapter
codexEngines.CodexLemonCliRunners.CodexRunnerNoOpenAI Codex CLI wrapper via CliAdapter
droidEngines.DroidLemonCliRunners.DroidRunnerNoFactory Droid CLI wrapper via CliAdapter
opencodeEngines.OpencodeLemonCliRunners.OpencodeRunnerNoOpencode CLI wrapper via CliAdapter
piEngines.PiLemonCliRunners.PiRunnerNoPi CLI wrapper via CliAdapter
echoEngines.Echo(in-process Task)NoTest/debug engine that echoes the prompt back

Engine Abstraction

All engines implement the LemonGateway.Engine behaviour:

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

  1. Resume token engine (from router-resolved auto-resume or explicit resume)
  2. Inline directive (/claude, /codex, /lemon, etc. via EngineDirective)
  3. Binding default_engine (topic-level, then chat-level)
  4. Project default_engine
  5. Global default_engine from config (default: "lemon")

Composite engine IDs like "claude:claude-3-opus" are resolved by prefix fallback to "claude".

Transports

TransportModule / LocationDescription
Telegramlemon_channels (external app)Telegram Bot API polling/webhooks
Discordlemon_channels (external app)Discord gateway via Nostrum
XMTPlemon_channels (external app)XMTP messaging via Node.js bridge
Emaillemon_channels (external app)SMTP outbound + inbound webhook, as a channel plugin
WebhookTransports.WebhookGeneric HTTP webhook (sync/async modes)
VoiceVoice.*Real-time phone calls via Twilio + Deepgram STT + ElevenLabs TTS
SMSSms.*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

ModuleFilePurpose
LemonGatewaylemon_gateway.exPublic API entry point (submit/1 delegates to submit_execution/1)
LemonGateway.Applicationapplication.exExecution runtime supervision tree with optional health and explicit legacy ingress children
LemonGateway.IngressSupervisoringress_supervisor.exSupervisor for gateway-owned transport, command, SMS, and voice startup
LemonGateway.Runtimeruntime.exExecution submission and cancellation API
LemonGateway.Configconfig.exTOML-backed runtime configuration GenServer
LemonGateway.ConfigLoaderconfig_loader.exLoads and parses TOML config into typed structs
LemonGateway.ExecutionRequestexecution_request.exGateway-private scheduler adapter with no queue semantics
LemonGateway.Typestypes.exLegacy compatibility types (Job, engine_id, lane)
LemonGateway.Eventevent.exRun lifecycle events (plain tagged maps with guards) and Delta struct
LemonCore.ChatState../lemon_core/lib/lemon_core/chat_state.exSession state struct for auto-resume tracking
LemonGateway.Cwdcwd.exDefault working directory resolver
LemonGateway.Projectproject.exProject configuration struct (id, root, default_engine)
LemonGateway.Sharedshared.exShared utilities (config access, data normalization, IP parsing)
LemonGateway.DependencyManagerdependency_manager.exCentralized app startup, module availability checks, safe bus/telemetry
LemonGateway.AIai.exDirect HTTP chat completions for OpenAI and Anthropic APIs
LemonGateway.Devdev.exDevelopment helpers (recompile and hot-reload)

Scheduling and Run Execution

ModuleFilePurpose
LemonGateway.Schedulerscheduler.exConcurrency-limited slot allocator keyed by router-supplied conversation keys
LemonGateway.ThreadWorkerthread_worker.exPer-conversation launcher / slot waiter with no queue-mode logic
LemonGateway.ThreadRegistrythread_registry.exRegistry for thread workers (unique key by thread_key)
LemonGateway.ThreadWorkerSupervisorthread_worker_supervisor.exDynamicSupervisor for thread workers
LemonGateway.Runrun.exIndividual run GenServer: engine lifecycle, bus events, steer/cancel
LemonGateway.RunSupervisorrun_supervisor.exDynamicSupervisor for run processes (temporary restart)
LemonGateway.EngineLockengine_lock.exPer-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

ModuleFilePurpose
LemonGateway.Engineengine.exBehaviour definition for engine plugins
LemonGateway.EngineRegistryengine_registry.exEngine registration, lookup, and resume token extraction
LemonGateway.EngineDirectiveengine_directive.exParses /engine prefix directives from user input
LemonGateway.Engines.CliAdapterengines/cli_adapter.exShared CLI subprocess runner for all CLI engines
LemonGateway.Workspaceworkspace.exWorkspace directory for channel-bound files, configured rather than read from the agent
LemonGateway.Engines.Claudeengines/claude.exClaude Code CLI adapter
LemonGateway.Engines.Codexengines/codex.exOpenAI Codex CLI adapter
LemonGateway.Engines.Droidengines/droid.exFactory Droid CLI adapter
LemonGateway.Engines.Opencodeengines/opencode.exOpencode CLI adapter
LemonGateway.Engines.Piengines/pi.exPi CLI adapter
LemonGateway.Engines.Echoengines/echo.exTest/debug echo engine

Transport Layer

ModuleFilePurpose
LemonGateway.Transporttransport.exBehaviour for transport plugins
LemonGateway.TransportRegistrytransport_registry.exTransport registration and enable/disable tracking
LemonGateway.TransportSupervisortransport_supervisor.exSupervisor for enabled transports
LemonGateway.Transports.Webhooktransports/webhook.exHTTP webhook transport (sync/async)

Binding and Legacy Rendering Helpers

ModuleFilePurpose
LemonGateway.Bindingbinding_resolver.exStruct mapping transport/chat/topic to project/engine/queue_mode
LemonGateway.BindingResolverbinding_resolver.exResolves engine, cwd, agent_id, queue_mode from ChatScope
LemonGateway.Rendererrenderer.exBehaviour for event-to-text rendering
LemonGateway.Renderers.Basicrenderers/basic.exPlain-text renderer with action lists and resume info

Command System

ModuleFilePurpose
LemonGateway.Commandcommand.exBehaviour for slash command plugins
LemonGateway.CommandRegistrycommand_registry.exCommand registration and lookup
LemonGateway.Commands.Cancelcommands/cancel.exBuilt-in /cancel command

SMS

ModuleFilePurpose
LemonGateway.Sms.Inboxsms/inbox.exStore and query inbound SMS messages
LemonGateway.Sms.WebhookServersms/webhook_server.exHTTP server for Twilio SMS webhooks
LemonGateway.Sms.WebhookRoutersms/webhook_router.exPlug router for SMS webhook requests
LemonGateway.Sms.TwilioSignaturesms/twilio_signature.exTwilio webhook signature validation
LemonGateway.Sms.Configsms/config.exSMS configuration helpers

Voice

ModuleFilePurpose
LemonGateway.Voice.CallSessionvoice/call_session.exPer-call GenServer managing STT/TTS pipeline
LemonGateway.Voice.TwilioWebSocketvoice/twilio_websocket.exWebSocket handler for Twilio Media Streams
LemonGateway.Voice.DeepgramClientvoice/deepgram_client.exWebSocket client for Deepgram STT
LemonGateway.Voice.WebhookRoutervoice/webhook_router.exVoice webhook HTTP routing
LemonGateway.Voice.RecordingManagervoice/recording_manager.exStarts dual-channel call recording via Twilio REST API
LemonGateway.Voice.RecordingDownloadervoice/recording_downloader.exDownloads and saves Twilio recordings locally
LemonGateway.Voice.AudioConversionvoice/audio_conversion.exPCM-to-mulaw and MP3 detection utilities
LemonGateway.Voice.Configvoice/config.exVoice configuration (Twilio, Deepgram, ElevenLabs credentials)

Gateway Tools (injected into Lemon engine runs)

ModuleFilePurpose
LemonGateway.Tools.Crontools/cron.exManage cron jobs and active cron runs via LemonAutomation.CronManager
LemonGateway.Tools.SmsGetInboxNumbertools/sms_get_inbox_number.exGet the Twilio inbox phone number
LemonGateway.Tools.SmsWaitForCodetools/sms_wait_for_code.exBlock until a matching SMS verification code arrives
LemonGateway.Tools.SmsListMessagestools/sms_list_messages.exList recent SMS messages
LemonGateway.Tools.SmsClaimMessagetools/sms_claim_message.exMark an SMS as claimed by the current session
LemonGateway.Tools.TelegramSendImagetools/telegram_send_image.exQueue an image for Telegram delivery (Telegram sessions only)
LemonGateway.Tools.DiscordSendFiletools/discord_send_file.exQueue a file for Discord delivery (Discord sessions only)

Health

ModuleFilePurpose
LemonGateway.Healthhealth.exHealth check system with built-in and custom checks
LemonGateway.Health.Routerhealth/router.exPlug router serving GET /health (port 4042)

Engine Lifecycle

Start

  1. Run.init/1 acquires the EngineLock for the session's thread key (or fails fast with :lock_timeout).
  2. Run.handle_continue(:start_run) resolves the engine from EngineRegistry, resolves the working directory, and calls engine.start_run(job, opts, self()).
  3. The engine returns {:ok, run_ref, cancel_ctx}. For CLI engines, CliAdapter starts a runner subprocess and spawns a linked Task that consumes the runner's event stream. Lemon starts a private SessionRunner GenServer that subscribes to CodingAgent.Session events.

Streaming

Completion

Steering

Cancellation

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

SMS Inbox

  1. Twilio sends SMS webhooks to Sms.WebhookServer (validates signatures via TwilioSignature).
  2. Sms.Inbox stores messages with extracted verification codes (4-8 digit sequences).
  3. Lemon engine runs can use injected tools (sms_wait_for_code, sms_list_messages, sms_claim_message) to interact with the inbox.
  4. 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:

Configuration

Configuration loads from ~/.lemon/config.toml (the [gateway] section) via LemonCore.GatewayConfig.load/0 and LemonGateway.ConfigLoader.

Core Options

KeyDefaultDescription
max_concurrent_runs2Maximum concurrent AI runs across all threads
default_engine"lemon"Engine when no hint or resume token present
default_cwdnilDefault working directory (falls back to $HOME)
auto_resumefalseAutomatically resume sessions from stored ChatState
require_engine_locktrueAcquire per-session mutex before engine runs
engine_lock_timeout_ms60000Timeout for engine lock acquisition

Startup Options

KeyDefaultDescription
gateway_ingress_enabledfalseStart gateway-owned transports, command registry, SMS inbox/webhook server, and voice supervisors. Default gateway startup is execution-only.

Transport Enable Flags

KeyDefaultDescription
enable_telegramfalseEnable Telegram adapter (via lemon_channels)
enable_discordfalseEnable Discord adapter (via lemon_channels)
enable_xmtpfalseEnable XMTP transport
enable_webhookfalseEnable 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])

KeyDefaultDescription
modenilLegacy default queue mode used only while building router-facing submissions from old transport/binding config
capnilLegacy queue cap for compatibility paths that still rely on transport-level queue config
dropnilLegacy 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 TagKey FieldsDescription
:startedengine, resume, title, metaRun began, includes resume token
:action_eventengine, action, phase, ok, messageTool/action progress
:completedengine, ok, answer, error, resume, usageRun 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:

Custom health checks can be registered via the :health_checks application environment.

Dependencies

Umbrella Apps

AppPurpose
agent_coreCLI runner infrastructure, tool types (AgentTool, AgentToolResult), event stream
coding_agentNative Lemon AI engine (CodingAgent.Session, CodingAgent.Session.Presentation)
lemon_coreShared primitives: Store, Bus, Telemetry, ResumeToken, ChatScope, Binding, Secrets, GatewayConfig

External Libraries

LibraryPurpose
jasonJSON encoding/decoding
uuidUUID generation for run IDs
tomlTOML configuration parsing
plug + banditHTTP servers (health port 4042, SMS webhooks, voice webhooks)
gen_smtp + mailSMTP email handling
earmark_parserMarkdown-to-Telegram entity rendering
websockex + websock_adapterWebSocket 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.