Skip to content

Architecture

This page maps Jenny’s runtime behavior to source files; use it when debugging internals, reviewing a PR, or adding a provider/channel/tool.

For the product-level mental model, read Concepts first.

flowchart LR
WS["WebSocket Channel<br/>WebUI"] --> Bus["MessageBus<br/>InboundMessage"]
TG["Telegram Channel<br/>(optional, paired)"] --> Bus
Bus --> Loop["AgentLoop<br/>session, workspace, context"]
Loop --> Runner["AgentRunner<br/>provider/tool loop"]
Runner --> Provider["Provider<br/>LLM backend"]
Provider --> Runner
Runner --> Tools["Tools<br/>files, python_exec, android_web,<br/>cron, spawn, apps..."]
Tools --> Runner
Runner --> Loop
Loop --> Outbound["MessageBus<br/>OutboundMessage"]
Outbound --> Dispatcher["WebSocketDispatcher<br/>fan-out + retry + coalescing"]
Dispatcher --> WS
Dispatcher --> TG
Loop -. reads/writes .-> State["Session, memory,<br/>hooks, skills, templates"]

Main files:

Area Files
Message events and queue jenny/bus/events.py, jenny/bus/queue.py
Turn orchestration jenny/agent/loop.py (+ mixins: turn_states.py, turn_persistence.py, loop_provider.py, loop_tasks.py)
Provider/tool conversation loop jenny/agent/runner.py (+ mixins: request_execution.py, tool_execution.py)
Context construction jenny/agent/context.py
Session storage and compaction jenny/session/manager.py, jenny/agent/autocompact.py
Long-term memory and Dream jenny/agent/memory.py
Composition root jenny/runtime/container.py (GatewayContainer)
Runtime state (workspace dir, Android context, config path override) jenny/runtime/context.py (RuntimeContext)

AgentLoop owns the channel-facing turn:

  • receives inbound messages from the bus;
  • determines the effective session and workspace scope (always the unified session on the WebUI/Telegram path; internal jobs use separate session keys);
  • builds context (workspace files, skills, memory, recent messages, channel metadata);
  • wires hooks, progress reporting, and channel metadata;
  • publishes outbound messages back to the bus.

AgentRunner owns the model-facing loop:

  • sends messages to the selected provider;
  • handles streaming deltas and reasoning blocks;
  • executes tool calls and feeds results back into the model;
  • stops when a final answer is produced or a runtime limit is hit (max_tool_iterations, default 200).

If a problem is about channel routing, session keys, workspace selection, or outbound delivery, start in jenny/agent/loop.py. If it is about provider calls, tool calls, streaming, or iteration limits, start in jenny/agent/runner.py.

Providers are user-defined ProviderConfig entries in jenny/config/schema.py (providers.providers); there is no built-in provider catalog.

Provider selection and construction:

  • Config.get_active_provider() returns the entry named by providers.default, otherwise the first entry in the list;
  • jenny/providers/factory.py::make_provider() builds the backend from that entry’s format: "anthropic"AnthropicProvider, everything else → OpenAICompatProvider (with an OpenAIResponsesConverter path when apiType selects the Responses API);
  • the backend is built once when the gateway starts (GatewayContainer.build()), but it is not fixed for the process lifetime — see the hot-reload note below.

Provider and model changes made from the Settings screen apply without an app restart. GatewayContainer._on_settings_changed() (jenny/runtime/container.py) is wired as a callback into the WebUI settings routes; when it fires it reloads config.json, rebuilds the provider via make_provider(), and — if the resolved model or the provider’s apiBase actually changed — calls agent._apply_provider_switch(new_provider, new_model, new_ctx) to swap the live agent’s provider and model in place. A restart is only needed when config.json is hand-edited on disk outside the Settings UI (nothing watches the file for external changes), or when a change is flagged requires_restart by the backend — update_agent_settings() in jenny/webui/settings_api.py sets that flag for timezone, bot_name, bot_icon, and tool_hint_max_length changes specifically (model/provider changes are not in that list and hot-reload as described above). As of this writing the WebUI never surfaces that flag to the user, so a restart-flagged change can silently need an app relaunch to take effect.

Provider implementations live in jenny/providers/. Most endpoints use the OpenAI-compatible implementation; Anthropic and the OpenAI Responses API (jenny/providers/openai_responses/) have specialized paths.

Useful docs:

Jenny has two channels, both owned by WebSocketDispatcher (jenny/channels/dispatcher.py), which fans outbound bus messages out to whichever channels are active:

