Configuration (config.json)
Every key Jenny reads from config.json, with the default value that actually ships in the code and what changing it does.
Most people never need this page: the Settings screen covers the common choices, and everything it writes ends up here anyway. Come here for the settings that have no UI — Dream, heartbeat, timezone, tool toggles, snapshot retention, model presets — and for exact defaults and ranges.
Where the file lives
Section titled “Where the file lives”On Android the file is <data_dir>/workspace/config.json, inside the app’s private storage (<filesDir>/workspace/). It is created on first boot with a minimal skeleton — a gateway.host and a per-install websocket.token_issue_secret — and then filled in by the onboarding wizard.
The Workspace file browser hides config.json by default. That is deliberate: it holds your API keys and the WebUI bootstrap secret, and it is the one file in the workspace that can stop the app from booting. Turn on Developer mode in Settings → System to see it.
Three things to know before you hand-edit it:
- Changes need an app restart. The only hot-reload path in the app is the WebUI settings screen, which reloads model and provider on the fly after it writes. Nothing watches
config.jsonfor external edits. - Broken JSON blocks boot. Config load raises on a malformed or invalid file, and the gateway retries three times before giving up — the WebUI never comes online, and there is no in-app editor to fix it from. Keep a copy of a known-good file before editing.
- Prefer Settings when the setting exists there. The UI validates ranges and writes atomically.
Key naming
Section titled “Key naming”Jenny writes camelCase (apiKey, maxTokens, intervalS), and this page uses camelCase throughout. snake_case is accepted everywhere on read (api_key, max_tokens, interval_s), so a hand-written config in either style loads fine — but a save from the UI rewrites the whole file in camelCase.
Two more parsing rules worth knowing:
- Unknown keys are ignored silently. A typo in a key name does not raise; the setting simply never applies. If an edit seems to do nothing, check the spelling first.
${VAR_NAME}in any string value is resolved from the environment at startup, in memory only. Resolved values are never written back, so editing through the WebUI preserves the placeholder. A referenced variable that is not set aborts startup withEnvironment variable 'NAME' referenced in config is not set. On Android there is no practical way to set environment variables for the app process, so this is a desktop/testing feature — on the phone, secrets live in the file (see Security model).
providers
Section titled “providers”The list of LLM endpoints you configured, plus which one is active. There is no built-in provider catalog: Jenny never infers an endpoint from a model name or key prefix.
{ "providers": { "providers": [ { "name": "deepseek", "format": "openai_compat", "apiKey": "sk-...", "apiBase": "https://api.deepseek.com/v1" } ], "default": "deepseek" }}| Key | Type | Default | Effect |
|---|---|---|---|
providers.providers[] |
array | [] |
The configured endpoints. Empty means no agent: the gateway still starts and serves the WebUI, but every turn fails with No provider configured. Add one in settings or config.json. |
providers.providers[].name |
string | required | Free-form identifier, referenced by providers.default and by modelPresets.<preset>.provider. |
providers.providers[].format |
"openai_compat" | "anthropic" |
required | Selects the wire format. The only field that decides which client is built. |
providers.providers[].apiKey |
string | null | null |
Credential, stored in clear text. Local servers that ignore auth still usually want a placeholder such as "EMPTY". |
providers.providers[].apiBase |
string | null | null |
Full base URL including the version path. When unset: https://api.openai.com/v1 for openai_compat, https://api.anthropic.com for anthropic. |
providers.providers[].apiType |
"auto" | "chat_completions" | "responses" |
"auto" |
openai_compat only, and only against a direct api.openai.com base. auto uses Chat Completions and probes the Responses API when a reasoning effort is requested or the model is a known OpenAI reasoning model, with a circuit breaker that stops probing after repeated failures. |
providers.providers[].extraHeaders |
object | null | null |
Headers merged into every request. |
providers.providers[].extraBody |
object | null | null |
Body fields deep-merged into every request. |
providers.providers[].extraQuery |
object | null | null |
Query parameters merged into every request. |
providers.default |
string | null | null |
Name of the active provider. When unset or unmatched, the first entry in the list is used. |
The onboarding wizard replaces the entire provider list with the single provider you enter. Details, error strings, and prompt-caching behavior: Providers and models.
agents.defaults
Section titled “agents.defaults”Everything about how the agent talks to the model and manages its own context.
Model and generation
Section titled “Model and generation”| Key | Type | Default | Effect |
|---|---|---|---|
agents.defaults.model |
string | "" |
Model ID sent to the provider. Empty until onboarding sets it. |
agents.defaults.maxTokens |
int | 8192 |
Max output tokens per request. |
agents.defaults.temperature |
float | 0.1 |
Sampling temperature. Ignored on models where the provider rejects it (Anthropic thinking modes force 1.0; a few Anthropic models omit it entirely). |
agents.defaults.reasoningEffort |
string | null | null |
null lets the model decide. Accepted: low, medium, high — plus two values the Settings dropdown does not offer: none (explicitly disable thinking) and adaptive (Anthropic adaptive thinking, which also enables interleaved thinking between tool calls). |
agents.defaults.contextWindowTokens |
int | 65536 |
Context budget used for prompt building and consolidation decisions. The settings endpoint accepts only 65536 or 262144 and rejects anything else; a hand-edited file can hold other values. If a request still overflows the model’s real context window, the runtime retries (up to 2 times per turn) with a reduced window for the rest of that turn — using the limit parsed from the provider’s error message when it can, otherwise shrinking the window by 50% as a heuristic. |
agents.defaults.toolChoice |
"auto" | "any" | "none" | "required" |
"auto" |
How aggressively the model is pushed to call a tool. |
agents.defaults.modelPreset |
string | null | null |
Name of the preset applied at startup. When null, the direct fields above are used. |
The three “Advanced parameters” fields in Settings — Max Tokens, Temperature, Reasoning Effort — currently do not save. The UI posts them and shows “Saved!”, but the backend update handler reads only
model,default_provider,context_window_tokens,timezone,bot_name,bot_iconandtool_hint_max_length. Editingconfig.json(or defining a model preset) is the only way to actually change those three today.
Context, memory and sessions
Section titled “Context, memory and sessions”| Key | Type | Default | Effect |
|---|---|---|---|
agents.defaults.idleCompactAfterMinutes |
int ≥ 0 | 15 |
Minutes of idle time before the session context is proactively compacted (summary + the most recent messages, rewritten into the session file). 0 disables it, leaving only the token-driven consolidation that never rewrites the file. Accepted under its legacy name sessionTtlMinutes too. |
agents.defaults.maxMessages |
int ≥ 0 | 120 |
Upper bound on live messages kept in the session before consolidation kicks in. |
agents.defaults.consolidationRatio |
float 0.1–0.95 | 0.5 |
Fraction of the live context consolidated when a consolidation runs. |
agents.defaults.dream.enabled |
bool | true |
Registers the periodic Dream memory-consolidation job at startup. |
agents.defaults.dream.intervalH |
int ≥ 1 | 2 |
Hours between Dream runs. The interval restarts on every app launch. |
agents.defaults.maxToolIterations |
int | 200 |
Hard ceiling on tool calls in a single turn. |
agents.defaults.maxToolResultChars |
int | 16000 |
Tool output above this is truncated before it reaches the model. |
agents.defaults.contextBlockLimit |
int | null | null |
Optional cap on context blocks; unset means no extra limit. |
dream has exactly two fields — enabled and intervalH. Older docs mentioned cron, modelOverride and maxBatchSize; none of them exist. See Memory and Dream.
Behavior and identity
Section titled “Behavior and identity”| Key | Type | Default | Effect |
|---|---|---|---|
agents.defaults.timezone |
string | "" |
Empty means auto: the device timezone detected at startup, falling back to UTC only when detection fails. Resolved once per config load, and written back as "" when it still matches the device — so it keeps following the phone. Set an IANA name ("Europe/Rome") to pin it. Drives runtime time context, cron schedules without an explicit tz, and one-shot at times without an offset. |
agents.defaults.botName |
string | "Jenny" |
Assistant name in chat and in the welcome message. Requires a restart to fully apply. |
agents.defaults.botIcon |
string | "✿" |
Emoji shown next to the name. No UI field; restart to apply. |
agents.defaults.language |
string | "it" |
Language for backend-generated text (welcome message and similar). Written once by onboarding from the UI locale. Not the UI language — that lives in the device’s localStorage. |
agents.defaults.maxConcurrentSubagents |
int ≥ 1 | 1 |
How many spawned subagents may run at once. Beyond the limit, spawn returns an error so the agent can wait or reorder its work. |
agents.defaults.toolHintMaxLength |
int 20–500 | 40 |
Truncation length of the short tool-call hints shown while the agent works. |
agents.defaults.disabledSkills |
string[] | [] |
Skill directory names excluded from the agent’s skill summary, always-on injection, and subagent summaries. Applies to built-in and workspace skills alike. |
agents.defaults.providerRetryMode |
"standard" | "persistent" |
"standard" |
Retry policy for provider errors. persistent keeps retrying transient failures longer. |
Top-level keys
Section titled “Top-level keys”| Key | Type | Default | Effect |
|---|---|---|---|
extractDocumentText |
bool | false |
When false (the default), non-image attachments are saved under workspace/uploads/ and only referenced by path — the agent reads them on demand with its file tools. Setting it to true restores the legacy behavior of extracting and inlining document text into the prompt on every turn. See Files and attachments. |
gateway
Section titled “gateway”| Key | Type | Default | Effect |
|---|---|---|---|
gateway.host |
string | "127.0.0.1" |
Bind address. |
gateway.port |
int | 18790 |
HTTP port. |
gateway.heartbeat.enabled |
bool | true |
Registers the built-in heartbeat cron job at startup. It reads workspace/HEARTBEAT.md and acts only on the ## Active Tasks section. |
gateway.heartbeat.intervalS |
int ≥ 1 | 1800 |
Seconds between heartbeat checks (30 minutes). Every cycle that finds a task costs an LLM call. |
gateway.heartbeat.keepRecentMessages |
int | 8 |
Messages retained in the internal heartbeat session after each run. |
On the phone, host and port are imposed by the Android runtime. The service calls the gateway entry point with 127.0.0.1:18790 explicitly, which overwrites whatever the file says — both for the HTTP API and for the WebSocket, which share that single port. Editing them in config.json changes nothing on-device; they only matter when running the gateway yourself for local testing.
The heartbeat job is stored like any other cron job (<workspace>/cron/jobs.json) and appears in cron(action="list") as heartbeat, but it is system-managed and cannot be removed with the cron tool — disable it here and restart. See Scheduling and proactivity.
websocket
Section titled “websocket”The channel the WebUI talks over. On-device, the runtime forces host and port onto this section too, so the WebView reaches chat and the HTTP API from one origin.
| Key | Type | Default | Effect |
|---|---|---|---|
websocket.enabled |
bool | false |
Declared, but not consulted by the current wiring: the channel is built whenever a non-empty websocket section exists, and the Android runtime always writes one. Setting it to false does not disable the channel today. |
websocket.host |
string | "127.0.0.1" |
Bind address. Forced to the runtime value on-device. |
websocket.port |
int | 8765 |
Off-device default. On the phone this is always overwritten with 18790. |
websocket.path |
string | "/" |
WebSocket path. Must start with /. |
websocket.tokenIssueSecret |
string | "" |
The install’s shared secret. Generated once at first boot (32 random URL-safe bytes) and persisted here with chmod 600. It authenticates both the WebSocket handshake (?token=…) and the HTTP API (Authorization: Bearer … or X-Jenny-Auth:). Never regenerate it casually — the WebUI receives it through the bootstrap route. |
websocket.websocketRequiresToken |
bool | true |
Requires the token in the handshake. |
websocket.allowFrom |
string[] | ["*"] |
Client-ID allowlist for connections. This is the real key — there is no channels.*.allowFrom anywhere in the codebase. |
websocket.streaming |
bool | true |
Stream assistant text as it is generated. |
websocket.sendProgress |
bool | true |
Send progress events to the WebUI. |
websocket.sendToolHints |
bool | false |
Send the short tool(args…) hints as progress events. |
websocket.showReasoning |
bool | true |
Config-only. Delivers the model’s reasoning stream to the WebUI. Turning it off also stops it being recorded: the transcript append happens inside the same send path the dispatcher gates on this flag, so with it off the reasoning is absent from replay and history too, not just from the live view. Telegram never receives reasoning regardless. |
websocket.sendMaxRetries |
int 0–10 | 3 |
Delivery attempts per outbound message, including the first send. Backoff 1 s, 2 s, 4 s, then capped at 4 s. |
websocket.maxMessageBytes |
int 1024–41943040 | 37748736 |
Max inbound frame size (36 MiB), sized for four ~6 MB images after client-side normalization plus base64 overhead. |
websocket.pingIntervalS |
float 5–300 | 20.0 |
Keepalive ping interval. |
websocket.pingTimeoutS |
float 5–300 | 20.0 |
Keepalive ping timeout. |
websocket.sslCertfile / websocket.sslKeyfile |
string | "" |
Optional TLS material for off-device deployments. |
One validation to be aware of: setting host to 0.0.0.0 or :: with an empty tokenIssueSecret is rejected at load — the config raises rather than exposing an unauthenticated agent on every interface. Wire-level details for integrators: WebSocket protocol.
telegram
Section titled “telegram”| Key | Type | Default | Effect |
|---|---|---|---|
telegram.enabled |
bool | false |
Master switch. The channel starts only when this is true and a bot token is present. |
telegram.botToken |
string | null | null |
BotFather token, stored in clear text. The API never returns it in full — the settings payload carries only a first4...last4 hint. |
telegram.botUsername |
string | null | null |
Cached bot username from getMe. |
telegram.pairedChatId |
string | null | null |
The paired chat. Its presence is the “paired” state — there is no stored enum. |
telegram.pairedUsername |
string | null | null |
Display name of the paired user. |
telegram.pairingCode |
string | null | null |
The 6-digit code, persisted so pairing survives the frequent process restarts on Android, and cleared once pairing succeeds. |
telegram.pollTimeoutS |
int 1–300 | 50 |
Long-poll timeout against the Telegram API. |
Pairing, the throttle, and the asymmetric view between Telegram and the WebUI: Telegram bridge.
Toggles for the built-in tool groups. Only web search and location have UI controls; everything else here is config-only. Full behavior of each tool: Tool reference.
tools.file
Section titled “tools.file”| Key | Type | Default | Effect |
|---|---|---|---|
tools.file.enable |
bool | true |
Registers the filesystem tools (read_file, write_file, edit_file, apply_patch, list_dir, find_files, grep). Off means the agent cannot touch files at all. |
tools.file.exposePackageSource |
bool | true |
Adds Jenny’s own extracted Python source as an extra read-only root, so the agent can inspect the framework it runs on. Never writable. |
tools.pythonExec
Section titled “tools.pythonExec”| Key | Type | Default | Effect |
|---|---|---|---|
tools.pythonExec.enable |
bool | true |
Registers python_exec and the exec-session tools. |
tools.pythonExec.timeout |
int ≥ 0 | 60 |
Seconds per execution. 0 means no limit. |
tools.pythonExec.maxOutputChars |
int 1000–50000 | 10000 |
Output truncation threshold. |
tools.pythonExec.allowedModules |
string[] | see below | Import allowlist. |
tools.pythonExec.blockedModules |
string[] | see below | Import denylist. |
Defaults — allowed: os, sys, pathlib, json, re, math, datetime, collections, itertools, functools, typing, io, shutil, glob, hashlib, base64, asyncio, csv, platform, time, struct, textwrap, unicodedata, html, xml, dataclasses, enum, uuid. Blocked: subprocess, pty, shlex, multiprocessing, ctypes, socket, signal, termios, tty, grp, pwd, resource, syslog, curses, readline, _thread, fcntl.
httpx and urllib are deliberately absent from the allowlist. A raw HTTP client inside executed code could reach loopback, link-local and RFC1918 targets (SSRF) or read local files through file://, bypassing both the SSRF policy and the workspace policy. Outbound HTTP stays available through the http_get / http_post helpers, which run every URL through the SSRF check. Adding "httpx" back to allowedModules opts out of that protection knowingly. And note what the code itself says: the allowlist is a guardrail, not a sandbox.
tools.androidWeb
Section titled “tools.androidWeb”| Key | Type | Default | Effect |
|---|---|---|---|
tools.androidWeb.enable |
bool | true |
Registers web_search and web_fetch. This is the only switch the code checks — the nested search.enable / fetch.enable fields exist in the schema but are not consulted, so they cannot disable one half on their own. |
tools.androidWeb.search.searchEngine |
string | "bing" |
The only supported value; the settings endpoint rejects anything else. |
tools.androidWeb.search.maxResults |
int | 5 |
Results per search. The UI validates 1–10. |
tools.androidWeb.search.timeout |
int | 30 |
Seconds per search. The UI validates 1–120. Also used as the fetch timeout. |
tools.androidWeb.fetch.maxChars |
int | 50000 |
Max characters extracted per page. The UI validates 1000–200000. |
tools.location
Section titled “tools.location”| Key | Type | Default | Effect |
|---|---|---|---|
tools.location.enable |
bool | true |
The user-facing toggle. With it on and the Android permission granted, a last-known position line is injected into the context on every turn — which means it reaches your LLM provider on every turn. |
tools.location.telegramTtlS |
int ≥ 60 | 3600 |
How long a location shared through Telegram overrides the GPS reading, for that channel only. Kept in memory, lost on restart. |
tools.location.freshTimeoutS |
int 1–60 | 15 |
How long get_location precise=true waits for a fresh GPS fix. |
See Location.
tools.my, introspect, diagnostics
Section titled “tools.my, introspect, diagnostics”| Key | Type | Default | Effect |
|---|---|---|---|
tools.my.enable |
bool | true |
Registers the my self-inspection tool. |
tools.my.allowSet |
bool | false |
The only restrictive default in the whole tools section. When false, my is read-only and a write attempt returns Error: set is disabled (tools.my.allow_set is false). |
tools.introspect.enable |
bool | true |
Registers get_source — read-only access to Jenny’s own package source. |
tools.diagnostics.enable |
bool | true |
Registers get_recent_logs (in-memory buffer, ~500 lines, cleared on restart). It is the first stop for troubleshooting: ask Jenny to check her own logs. |
tools.restrictToWorkspace also appears under tools — it is a mirror, not a setting. See below.
security
Section titled “security”The canonical home for the two policy switches.
| Key | Type | Default | Effect |
|---|---|---|---|
security.restrictToWorkspace |
bool | true |
Keeps workspace-aware tools inside the workspace (plus skills/, the media directory, and the read-only package source). This is an application-level, fail-closed policy boundary, not an OS sandbox. |
security.ssrfWhitelist |
string[] | [] |
CIDR ranges exempted from the SSRF guard, e.g. ["100.64.0.0/10"] for Tailscale. Keep entries as narrow as possible (192.168.1.50/32). |
Two things people get wrong here:
- The old location still loads, but is not where you edit. A legacy config carrying
tools.restrictToWorkspace/tools.ssrfWhitelistand nosecurityblock is migrated intosecurityautomatically by a validator, andtools.restrictToWorkspaceis then kept in sync as a mirror the tool layer reads. Write tosecurity. - The SSRF whitelist covers agent tools, not provider calls. It gates
web_fetch,download_file, thepython_execHTTP helpers, and media ingestion. Requests to your LLM endpoint do not go through it — a self-hosted model on a private address works without whitelisting anything (see Local models).
Full threat model: Security model.
workspace
Section titled “workspace”These govern the WebUI Workspace tab, not the agent’s file tools — the agent is bounded by security.restrictToWorkspace instead.
| Key | Type | Default | Effect |
|---|---|---|---|
workspace.enabled |
bool | true |
Off makes every /api/workspace/* route answer 503 workspace is disabled — the Workspace tab stops working. |
workspace.maxFileSize |
int | 1000000 |
Max bytes the file viewer will read (1 MB). |
workspace.allowWrite |
bool | true |
Off makes write, mkdir, rename and copy answer 403 workspace writes are disabled. |
workspace.allowDelete |
bool | true |
Off makes delete answer 403 workspace deletes are disabled. |
snapshots
Section titled “snapshots”Local versioning of the workspace, plus the key derivation used by encrypted backup export. Snapshots are created by the runtime without involving the LLM.
| Key | Type | Default | Effect |
|---|---|---|---|
snapshots.enabled |
bool | true |
Master switch for automatic snapshots. |
snapshots.scanIntervalMinutes |
int ≥ 1 | 5 |
How often the workspace is scanned for changes. |
snapshots.quietMinutes |
int ≥ 1 | 10 |
Quiet period after the last change before a snapshot is taken. |
snapshots.dailySafetySnapshot |
bool | true |
Takes one snapshot a day even without qualifying changes. |
snapshots.retentionRecent |
int ≥ 1 | 20 |
The most recent N snapshots are always protected from pruning, including from the age horizon. |
snapshots.retentionThinAfterDays |
int ≥ 1 | 30 |
Beyond this age, history is thinned to roughly one snapshot per day. |
snapshots.retentionMaxAgeDays |
int ≥ 0 | 0 |
Age horizon in days; 0 means keep forever. The Settings selector maps to 7 / 30 / 365 / 0. Changing retention prunes immediately. |
snapshots.pbkdf2Iterations |
int 100000–10000000 | 600000 |
PBKDF2 iterations for the exported .jbk backup key. The ceiling mirrors the container format’s own limit. |
snapshots.excludeGlobs |
string[] | see below | Paths never captured. |
Default excludes: ui/**, logs/**, .jenny/logs/**, .jenny/snapshots/**, .jenny/backup_staging/**, **/__pycache__/**, *.tmp, *.tmp.*. See Backup and restore.
| Key | Type | Default | Effect |
|---|---|---|---|
apps.enabled |
bool | true |
Enables the Jenny Apps runtime and the dynamic <slug>_<action> tools it registers. |
apps.httpTimeoutS |
float 1–120 | 20.0 |
Timeout for an app’s outbound HTTP proxy calls. |
apps.maxCollectionBytes |
int | 5000000 |
Per-collection storage ceiling (5 MB). Writes past it fail with 413. |
See Mini-apps.
| Key | Type | Default | Effect |
|---|---|---|---|
wiki.enabled |
bool | true |
Off makes every wiki route answer 503. |
wiki.wikisDir |
string | "wikis" |
Directory holding the wikis, relative to the workspace. |
wiki.defaultWiki |
string | "main" |
Wiki opened when none is specified. |
wiki.extensions |
string[] | ["fenced_code", "tables", "toc", "wikilinks", "mermaid"] |
Python-Markdown extensions used to render wiki pages. Mermaid renders here and only here — not in chat. |
See Wiki.
modelPresets
Section titled “modelPresets”Named bundles of model settings you can switch between at runtime with /model <name>. modelPresets is a top-level object; its keys are your own preset names.
{ "modelPresets": { "fast": { "label": "Fast", "provider": "openai", "model": "gpt-4.1-mini", "maxTokens": 4096, "contextWindowTokens": 65536, "temperature": 0.2, "reasoningEffort": "low" }, "deep": { "label": "Deep", "provider": "anthropic", "model": "claude-opus-4-5", "maxTokens": 8192, "reasoningEffort": "adaptive" } }, "agents": { "defaults": { "modelPreset": "fast" } }}| Field | Type | Default | Effect |
|---|---|---|---|
label |
string | null | null |
Display name. |
provider |
string | null | null |
Name of a configured provider entry. |
model |
string | null | null |
Model ID. Falls back to the current model when null. |
maxTokens |
int | null | null |
Overrides output tokens while the preset is active. |
contextWindowTokens |
int | null | null |
Overrides the context budget. Falls back to the current value when null. |
temperature |
float | null | null |
Overrides the sampling temperature. |
reasoningEffort |
string | null | null |
Per-preset reasoning effort — including none and adaptive. This is the tidiest way to keep several thinking levels one command apart. |
How switching behaves:
/modelwith no argument prints the current model, the active preset ((none)when unset), and the available preset names./model <name>applies the preset for future turns — model, context window, and generation settings on the active provider. An unknown name returnsCould not switch model preset: …followed by the available names.- A preset’s
providerfield selects request routing where the active provider supports it; provider objects are not swapped mid-run, so a preset pointing at a genuinely different backend needs an app restart (or a provider change from Settings, which does hot-reload). - Runtime switches are not written back to
config.json.agents.defaults.modelPresetdecides what you start with after a restart.
Cross-references
Section titled “Cross-references”- Settings — the UI-first view of the settings that have screens
- Providers and models — choosing formats, base URLs, and models
- Tool reference — what each tool actually does with these toggles
- Security model — workspace policy, SSRF, and where the real boundaries are
- Memory and Dream, Scheduling and proactivity, Telegram bridge, Backup and restore
- Troubleshooting — what to do when a config change breaks the boot
