LemonCore
Foundational shared library for the Lemon umbrella project. All other apps depend on lemon_core -- it provides configuration management, configured-implementation contract validation, encrypted secrets, pluggable storage, an event bus, live named-node invocation routing, session routing primitives, idempotency, execution approvals, telemetry, and quality tooling.
LemonCore.Context is the shared versioned preview/resolve boundary for
root-confined file and folder references, shell-free git diffs, SSRF-guarded
public URLs, redacted session exports, and format-sniffed document text. See
docs/user-guide/context-references.md.
This app has zero dependencies on other umbrella apps and must remain that way.
Architecture Overview
+-----------------------+
| LemonCore.Config | TOML parsing, env overrides,
| .Modular / .Providers| hot reload, validation
+-----------+-----------+
|
+-----------+-----------+
| LemonCore.ConfigCache | ETS cache with mtime-based
| LemonCore.ConfigReloader| invalidation + FileSystem watcher
+-----------+-----------+
|
+--------------------+--------------------+
| | |
+---------+--------+ +--------+--------+ +---------+--------+
| LemonCore.Secrets| | LemonCore.Store | | LemonCore.Bus |
| .Crypto/.External| | .EtsBackend | | (Phoenix.PubSub) |
| .Source/.Runner | | .SqliteBackend | | |
| .MasterKey | | .JsonlBackend | | LemonCore.Event |
+------------------+ | .ReadCache | +------------------+
+-----------------+
|
+--------------------+--------------------+
| | |
+---------+--------+ +--------+--------+ +---------+--------+
| LemonCore. | | LemonCore. | | LemonCore. |
| Idempotency | | ExecApprovals | | Introspection |
+------------------+ +-----------------+ +------------------+
|
+---------+--------+
| LemonCore. |
| RouterBridge | Runtime bridge to :lemon_router
| SessionKey | without compile-time coupling
| RunRequest |
| InboundMessage |
+------------------+
Supervised Process Tree
LemonCore.Application starts these children under a :one_for_one supervisor:
| # | Child | Purpose |
|---|---|---|
| 1 | Phoenix.PubSub (name: LemonCore.PubSub), or a Registry |
Backbone for the Bus; see LemonCore.Bus for which one is used |
| 2 | LemonCore.ACPClientBridge |
Registry for direct ACP client request/reply |
| 3 | LemonCore.NodeRegistry |
Live named-node registry and targeted invocation broker |
| 4 | LemonCore.ConfigCache |
ETS-backed config cache with TTL fingerprinting |
| 5 | LemonCore.Store |
Key-value storage GenServer with pluggable backends |
| 6 | LemonCore.Secrets.SourceTaskSupervisor |
Owns bounded external-source tasks |
| 7 | LemonCore.Secrets.SourceCache |
Bounded optional process-local source cache |
| 8 | LemonCore.RunHistoryStore |
Run history persistence (only when :exqlite is available) |
| 9 | LemonCore.ConfigReloader |
Reload orchestrator with diff computation |
| 10 | LemonCore.ConfigReloader.Watcher |
FileSystem watcher for config.toml and .env |
Durable memory moved to lemon_memory, the workspace stores to agent_core,
and provider credential-pool rotation to lemon_agent
(LemonAgent.ModelRuntime.ProviderPoolRotator); each supervises its own
processes.
Browser, media-job, and LSP drivers live in lemon_browser, lemon_media,
and lemon_lsp. Core doctor diagnostics may probe them at runtime, but
lemon_core must not depend on those apps.
Module Inventory
Configuration
| Module | Purpose |
|---|---|
LemonCore.Config |
Canonical TOML config loader with global/project merge and env overrides |
LemonCore.Config.Modular |
Newer typed config interface delegating to per-domain sub-modules |
LemonCore.Config.Providers |
LLM provider config (API keys, base URLs, secret refs, OAuth) |
LemonCore.Config.Secrets |
Exact configuration for explicitly enabled external secret sources |
LemonCore.Config.Agent |
Agent behavior settings sub-module |
LemonCore.Config.Gateway |
Gateway settings sub-module |
LemonCore.Config.Tools |
Web tools and WASM config sub-module |
LemonCore.Config.TUI |
Terminal UI theme and debug sub-module |
LemonCore.Config.Logging |
Log file and rotation sub-module |
LemonCore.Config.Automation |
[automation] sub-module (opt-in daily_brief HH:MM time) |
LemonCore.Config.Validator |
Validation for both legacy and modular config structs |
LemonCore.Config.ValidationError |
Raised by Config.Modular.load!/1 on invalid config |
LemonCore.Contract |
Validates configuration-injected modules for loadability and required behaviour callbacks |
LemonCore.Config.Helpers |
Shared config parsing helpers |
LemonCore.Config.TomlPatch |
Textual TOML editing for targeted key upserts |
LemonCore.ConfigCache |
ETS cache with mtime/size fingerprint-based invalidation |
LemonCore.ConfigCacheError |
Raised when ConfigCache is unavailable |
LemonCore.ConfigReloader |
Central reload orchestrator with digest diffing and Bus broadcast |
LemonCore.ConfigReloader.Digest |
File/env/secrets digest computation |
LemonCore.ConfigReloader.Watcher |
FileSystem watcher targeting config.toml and .env paths |
LemonCore.Setup.Readiness |
Read-only first-run readiness shared by setup, TUI, Web, and launchers |
LemonCore.GatewayConfig |
Unified gateway config access merging TOML, app env, and transport overrides |
LemonCore.A2A.Protocol / LemonCore.A2A.Client |
Generic A2A v1.0 wire shapes and credential-scrubbing HTTP client |
LemonCore.A2AStore |
Typed persistent A2A contexts, default peer conversations, tasks, and message history |
LemonCore.Dotenv |
.env file loader preserving existing env vars |
LemonCore.Logging |
Runtime log-to-file handler from [logging] config |
LemonCore.OAuth.LocalCallbackListener |
One-shot localhost OAuth callback capture with monitored listener failure and bounded wait/cleanup |
Doctor and Support
| Module | Purpose |
|---|---|
LemonCore.Doctor.SupportBundle |
Redacted doctor report, runtime metadata, config, and diagnostics ZIP writer |
LemonCore.Doctor.ProviderDiagnostics |
Provider setup/routing diagnostics for support bundles without raw API keys, secret names, base URLs, env var names, prompts, or provider responses |
LemonCore.Doctor.Checks.Usage |
Usage aggregate and quota-pressure check backed by redacted shared usage diagnostics |
mix lemon.doctor includes usage.status, which reads the same redacted
aggregate usage diagnostics and warns when configured run, token, or cost
limits are exceeded.
Checks for domains owned by other apps live in those apps and register through
config :lemon_core, :doctor_checks (memory: LemonMemory.Doctor.Checks.Memory;
skills: LemonSkills.Doctor.Checks.Skills; channels and automation likewise).
Secrets
| Module | Purpose |
|---|---|
LemonCore.Secrets |
Encrypted secrets API and store/external/environment resolution |
LemonCore.Secrets.Crypto |
AES-256-GCM encryption with HKDF-SHA256 key derivation |
LemonCore.Secrets.EnvCatalog |
Ordered environment-secret catalog for packaged and Mix check/import commands |
LemonCore.Secrets.External |
Ordered, supervised, fail-closed external-source orchestration and redacted diagnostics |
LemonCore.Secrets.Source |
Read-only source behaviour implemented by 1Password, Bitwarden, and command adapters |
LemonCore.Secrets.SourceRunner |
Minimal-environment argv runner with timeout and output caps |
LemonCore.Secrets.SourceCache |
Bounded optional process-local TTL cache |
LemonCore.Secrets.Keychain |
macOS Keychain integration for master key storage |
LemonCore.Secrets.MasterKey |
Master key resolution chain (keychain -> env var) |
LemonCore.Redact is the common credential policy: text/1 masks assignments,
Bearer/Basic authorization, provider keys, JWTs, and PEM private keys; map/1
also masks sensitive-key values recursively through maps, structs, lists, and
tuples. Structs become plain maps. sensitive_key?/1 accepts atom/string keys.
Redaction is idempotent and does not truncate output.
Use text(text, count: true) for {redacted_text, replacement_count},
drop_keys: true when secret names must also be omitted, and strict: true
for portable profile/config exports that also omit full sensitive assignment
lines and unlabelled hex keys. Keep caller-specific content allowlists, URL/path
omission, collection/depth/byte bounds, and bounded error summaries: credential
redaction alone does not make arbitrary data safe to export. Redact before
truncating so partial credentials cannot escape.
Storage
| Module | Purpose |
|---|---|
LemonCore.Store |
Generic table GenServer with pluggable backends plus the serialized run lifecycle (append/finalize) |
LemonCore.RunStore |
Typed run lifecycle/history wrapper over :runs and :sessions_index; finalization is synchronous and retry-safe |
LemonCore.ChatStateStore |
Per-scope chat state in :chat with a 24h expiry |
LemonCore.Store.Backend |
Behaviour for storage backends (init/put/put_new/get/delete/list) |
LemonCore.Store.EtsBackend |
In-memory ETS backend (ephemeral, default) |
LemonCore.Store.SqliteBackend |
SQLite backend with WAL mode and optional ephemeral tables |
LemonCore.Store.JsonlBackend |
Append-only JSONL backend (human-readable, portable) |
LemonCore.Store.ReadCache |
Public ETS read-through cache for hot domains |
LemonCore.PolicyStore |
Agent, channel, session, and runtime policies over their four tables |
Memory
Durable memory moved to the lemon_memory app (LemonMemory.*). Core reaches
it only through the LemonCore.Store finalize-run hook the runtime configures.
Event System
| Module | Purpose |
|---|---|
LemonCore.Bus |
PubSub wrapper with topic helpers |
LemonCore.Event |
Canonical event struct (type, ts_ms, payload, meta) |
LemonCore.ChatState |
Sticky execution state for auto-resume across router/gateway/channel boundaries |
LemonCore.EventBridge |
Cross-app event translation |
Routing and Sessions
| Module | Purpose |
|---|---|
LemonCore.SessionKey |
Session key generation and parsing |
LemonCore.RouterBridge |
Runtime bridge to :lemon_router without compile-time coupling |
LemonCore.RunRequest |
Canonical run submission struct |
LemonCore.InboundMessage |
Normalized inbound message from any channel |
LemonCore.Binding |
Struct mapping transport/chat/topic to project config |
LemonCore.BindingResolver |
Binding resolution logic |
LemonCore.ChatScope |
Chat scope struct |
LemonCore.ResumeToken |
Resume token for session continuity |
Operations
| Module | Purpose |
|---|---|
LemonCore.Idempotency |
At-most-once deduplication backed by Store with 24h TTL |
LemonCore.ExecApprovals |
Tool execution approval flow with scope-based persistence |
LemonCore.Checkpoint |
Shared checkpoint store plus filesystem diff/restore and lifecycle events |
LemonCore.Introspection |
Canonical introspection event builder and persistence |
LemonCore.UsageStore |
Shared typed wrapper for usage records, current usage summaries, and quota counters |
LemonCore.UsageDiagnostics |
Redacted aggregate usage diagnostics shared by doctor checks and support bundles |
LemonCore.Dedupe.Ets |
Low-level ETS-backed TTL deduplication |
Utilities
| Module | Purpose |
|---|---|
LemonCore |
Root module with module list |
LemonCore.Id |
UUID and unique ID generation |
LemonCore.Clock |
Time utilities (monotonic timestamps) |
LemonCore.Httpc |
:httpc wrapper ensuring :inets/:ssl started |
LemonCore.MapHelpers |
Map key access helpers (atom/string agnostic) |
LemonCore.Telemetry |
Telemetry event helpers and named event emitters |
LemonCore.Reload |
Runtime BEAM/extension reload orchestration |
LemonCore.Testing |
Test harness builder (Harness, Case, Helpers) |
LemonCore.TerminalBackend / TerminalBackends / TerminalBackendPolicy |
Shared terminal/process backend contract, registry, policy, and redacted diagnostics |
Quality
| Module | Purpose |
|---|---|
LemonCore.Quality.DocsCheck |
Local markdown link check for tracked docs/**/*.md |
LemonCore.Quality.ArchitectureCheck |
deps/0 direct-dependency policy check |
LemonCore.Quality.ArchitectureDocs |
Generated dependency-policy section in docs/architecture_boundaries.md |
Mix Tasks
| Task | Purpose |
|---|---|
mix lemon.config |
Validate and show configuration |
mix lemon.secrets.init |
Generate master key |
mix lemon.secrets.status |
Show secrets status |
mix lemon.secrets.list |
List secrets metadata |
mix lemon.secrets.set |
Store a secret |
mix lemon.secrets.delete |
Delete a secret |
mix lemon.secrets.check |
Check secrets health |
mix lemon.secrets.import_env |
Import env vars as secrets |
mix lemon.secrets.rotate_key |
Preview, then (--confirm TOKEN) rotate the master key and re-encrypt secrets |
mix lemon.onboard |
Top-level provider onboarding picker with interactive TUI selection |
Provider onboarding preferred_models should favor stable models that actually
work on Lemon's live transport path. Do not keep known-broken variants in those
default suggestions just because the upstream provider documents them.
| mix lemon.onboard.anthropic | Anthropic provider setup |
| mix lemon.onboard.antigravity | Antigravity (Google) provider setup with OAuth |
| mix lemon.onboard.gemini | Google Gemini CLI / Code Assist setup with OAuth |
| mix lemon.onboard.codex | OpenAI Codex provider setup with OAuth and localhost callback capture |
| mix lemon.onboard.copilot | GitHub Copilot provider setup with OAuth |
| mix lemon.quality | Run all quality checks |
| mix lemon.store.migrate_jsonl_to_sqlite | Migrate JSONL to SQLite |
| mix lemon.introspection | Query introspection events |
| mix lemon.check_duplicate_tests | Check for duplicate test names |
Configuration System
Config Sources (precedence: env > project > global)
- Global:
~/.lemon/config.toml - Project:
<cwd>/.lemon/config.toml - Environment:
LEMON_*and provider-specific variables override both
Config Sections
providers-- LLM API keys and base URLs (anthropic, openai, openai-codex, opencode, kimi, google)defaults-- Preferred home for default provider/model/thinking level/engineruntime-- Runtime behavior (compaction, retry, shell, tools, cli, extensions, theme)profiles-- Per-agent identity/model/node defaults and tool policies. The user-managed lifecycle lives inLemonCore.ProfileStore, with derived homes at~/.lemon/profiles/<id>/and stableagent:<id>:mainsession keys.agent-- Legacy alias for runtime/default settings (still supported)agents-- Legacy alias for profile settings (still supported)tui-- Theme, debug modelogging-- File logging, level, rotationgateway-- Max concurrent runs, engine bindings, SMS/email/webhook settings, plus per-platform[gateway.<id>]sections resolved by their own app (seeLemonCore.Config.Gateway.Channel)
Access Patterns
# Cached read (default hot path) - uses ConfigCache when available
config = LemonCore.Config.load(cwd)
# Force reload from disk (updates cache)
config = LemonCore.Config.reload(cwd)
# Access nested values
provider = LemonCore.Config.get(config, [:agent, :default_provider], "anthropic")
# Modular config interface (typed sub-structs, validation)
config = LemonCore.Config.Modular.load(project_dir: cwd)
config = LemonCore.Config.Modular.load!(project_dir: cwd)
{:ok, config} = LemonCore.Config.Modular.load_with_validation(project_dir: cwd)
Hot Reload Flow
LemonCore.ConfigReloader.reload/1
|-- Acquire reload lock
|-- Compute file/env/secrets digests
|-- Compare with cached digests
|-- Reload .env if changed
|-- Reload TOML via LemonCore.Config.reload/2
|-- Compute redacted diff vs previous snapshot
|-- Update ConfigCache
|-- Broadcast :config_reloaded on "system" topic
|-- On failure: keep last good snapshot, emit :config_reload_failed
First-run readiness
LemonCore.Setup.Readiness.status/1 derives one shared readiness state from the
global config, secrets backend, default provider/model, and credential source.
It never performs a network request or mutates setup state. Clients use
pending_steps/1 and ready?/1 so a setup wizard, first-run gate, or browser
cannot silently invent a different definition of “ready.” Live provider
verification remains in lemon_cli and doctor diagnostics.
Environment Variable Overrides
| Env Var | Overrides |
|---|---|
LEMON_DEFAULT_PROVIDER |
defaults.provider |
LEMON_DEFAULT_MODEL |
defaults.model |
LEMON_PROVIDER_ROUTING_ENABLED |
runtime.provider_routing.enabled |
LEMON_PROVIDER_FALLBACK_PROVIDERS |
runtime.provider_routing.fallback_providers |
LEMON_PROVIDER_ROUTING_REQUIRE_CREDENTIALS |
runtime.provider_routing.require_credentials |
LEMON_DEBUG |
tui.debug |
LEMON_THEME |
tui.theme |
LEMON_LOG_FILE |
logging.file |
LEMON_LOG_LEVEL |
logging.level |
LEMON_WASM_ENABLED |
runtime.tools.wasm.enabled |
LEMON_WASM_RUNTIME_PATH |
runtime.tools.wasm.runtime_path |
LEMON_WASM_TOOL_PATHS |
runtime.tools.wasm.tool_paths |
LEMON_WASM_AUTO_BUILD |
runtime.tools.wasm.auto_build |
LEMON_BROWSER_DRIVER_PATH |
Path to local browser driver JS file |
ANTHROPIC_API_KEY |
providers.anthropic.api_key |
ANTHROPIC_TOKEN |
Ambient Anthropic OAuth token when providers.anthropic.auth_source = "oauth" |
CLAUDE_CODE_OAUTH_TOKEN |
Ambient Claude Code OAuth token when providers.anthropic.auth_source = "oauth" |
OPENAI_API_KEY |
providers.openai.api_key |
OPENAI_CODEX_API_KEY |
providers.openai-codex.api_key |
GOOGLE_GENERATIVE_AI_API_KEY |
providers.google.api_key |
GOOGLE_GEMINI_CLI_API_KEY |
providers.google_gemini_cli.api_key (JSON credential payload) |
LEMON_GEMINI_PROJECT_ID |
Runtime override for providers.google_gemini_cli.project_id |
Secrets Management
Storage Model
Secrets are encrypted at rest with AES-256-GCM. Per-secret encryption keys are derived via HKDF-SHA256 from a master key and a random 32-byte salt. The ciphertext, nonce, and salt are base64-encoded and stored in the :secrets_v1 Store table.
Master Key Resolution (in order)
- macOS Keychain (
Lemon Secretsservice,defaultaccount) -- preferred LEMON_SECRETS_MASTER_KEYenvironment variable -- fallback~/.lemon/secrets_master_keyfile -- local non-keychain fallback- Fail with
:missing_master_key
The order and the locations are configurable via config :lemon_core, LemonCore.Secrets, key_providers: [:keychain, :env, :file], key_file: ..., env_var: ...; see LemonCore.Secrets.KeyProvider. The key must be base64-encoded 32 bytes.
For local Linux/dev usage, treat ~/.lemon/secrets_master_key as the canonical master key file. ./bin/lemon normalizes LEMON_SECRETS_MASTER_KEY from that file before boot so stale inherited shell env does not override the working local key.
To change the master key without re-entering secrets, run lemon secrets rotate-key (or mix lemon.secrets.rotate_key): it previews, then with the preview's --confirm token re-encrypts every stored secret under a new key and writes that key to the Keychain entry or key file that supplied the old one. See LemonCore.Secrets.KeyRotation and the secrets contract.
API
# Store a secret
{:ok, metadata} = LemonCore.Secrets.set("api_key", "secret_value", provider: "manual")
# Retrieve a secret
{:ok, value} = LemonCore.Secrets.get("api_key")
# Resolve (store first, then external sources, then env fallback)
{:ok, value, :store} = LemonCore.Secrets.resolve("api_key")
{:ok, value, "external:command:local_helper"} = LemonCore.Secrets.resolve("api_key")
{:ok, value, :env} = LemonCore.Secrets.resolve("MISSING_FROM_STORE")
# Convenience (returns value or nil)
value = LemonCore.Secrets.fetch_value("ANTHROPIC_API_KEY")
# Check existence
exists? = LemonCore.Secrets.exists?("api_key")
# List (metadata only, no values)
{:ok, metadata_list} = LemonCore.Secrets.list()
# Delete
:ok = LemonCore.Secrets.delete("api_key")
# Status
status = LemonCore.Secrets.status()
External sources are read-only fallbacks behind the encrypted store and ahead
of environment variables. They are disabled unless their config contains the
exact boolean enabled = true. Enabled-source failures stop resolution before
environment fallback; a source that succeeds without the requested name may
continue. Use env_fallback: false to disable both external and environment
fallbacks. Secret reads update usage metadata (usage_count, last_used_at)
without mutating updated_at.
1Password, Bitwarden Secrets Manager, and arbitrary argv-only command adapters share one supervised runner with exact config validation, minimal child environments, stable redacted errors, and configured time/output limits. The optional bounded process-local cache defaults off. Inspect or live-test them without revealing values:
lemon secrets sources status --json
lemon secrets sources test [source-id] --json
See docs/config.md for the
exact source schemas and bootstrap rules.
LemonCore.Secrets.EnvCatalog.names/0 is the canonical ordered set used by
the packaged lemon secrets check / lemon secrets import-env commands and
their Mix equivalents. It is intentionally separate from the release-profile
runtime declarations in LemonCore.Env.
Storage Backends
When to Use Each
| Backend | Persistence | Use Case |
|---|---|---|
EtsBackend |
No (in-memory) | Tests, ephemeral data, default |
SqliteBackend |
Yes (single file) | Production, WAL mode, optional ephemeral tables for high-churn |
JsonlBackend |
Yes (append-only files) | Debugging, data portability, human-readable |
Configuration
# config/config.exs
config :lemon_core, LemonCore.Store,
backend: LemonCore.Store.SqliteBackend,
backend_opts: [path: "/var/lib/lemon/store.sqlite3"]
Wrapper-First Table Access
defmodule MyApp.WidgetStore do
@table :widgets
def put(id, widget), do: LemonCore.Store.put(@table, id, widget)
def claim(id, widget), do: LemonCore.Store.put_new(@table, id, widget)
def get(id), do: LemonCore.Store.get(@table, id)
end
MyApp.WidgetStore.put(id, widget)
widget = MyApp.WidgetStore.get(id)
put_new/3 is the insert-if-absent primitive for durable claims. It returns :ok for the first writer and {:error, :exists} for later writers without overwriting the original value. take/2 atomically consumes a key, compare_and_swap/4 replaces a value only when its exact expected value is still current, and compare_and_delete/3 conditionally removes an exact snapshot so a sweeper cannot erase a renewed lease or receipt. compare_and_delete_many/1 first validates a set of exact snapshots in one serialized Store call. SQLite commits the set transactionally, ETS applies it under the Store owner, and ordered backends report the first failure so callers can place a safety-critical durable fence last.
Store calls are fail-soft: if the GenServer is overloaded/unavailable, write APIs return {:error, :store_unavailable} and ordinary read/list APIs return nil/[]. Correctness-sensitive callers use fetch/3 and fetch_all/2 to distinguish confirmed absence or an empty table from an unavailable backend.
Store diagnostics hash keys for idempotency tables so caller replay secrets do not enter logs.
Use the generic table API only inside wrapper modules, backend internals, or explicitly app-local legacy tables. Shared-domain runtime code should go through typed wrappers so table ownership stays localized. Wrappers are plain modules over the generic API; LemonCore.Store has no domain-specific chat-state, policy, or progress functions.
Specialized APIs
Shared-domain callers should prefer typed wrappers:
- Chat state:
LemonCore.ChatStateStore.put/2,get/1,delete/1(each also takes a store name first); stamps a 24h:expires_aton write, treats expired entries as absent on read, and the store's periodic sweep removes expired:chatentries - Run history:
LemonCore.RunStore.append_event/2,finalize/2,history/2,get/1 - Policies:
LemonCore.PolicyStore.put_agent/2,put_channel/2,put_session/2,put_runtime/1 - Idempotency:
LemonCore.IdempotencyStore.put/3,get/2,delete/2 - Introspection:
LemonCore.IntrospectionStore.append/1,list/1 - Project bindings:
LemonCore.ProjectBindingStore.get_override/1,put_override/2,get_dynamic/1 - Exec approvals:
LemonCore.ExecApprovalStore.get_pending/1,put_pending/2, policy getters/setters by scope
RunStore.finalize/2 returns :ok only after the immutable final summary and
its session index are written. Retrying the same summary repairs a partial index
failure without double-counting the run: the session entry remembers its last
32 indexed run ids, so its size stays fixed however many runs the session has.
Finalize hooks run after both writes and use at-least-once delivery, so hook
implementations must be idempotent by run id.
Agent workspace coordination — goals and kanban boards — is built on this Store but lives in lemon_agent as LemonAgent.Workspace.{GoalStore, KanbanStore}. Heartbeats use automation cron jobs and cron history.
ReadCache
LemonCore.Store.ReadCache maintains public ETS tables for hot domains (:runs, plus the default cached tables :sessions_index, :chat, and :progress, plus tables collaborators add with register_cached_table/1). Reads bypass the GenServer entirely for O(1) ETS lookup, while writes go through the GenServer which updates both the backend and cache atomically.
Backend Behaviour
Implementing a new backend requires the LemonCore.Store.Backend behaviour:
@callback init(opts()) :: {:ok, state()} | {:error, term()}
@callback put(state(), table(), key(), value()) :: {:ok, state()}
@callback put_new(state(), table(), key(), value()) :: {:ok, state()} | {:exists, state()} | {:error, term()}
@callback get(state(), table(), key()) :: {:ok, value() | nil, state()}
@callback delete(state(), table(), key()) :: {:ok, state()}
@callback list(state(), table()) :: {:ok, [{key(), value()}], state()}
SQLite serializes keys and values with :erlang.term_to_binary/1. The JSONL
backend uses a JSON codec that preserves atoms, tuples, structs, and nested map
keys for portable append-only files.
Event Bus
Standard Topics
| Topic | Purpose |
|---|---|
"run:<run_id>" |
Run-specific events |
"session:<session_key>" |
Session-scoped events |
"channels" |
Channel lifecycle |
"cron" |
Cron/automation events |
"exec_approvals" |
Approval requests/resolutions |
"nodes" |
Node pairing/invocation |
"system" |
Config reload, global events |
"logs" |
Log streaming |
Usage
# Subscribe
LemonCore.Bus.subscribe("run:" <> run_id)
# Receive
receive do
%LemonCore.Event{type: :delta, payload: payload} -> handle_delta(payload)
%LemonCore.Event{type: :completed, payload: payload} -> handle_completion(payload)
after
30_000 -> :timeout
end
# Broadcast
event = LemonCore.Event.new(:run_started, %{engine: "lemon"}, %{run_id: run_id})
LemonCore.Bus.broadcast("session:" <> session_key, event)
# Unsubscribe
LemonCore.Bus.unsubscribe("run:" <> run_id)
Session Keys
Session keys provide stable identifiers for routing and state management.
Formats
- Main:
agent:<agent_id>:main[:sub:<sub_id>] - Channel:
agent:<agent_id>:<channel_id>:<account_id>:<peer_kind>:<peer_id>[:thread:<thread_id>][:sub:<sub_id>]
Usage
key = LemonCore.SessionKey.main("my_agent")
# => "agent:my_agent:main"
key = LemonCore.SessionKey.channel_peer(%{
agent_id: "my_agent", channel_id: "demo",
account_id: "bot123", peer_kind: :dm, peer_id: "user456"
})
%{agent_id: id, kind: :main} = LemonCore.SessionKey.parse(key)
Execution Approvals
Tool execution gating with scope-based persistence. Placed in lemon_core so any app can request/resolve approvals without depending on :lemon_router.
Scopes
| Scope | Persistence |
|---|---|
:approve_once |
Not persisted (single request) |
:approve_session |
Persisted per session_key |
:approve_agent |
Persisted per agent_id |
:approve_global |
Persisted globally |
# Request (blocks until resolved)
case LemonCore.ExecApprovals.request(%{
run_id: run_id, session_key: session_key,
tool: "shell", action: %{command: "rm -rf /tmp/old"},
expires_in_ms: 60_000
}) do
{:ok, :approved, scope} -> proceed()
{:ok, :denied} -> halt()
{:error, :timeout} -> handle_timeout()
end
# Resolve (called by UI/admin). Atomic: a racing cancel/resolve on the same
# id has exactly one winner; the loser gets {:error, :not_pending}.
:ok = LemonCore.ExecApprovals.resolve(approval_id, :approve_session)
Approval requests, resolutions, and timeouts also write redacted
approval_* introspection events with approval id, tool, action type/hash, and
decision/scope metadata. Raw action payloads, prompts, and secrets are not
stored in the timeline. Timeouts also broadcast :approval_resolved with
decision: :timeout so operator clients can clear stale pending requests live.
Idempotency
At-most-once deduplication backed by the Store with 24h TTL.
result = LemonCore.Idempotency.execute("messages", msg_id, fn ->
perform_operation()
end)
Introspection
Canonical event persistence with redaction for tool arguments and sensitive fields.
:ok = LemonCore.Introspection.record(:tool_completed,
%{tool_name: "exec", result_preview: "ok"},
run_id: run_id, session_key: session_key, engine: "codex"
)
events = LemonCore.Introspection.list(run_id: run_id, limit: 50)
RouterBridge
Channel adapters forward runs to :lemon_router without compile-time coupling. The router registers itself at startup.
{:ok, run_id} = LemonCore.RouterBridge.submit_run(run_request)
:ok = LemonCore.RouterBridge.handle_inbound(inbound_message)
:ok = LemonCore.RouterBridge.abort_session(session_key, :user_requested)
{:ok, busy?} = LemonCore.RouterBridge.session_busy?(session_key)
{:ok, active_run_id} = LemonCore.RouterBridge.active_run(session_key)
{:ok, sessions} = LemonCore.RouterBridge.list_active_sessions()
An unregistered router returns {:error, :unavailable}. A configured mutating
callback that raises, throws, returns a malformed acknowledgement, times out,
or exits returns {:error, :outcome_unknown}: even a :noproc exit may follow
an earlier side effect inside a multi-step callback. Callers must not retry
automatically without independent idempotency or reconciliation. Read-only
query exits and throws remain unavailable because they cannot duplicate a side
effect. Query calls do not substitute false, :none, or []; callers must
handle unknown router state explicitly. Query exceptions return the sanitized
{:error, :query_failed} rather than carrying exception messages across the
bridge.
Configured implementations are validated against the bridge behaviours before
use. submit_run/1 accepts only {:ok, nonempty_binary_run_id} as success, and
an explicit {:error, reason} other than {:error, :outcome_unknown} means
the implementation guarantees the mutation did not take effect. Malformed
mutation answers are outcome-unknown; malformed query answers remain
{:error, {:unexpected_answer, value}}. Active-session lists are accepted only
when every entry has non-empty binary session_key and run_id fields.
For abort and keep-alive commands, :ok acknowledges that the router accepted
or dispatched the decision; it does not wait for synchronous application by
the target run process.
Failure logs contain only a sanitized callback MFA and failure class; raw
exceptions, exit reasons, stacktraces, and callback arguments are neither
logged nor returned for query exceptions.
Telemetry Events
| Event | Measurements | Metadata |
|---|---|---|
[:lemon, :run, :submit] |
%{count: 1} |
session_key, origin, engine |
[:lemon, :run, :start] |
%{ts_ms: ...} |
run_id |
[:lemon, :run, :first_token] |
%{latency_ms: ...} |
run_id |
[:lemon, :run, :stop] |
%{duration_ms: ..., ok: bool} |
run_id |
[:lemon, :run, :exception] |
%{} |
run_id, exception, stacktrace |
[:lemon, :channels, :inbound] |
%{count: 1} |
channel_id |
[:lemon, :approvals, :requested] |
%{count: 1} |
approval_id, tool |
[:lemon, :approvals, :resolved] |
%{count: 1} |
approval_id, decision |
[:lemon, :cron, :tick] |
%{job_count: ...} |
|
[:lemon, :config, :reload, :start/stop/exception] |
duration | reload_id, reason, sources |
Dependencies
| Dependency | Purpose |
|---|---|
jason |
JSON encoding/decoding |
toml |
TOML parsing |
telemetry |
Metrics and instrumentation |
Everything else is optional — lemon_core boots without it, with the
degradation noted below. UUIDs are generated by the vendored LemonCore.UUID.
| Optional dependency | Purpose | Without it |
|---|---|---|
phoenix_pubsub |
Bus transport | LemonCore.Bus falls back to a local-node Registry |
exqlite |
SQLite driver | SqliteBackend and RunHistoryStore are unavailable; use EtsBackend |
sentry + finch |
Error reporting sink | The Sentry logger handler is skipped |
file_system |
File watching for config reload | The reloader polls |
If you want run history, declare exqlite yourself
exqlite being optional has a consequence that is easy to miss: LemonCore.Application
only starts LemonCore.RunHistoryStore when the SQLite NIF is loadable. An application
that depends on lemon_core but does not itself declare exqlite boots fine, but run
history is disabled — get_run_history/3 returns [] and the finalize-run hooks that
persist history do nothing. The only signal is a warning at boot.
Optional dependencies are not inherited: add it to your own deps/0 to enable it.
{:exqlite, "~> 0.34"}
Durable memory has the same requirement in lemon_memory.
What the Hex package leaves out
The Hex tarball ships the library, not this repository's tooling: the
architecture/docs lint (LemonCore.Quality.*, mix lemon.quality,
mix lemon.architecture.docs, mix lemon.check_duplicate_tests), the
managed-install self-updater (LemonCore.Update.*, mix lemon.update), the
platform benchmark runner (mix lemon.bench) and the repo task catalog
(mix lemon.help) exist only in the lemon checkout.
Filesystem layout
Everything lemon_core reads or writes by convention hangs off a per-user state
directory (~/.lemon) and a per-project one (<project>/.lemon). The defaults
are the reference runtime's; LemonCore.Paths makes them configurable so a
host application can put its state somewhere not named after Lemon:
config :lemon_core, :paths,
state_dir: ".lemon", # per-user and per-project directory name
config_file: "config.toml", # file inside it
home_state_dir: nil, # pin the per-user directory outright
global_config: nil # pin the global config file outright
Renaming :state_dir moves everything derived from it: both config files, the
config reloader's watched paths, the default run-history store directory, and the
doctor's extension directory. Two locations
are configured separately because they are not part of that layout: the secrets
master key (config :lemon_core, LemonCore.Secrets, key_file: ...) and the
store backend path (config :lemon_core, LemonCore.Store, backend_opts: [path: ...]).
Testing
# Run all lemon_core tests
mix test apps/lemon_core
# Run specific test file
mix test apps/lemon_core/test/lemon_core/config_test.exs
# Run with coverage
mix test --cover apps/lemon_core
Test Harness
defmodule MyTest do
use LemonCore.Testing.Case, async: true
test "example", %{harness: harness, tmp_dir: tmp_dir} do
path = temp_file!(harness, "config.toml", "[agent]\ndefault_model = \"test\"")
key = unique_session_key("mytest")
end
end
Available helpers: unique_token/0, unique_scope/0, unique_session_key/0, unique_run_id/0, temp_file!/3, temp_dir!/2, clear_store_table/1, mock_home!/1, random_master_key/0.
Important Notes
- Never add umbrella app dependencies to
lemon_core-- it is the base layer - Keep module interfaces stable -- other apps depend on them
LemonCore.Config.load/2uses the cache by default;LemonCore.Config.reload/2forces a disk read- Secret values are never logged or returned by list/status APIs
- SQLite store values serialize with
:erlang.term_to_binary/1; JSONL uses a JSON codec with Elixir-term markers for portable persistence - Events use millisecond timestamps from
System.system_time(:millisecond) RouterBridgereturns{:error, :unavailable}when:lemon_routerhas not registered, and{:error, :outcome_unknown}when a configured mutation loses its acknowledgement; never automatically retry the latterDedupe.Etsuses monotonic time for TTL;Idempotencyuses wall-clock timeConfig.Modularis the newer typed approach;Configis still the primary interface