Channel File Notes
WebSocket (WebUI) jenny/channels/websocket.py (+ ws_sender.py, ws_parsing.py) Always enabled on Android; serves the mobile WebUI over the same port as the HTTP API.
Telegram jenny/channels/telegram.py Optional, personal-bot channel; created only if telegram.enabled is true and a bot_token is set. Paired via a 6-digit code; both channels share the single unified session, so /new from either one resets the other too.

WebSocketDispatcher also owns retry, delta coalescing for streaming updates, and progress-message filtering per channel — see dispatcher.py. This is a decomposition, not a generic channel registry: the two channels are wired explicitly in _init_channel()/_init_telegram(), not discovered.

Useful docs:

The gateway (started by the Android runtime via jenny.android_entry.run_gateway(data_dir, android_context, port=18790)) starts:

  • the WebSocket channel and, if configured, the Telegram channel (via WebSocketDispatcher);
  • the workspace-scoped cron service;
  • system jobs such as Dream and the heartbeat;
  • the HTTP API routes under /api/ (settings, apps, media, skills, wiki, transcript, backup…) served from the same asyncio process.

There is no /health endpoint. No route named health exists anywhere in jenny/channels/ or jenny/webui/. On Android, run_gateway() forces host="127.0.0.1" and port=18790 for both the WebSocket handshake and the HTTP API (_apply_gateway_overrides() in jenny/gateway_runtime.py sets config.gateway.port, config.websocket["port"], and defaults config.websocket["enabled"] to true) — WebSocket and HTTP genuinely share one origin so the WebView can reach both without CORS. gateway.port defaults to 18790 in the schema; websocket.port independently defaults to 8765 and websocket.enabled defaults to false in jenny/config/schema.py — those are the desktop-testing defaults, and the Android entry point always overrides them at startup.

The packaged WebUI is served from jenny/templates/ui/ and rendered inside the Android app’s WebView.

Useful docs:

Tools are explicitly registered, not discovered by scanning the filesystem. jenny/agent/tools/loader.py imports a fixed list of 16 modules (_HARDCODED_TOOL_MODULES); each module declares a module-level TOOLS = [...] list of Tool subclasses. ToolLoader.discover() imports every module in the list, in order, and collects each module’s TOOLS; a module with no TOOLS attribute at all raises at startup rather than silently contributing nothing, and a name collision between two registered tools also raises at startup instead of one silently overwriting the other.

# Module TOOLS Tool area
1 filesystem.py ReadFileTool, WriteFileTool, EditFileTool, ListDirTool Read/write/edit files and list directories, workspace-scoped
2 python_exec.py PythonExecTool In-process Python execution on the Chaquopy interpreter
3 android_web.py AndroidWebSearchTool, AndroidWebFetchTool Web search/fetch via a hidden Android WebView
4 download.py DownloadFileTool Downloads a URL into workspace/downloads/, per-hop SSRF-checked
5 location.py GetLocationTool On-demand fresh GPS fix (distinct from the last-known location injected into every turn’s context)
6 long_task.py LongTaskTool, CompleteGoalTool Sustained/background goal tracking (/goal)
7 spawn.py SpawnTool Spawns a subagent, blind to the parent conversation
8 cron.py CronTool Create/list/cancel reminders and scheduled jobs
9 self.py (none — see below) Declares MyTool but registers it manually, not through TOOLS
10 search.py FindFilesTool, GrepTool Filename and content search inside the workspace
11 message.py MessageTool Sends a proactive message (with attachments/buttons) outside the current turn
12 apply_patch.py ApplyPatchTool Atomic multi-file patch application with rollback
13 exec_session.py ListExecSessionsTool, WriteStdinTool Manage long-running python_exec sessions (poll/stdin/terminate)
14 introspect.py GetSourceTool Read-only access to Jenny’s own bundled Python source
15 diagnostics.py GetRecentLogsTool Reads the in-memory log ring buffer
16 ui_view.py UiViewTool Pull-based view of what’s on screen right now; fails from Telegram, cron, or with the screen off

self.py’s module-level TOOLS list is deliberately empty. MyTool (the my introspection/self-check tool) needs a live reference to the running AgentLoop, which the generic loader can’t provide, so it is instantiated and registered by hand in AgentLoop._register_default_tools() (jenny/agent/loop.py:362-365), gated on tools.my.enable.

ToolLoader.discover() therefore returns 22 tool classes across those 16 modules (verified by running it directly), plus the one manually-registered MyTool — 23 built-in tool classes in total. Not all of them are necessarily registered at runtime: ToolLoader.load() also checks each tool’s enabled(ctx) against the current config and the tool’s declared scope, so the live tool count for a given install depends on config toggles. On top of the built-ins, Jenny Apps register their own dynamic <slug>_<action> tools per turn (AppToolsSyncer) — see Mini-apps and Tool reference for the full, toggle-aware picture.

Tool behavior is part of the model contract: user-visible tool names, schemas, and error messages should be treated as an interface — changing them affects how the model uses the tool, so keep changes intentional and covered by tests.

The config schema lives in jenny/config/schema.py. Loading and saving live in jenny/config/loader.py. Runtime path helpers live in jenny/config/paths.py, backed by the single source of truth in jenny/runtime/context.py::RuntimeContext.

On Android, the workspace root is <filesDir>/workspace — app-private storage set once via set_workspace_dir() when the gateway starts (jenny/android_entry.py), not a path under the project checkout.

Defaults (all relative to the workspace root unless noted):

Path Default
Config workspace/config.json
Workspace <filesDir>/workspace (Android)
Sessions <workspace>/sessions/*.jsonl
Memory <workspace>/memory/ (MEMORY.md, history.jsonl)
Cron store <workspace>/cron/jobs.json
Uploads (chat attachments) <workspace>/uploads/
Runtime data (media, WebUI display threads) <workspace>/.jenny/ (media/, webui/) — migrated automatically from the legacy .minijenny//.nanobot/ names if found. Runtime logs are not written here; they only live in the in-memory ring buffer the get_recent_logs tool reads (see Tools below)
Snapshots (workspace version history) Sibling of the workspace directory, so a restore’s atomic swap doesn’t take snapshot history with it

The schema accepts both camelCase and snake_case keys on read, but Config.save() always writes config.json back out with camelCase aliases.

Session history is the near-term conversation replay. Memory is the longer-term workspace state that survives session compaction.

Store File area Notes
Session JSONL <workspace>/sessions/*.jsonl Recent conversation turns, replayed into context; compacted on idle (agents.defaults.idleCompactAfterMinutes, default 15 min) and capped by max_messages (default 120)
Long-term memory <workspace>/memory/MEMORY.md Facts and durable context Dream writes back
Consolidation source history <workspace>/memory/history.jsonl Append-only log Dream reads from; capped at 1000 entries (oldest dropped first)
Bootstrap identity files <workspace>/SOUL.md, <workspace>/USER.md, seeded from jenny/templates/ Identity/persona and user-facts files Dream also updates

Dream is implemented in jenny/agent/memory.py and scheduled by the runtime when agents.defaults.dream.enabled is true (default true, every dream.interval_h hours, default 2). Its interval is a periodic-from-boot schedule, not wall-clock-anchored, so it restarts counting from zero every time the app (and therefore the gateway process) restarts. See Memory and Dream for the user-facing behavior.

Security-sensitive code paths include:

Boundary Files
Workspace scope jenny/security/workspace_access.py, jenny/security/workspace_policy.py
SSRF/network checks jenny/security/network.py, jenny/agent/tools/android_web.py, jenny/agent/tools/download.py
Channel access control jenny/channels/websocket.py, jenny/channels/telegram.py

When changing tools, file access, WebUI workspace behavior, or network fetching, treat security as part of the functional behavior and update the docs if the user-facing boundary changes. See Security model for the full four-level breakdown.

Extension How
Provider Add a ProviderConfig entry in config (no code needed); for a genuinely new wire format, subclass LLMProvider in jenny/providers/base.py and add a branch in jenny/providers/factory.py
Channel Implement a channel under jenny/channels/ and wire it explicitly into WebSocketDispatcher
Tool Implement a Tool subclass under jenny/agent/tools/, add its module to _HARDCODED_TOOL_MODULES in loader.py, and declare it in that module’s TOOLS = [...] list — see Write a tool
Skill Add workspace skill files under <workspace>/skills/ or built-in skills under jenny/skills/

Prefer the existing explicit-registration patterns over ad hoc wiring — this codebase deliberately avoids implicit discovery (see the loader’s own comment on why reflection-based TOOLS discovery was replaced with an explicit list).

Common checks:

Terminal window
ruff check jenny/ tests/
npx pyright jenny/bus jenny/command jenny/runtime jenny/session
pytest -q

Choose tests based on the changed surface:

Change Minimum useful verification
Provider behavior Provider unit tests or a mocked API path
WebUI behavior WebUI tests plus browser-level verification through the gateway
Tool behavior Tool unit tests and an agent-run path when schema or model-facing behavior changes
Docs Link checks, command accuracy against schema, and a diff review for stray whitespace

For user-facing flows, prefer at least one verification path through the surface a user actually touches: the WebSocket/WebUI, an /api/ HTTP route, or (for tools) a real agent turn.

See Testing and CI for the full command set.

Jenny · code underAGPL-3.0 · name and mascot undertrademark

No cookies, no trackers, no analytics. This page loads nothing from a third party.