Configuration
Enso’s configuration and normal runtime state live under one home directory, ~/.enso
unless ENSO_HOME is set. See Concepts for the layout and the
integration-specific user-level state Enso may inspect or create.
enso config check validates the file and lists every problem at once; enso serve
refuses to start on a problem. enso config show prints it with tokens redacted.
Applying configuration
enso config apply --file FILE validates and atomically replaces the complete document;
--file - reads it from stdin. Input is strict UTF-8 JSON, at most 1 MiB. This is full
replacement, with no merging of omitted fields. Validation problems are reported together
and leave the current file unchanged. Successful writes use mode 0600 (owner read/write).
Transport tokens stay literal in this file; environment placeholders are not expanded.
For an update based on an earlier read, pass --expected-hash HASH, using the exact-byte
SHA256 from enso config check --json or the previous apply. Use missing to require that
no config exists. A stale hash or busy writer lock refuses the change. Enso’s writers share
~/.enso/.config.lock; do not delete that file while Enso commands are running. Programs
editing config themselves should use the apply command and revision check to participate in
that protection. A symbolic-link config.json is preserved and refused by apply.
Apply installs only missing bundled jobs after a valid default agent is known, using that
agent for their initial configuration. It preserves any existing job directory, including
edited jobs or deleted scripts. Each new job appears as a complete directory, so an interrupted
installation is retryable. If config was saved but job installation failed, JSON reports
applied: true, jobs_complete: false, and ok: false; fix the filesystem problem and
repeat apply with the returned revision. Apply does not start services or send messages.
It works while the service is running: the write is atomic, and the next chat turn or
scheduler tick reads the new document. Apply refuses while a pairing attempt is active,
hosted or in the setup wizard, and when the pairing state cannot be read. During an
update the CLI itself refuses new commands, so a write never lands
in an update’s snapshot.
Patching one value
enso config set PATH VALUE stores one value in the current config.json and
enso config unset PATH removes one key. PATH is dotted: each segment is an object key,
so a workspace, provider, binding, or project name is just a segment, and set creates
the objects on the way when they are missing. VALUE is JSON; text that is not valid JSON
is stored as a string, so enso config set defaults.model opus and
enso config set workspaces.meteor.restricted true both do what they look like. Quote a
string that would parse as JSON, such as '"123"', to keep it a string, and put --
before a value that starts with -, after any options. Removing a key that is not set is
a problem. The patched document takes exactly the path apply does — the same lock,
revision check, symlink refusal, validation, atomic write, and job installation — and
reports the same fields, so a result that fails validation leaves the file unchanged. A
missing or unreadable config.json is reported rather than created; apply is the repair
path. Neither command prints the document or a value.
While the service runs
enso serve reads config.json again before each chat turn, before each job scheduler
tick, and whenever a transport or chat command resolves a binding, so bindings,
defaults, workspaces, providers, projects, agent, runs, and heartbeat take
effect on the next turn or tick without a restart, however the file was written. A turn or
job run keeps the snapshot it started with; a queued message runs in the workspace it was
bound to when it arrived, and is dropped with a notice if that binding is removed before it
runs. transports and logging are read when enso serve starts, so a change there needs
!restart in chat or enso service restart; web is read when the viewer starts, so a
change there needs enso web stop, then enso web start. Apply, set, and unset report any
of the three as restart_required: true in JSON and as a line in text output.
restart_required compares those three sections of the replaced and applied documents; it
is false on a fresh home and whenever nothing was applied. A provider whose directory is new
to the service unit also needs enso service install; see
The service.
A file that fails validation when it is read is logged once per revision, and chat turns and jobs keep the last valid configuration until the file is valid again. Heartbeat is stricter: it stops admitting assessments and cancels running ones while the file is invalid.
The Memory feature adds memory settings that also take
effect without a service restart.
enso init prepares the home before the first apply; see
Non-interactive setup. CLI JSON contracts are documented in
Onboarding contracts.
Every object below accepts only the keys documented for it. An unrecognized member — a typo
such as logging.max_byte, or a key from some other tool — is a problem reported by its full
path alongside the others, never a silently ignored extra that leaves the default in force.
The names you choose are not members: binding keys, workspace names, and provider names stay
free-form within their own rules, and only the objects stored under them are closed. version
names the schema itself, so a file declaring anything but 1 is reported as an unsupported
version and its members are left alone rather than measured against version 1.
config.json
This complete example is strict JSON. Replace example tokens, IDs, paths, and model
choices; create the default, meteor, and dev workspaces and the project
repository before applying it. Keep only the transports and providers you use.
{
"version": 1,
"transports": {
"slack": {
"bot_token": "xoxb-\u2026",
"app_token": "xapp-\u2026",
"notify": "C0987654321",
"mention_required": false,
"thread_mention_required": false
},
"telegram": {
"bot_token": "\u2026",
"allowed_users": [
"123456"
],
"notify": "123456"
}
},
"bindings": {
"slack:dm:U0123456789": "default",
"slack:C0987654321": "default",
"slack:C0123456789": "meteor",
"telegram:123456": "default"
},
"defaults": {
"provider": "claude",
"model": "opus",
"effort": "xhigh"
},
"workspaces": {
"meteor": {
"agent": {
"provider": "codex",
"model": "sol",
"effort": "xhigh"
}
}
},
"providers": {
"claude": {
"path": "claude",
"models": [
"opus",
"sonnet",
"haiku"
],
"args": [
"--dangerously-skip-permissions"
]
},
"codex": {
"path": "codex",
"models": [
"astra",
"sol",
"terra",
"luna"
],
"args": [
"--dangerously-bypass-approvals-and-sandbox"
]
},
"grok": {
"path": "grok",
"models": [
"grok-4.6",
"grok-4.5"
],
"args": [
"--always-approve"
]
},
"agy": {
"path": "agy",
"models": [
"gemini-3.8-flash-high",
"claude-sonnet-4-6"
],
"args": [
"--dangerously-skip-permissions"
]
},
"opencode": {
"path": "opencode",
"models": [
"openrouter/deepseek/deepseek-v4-flash"
],
"args": [
"--auto"
]
}
},
"projects": {
"EN": {
"name": "Enso",
"workspace": "dev",
"repo": "~/Projects/enso",
"stages": [
"triage",
"todo",
"review"
],
"setup": ".dev/prepare",
"copy": [
".env"
]
}
},
"agent": {
"timeout": 3600
},
"logging": {
"level": "INFO",
"max_bytes": 10485760,
"backups": 5
},
"runs": {
"keep": 500,
"max_age_days": 30
},
"heartbeat": {
"enabled": true,
"retention_days": 30
},
"web": {
"host": "127.0.0.1",
"port": 8787
}
}
Transports
Configure one or both; they run in the same process.
Slack needs a bot token and an app-level token for Socket Mode. Create the app at
https://api.slack.com/apps?new_app=1 from the JSON printed by enso slack manifest;
it lists exactly the scopes and events Enso uses. Invite the bot to every channel you
bind. When you apply the manifest to an existing app with more scopes, revoke its bot token
first and then reinstall — Slack never removes scopes from a live token, and the reinstall
drops the bot from its channels, so invite it again.
Two optional Slack settings decide when a bound channel answers without being addressed:
| Key | Default | true means |
|---|---|---|
mention_required |
false |
A top-level message in the channel is ignored unless it @-mentions the bot |
thread_mention_required |
false |
A reply inside a thread is ignored unless it @-mentions the bot |
A DM or an @-mention is always answered. With both left at false, Enso answers every
top-level message in a bound channel, and every reply in a thread it is already part of.
Telegram is private chats only. allowed_users are the numeric ids the bot answers;
everyone else is ignored silently. An empty list admits nobody; a binding is also required.
notify is where job failure alerts and untargeted enso message send calls go. It is a
conversation id (C…, G…, or D…; use enso slack open-dm U… for a person) or a
Telegram user id. It is optional: without one, a send with no --to is refused with
no destination: pass --to or set transports.<name>.notify, and a job alert that has no
notify of its own is only written to the log. When both transports set it, Slack’s is used.
Bindings
A binding maps a place to a workspace. Keys are:
| Key | Means |
|---|---|
slack:C… or slack:G… |
A channel. Every top-level message starts its own thread and conversation. |
slack:dm:U… or slack:dm:W… |
A user’s DM, as one continuous conversation. W… ids are Enterprise Grid org-wide user ids. |
telegram:<user id> |
A Telegram private chat |
A Slack channel binding is shared access. Human participants can trigger the
workspace’s agent when its mention/thread rules permit. Slack has no Enso per-user
allowlist for a bound channel; mention_required and thread_mention_required control
when it responds, not who is authorized. Bind a personal DM for personal access, or
choose channel membership and agent permissions for shared use. See
Security and policies.
An unbound Slack channel stays silent unless the bot is mentioned, and then replies once to
say it is not bound; an unbound DM says the same. Bindings are read fresh for each incoming
message, so a binding added or changed in config.json applies to the next message with no
restart; a message already queued runs in the workspace it was bound to when it arrived,
and is dropped with a notice if that binding is removed before it runs.
The named workspace directory must exist. config check treats a binding pointing at a
missing directory as a problem.
Defaults, workspaces, and agents
defaults names the provider, model, and effort every conversation uses unless the
workspace overrides it, and all three keys are required. Each job that calls a provider
supplies its own triple in JOB.md; changing chat defaults or a workspace’s agent does
not change an existing job.
provider must be one of the names in providers, and model must be listed in that
provider’s models. effort is how much reasoning to ask for, and each provider accepts
its own set of levels:
| Provider | Accepted effort |
|---|---|
claude |
low, medium, high, xhigh, max |
codex |
low, medium, high, xhigh, max, ultra |
grok |
low, medium, high, xhigh |
agy |
low, medium, high |
opencode |
none, minimal, low, medium, high, xhigh, max |
Anything outside a provider’s set is a validation problem. enso providers prints the
levels each bundled model really supports, which can be fewer than the column above —
haiku, for example, stops at high.
A workspace may replace the whole triple with an agent block (also all three keys), or
replace one provider’s flags with providers.<name>.args. Providers with an ordered
reasoning ladder clamp effort down to the model’s maximum, with a log line. Antigravity and
OpenCode have the different semantics described below.
Provider-argument overrides apply to chat turns, jobs, and heartbeat assessments; they replace the global argument list rather than appending to it.
Only workspaces with overrides need an entry in workspaces. The directory
~/.enso/workspaces/<name> must exist for every binding and job that names it;
enso workspace create NAME scaffolds it. See Workspaces.
Restricted workspaces
workspaces.NAME.restricted defaults to false. Set it to true to require the
selected provider’s policy prerequisites before chat, job-provider execution, and
heartbeat assessments. Enso checks the expected file, recognized bypass flags, and
Codex/Grok trust settings; the provider enforces the rules.
Use Security and policies for the provider paths, complete setup examples, verification, and limitations. That guide also explains why scripts run outside provider policies and why a passing workspace audit does not prove isolation.
Providers
providers is required and names the agent CLIs this install may run. Enso supports five:
claude (Claude Code), codex, grok, agy (Antigravity), and opencode. Any other
name is a problem, so keep only the entries you use.
| Key | Contract |
|---|---|
path |
The executable; ~ is expanded and a bare name is found on PATH. Defaults to the provider’s own name. A path that is not an executable on this machine is reported as a warning, not a problem. |
models |
Required non-empty list of the model ids a config or job may name |
args |
Optional list of flags added to every invocation verbatim; the default is none |
Enso passes those flags through unchanged. The exception is a restricted workspace, which refuses to launch Codex when the arguments include a recognized bypass flag; see Security and policies. Choose permissions for unattended operation and verify them with the provider you use.
Model ids remain canonical everywhere except their compact chat label. Slack’s live run
header and !status, and Telegram’s live run header and /status, share that presentation;
it does not change provider arguments, configuration, logs, or CLI output. See
Agent for the exact display rule.
Chat session ids are stored per conversation and provider in enso.db, and a session is only
resumable in the workspace it was created in. clear in chat forgets them. Sessions idle
for 30 days are pruned at start. Jobs with postrun capture a separate session per run and
can resume it for bounded follow-ups; later triggers start fresh. Job session IDs live in
run history and are not chat sessions. See Jobs.
Every id is checked against the contract of the provider that owns it — one opaque token,
never a path — before it is stored, resumed, or deleted, and a clear proves the data it
removes really is inside that provider’s own session store. A stored row that no longer
matches is not resumed and not deleted from: it is dropped, and the next message starts a
fresh session.
OpenCode
OpenCode models use their full <provider>/<model> ids. For example, the copy-ready id for
the default OpenRouter model is openrouter/deepseek/deepseek-v4-flash, not
deepseek/deepseek-v4-flash. Enso’s provider is still named opencode; OpenRouter is the
model’s route, not another Enso provider. providers.opencode.models may contain any full
id OpenCode accepts. enso models lists full, copy-ready ids from
models.dev’s OpenRouter catalog, restricted by default to models that support tool calls.
The lookup never edits this list or any other part of config.json.
For OpenCode, effort is the exact model variant passed as --variant. Enso accepts
none, minimal, low, medium, high, xhigh, and max, but support is model-specific
and can be a sparse set: choose one shown in that model’s EFFORTS column from
enso models. Enso passes the value unchanged, and OpenCode may silently ignore a variant
the model does not support.
The lookup caches results for an hour and can use a stale catalog with a warning when refresh fails. It never edits your configuration. See Models.
Setup writes --auto in args. That flag approves only permissions that OpenCode’s own
configuration has not explicitly denied; it does not override a deny rule or make a
restrictive permission profile unrestricted. Edit the OpenCode permission rules, or use a
workspace-specific providers.opencode.args, when a workspace needs a different boundary.
Antigravity
Enso asks Antigravity (agy) to register a workspace on its first launch, then
selects that project for later turns so instructions and skills remain in scope.
Antigravity keeps the registration in its own user-level project catalog.
Antigravity carries the reasoning effort in the model id (gemini-3.8-flash-high), and the
CLI rejects --effort alongside such an id and for the claude-* models entirely. Enso
never passes it. For a model id ending in -low, -medium, or -high, Enso reports that
embedded level in chat and job run metadata, raising or lowering the requested effort
with a log line when it differs. For example, model: gemini-3.8-flash-high with
effort: low reports high, and a -low model with effort: high reports low.
The selected model id and command arguments stay unchanged. Models without an effort
suffix, such as claude-sonnet-4-6 and claude-opus-4-6-thinking, keep the requested
low, medium, or high value for reporting; it is not sent as a flag to Antigravity.
agy models lists the live catalog and models accepts any id from it.
Prompts beginning with / are read by Antigravity as slash commands. --disable-slash-commands
in args stops that, at the cost of skill expansion — which is most of the point of a
workspace — so it is yours to add, not Enso’s.
Projects
projects declares the task board’s projects, keyed by the prefixes used
in task references (EN gives EN-001). The section is optional. enso project add
creates a project; enso workflow init configures a preset and disabled stage jobs.
Both validate the complete configuration and write it atomically.
The workflow fields and commands below describe the implemented beta workflow changes; check availability before using them with a released installation. Workflows owns setup, migration, acceptance, and worktree lifecycle procedures.
Project examples
Checks, Git, and multiple stages are optional. This is a valid projects section for
an unchecked work → done flow:
{
"projects": {
"EX": {
"name": "Example",
"workspace": "default",
"stages": ["work"]
}
}
}
A development project can separate planning, implementation, review, and integration:
{
"projects": {
"APP": {
"name": "Application",
"workspace": "dev",
"repo": "~/Projects/app",
"base": "main",
"worktree_root": "../.worktrees/app",
"max_concurrency": 3,
"setup": "npm ci",
"stages": [
{ "name": "plan", "worktree": false },
{
"name": "implement",
"max_repairs": 2,
"checks": [
{ "name": "lint", "command": "npm run lint", "timeout": 600 },
{ "name": "tests", "command": "npm test", "timeout": 600 }
]
},
{ "name": "review", "return_to": "implement", "max_returns": 2 },
{
"name": "integrate",
"integrate": true,
"checks": [
{ "name": "lint", "command": "npm run lint", "timeout": 600 },
{ "name": "tests", "command": "npm test", "timeout": 600 }
]
}
],
"hooks": { "after:done": "./scripts/task-done" },
"script_timeout": 600
}
}
}
These are complete JSON objects showing the projects section, not full replacement
configurations for enso config apply. Add the chosen entries to your complete document.
The named workspaces and repositories must exist; choose setup, check, and hook commands
that your project actually provides. The dev preset requires explicit lint and test
commands. The sibling worktree root in this example also avoids placing task files below
the main development server’s watched repository; Git ignores alone do not control every
file watcher.
Project fields
| Field | Contract |
|---|---|
| Project key | 2–10 uppercase letters or digits, starting with a letter |
name |
Required nonempty display name |
workspace |
Required lowercase kebab-case workspace whose directory exists |
repo |
Optional path to a directory holding a Git repository; ~ expands |
stages |
Required nonempty ordered list of unique stage names or objects |
base |
Optional target branch; otherwise the main checkout’s current branch is selected when the worktree is first created. The recorded target stays pinned. |
worktree_root |
Default <repo>/.worktrees; accepts a repository-relative, absolute, ~, or sibling path. Each task gets <root>/<REF>. |
setup |
Optional nonempty bash command run in a new worktree |
copy |
Optional list of relative repository paths, with no absolute paths or ..; retained for legacy setup. See workflows for .worktreeinclude and copy safety. |
max_concurrency |
Positive integer limiting simultaneous project task executions; default 1 |
hooks |
Optional object of nonempty commands keyed by after_transition, after:STAGE, or teardown; STAGE must be a configured or built-in stage |
script_timeout |
Positive integer seconds per setup or lifecycle script; default 600 |
Changing worktree_root or base affects new worktrees; existing tasks retain their
recorded directory and target. Worktree directories are outside the Enso home by default.
Separate worktrees do not isolate ports, databases, or other machine resources; configure
job concurrency groups where checks share them.
Stage and check fields
A string stage is "work" or "approve:human". Use an object to configure execution,
checks, or finite repair and review loops:
| Stage field | Contract |
|---|---|
name |
Required; 2–24 lowercase letters, digits, or hyphens, starting with a letter. Never backlog, blocked, done, or cancelled. |
human |
Boolean, default false; waits for operator action |
command |
Optional nonempty command; runs without a provider |
integrate |
Boolean, default false; Enso owns landing and requires a repository and worktree |
worktree |
Boolean; false keeps execution in the Enso workspace, true requires a repository. Omitted follows whether the project has a repository. |
checks |
Optional list of required check objects; omitted or empty means no executable acceptance checks |
max_repairs |
Nonnegative integer additional repair opportunities, default 2; 0 disables repairs |
return_to |
Optional earlier stage name; otherwise a return selects the previous stage |
max_returns |
Nonnegative integer return-loop limit, default 2; 0 disables returns |
Choose at most one of human: true, command, and integrate: true; other stages use an
agent job. Integration cannot use worktree: false. Stage instructions belong in the
bound stage job’s prompt, while execution and acceptance rules
belong in configuration.
| Check field | Contract |
|---|---|
name |
Required nonempty name, unique within the stage |
command |
Required nonempty bash command |
timeout |
Positive integer seconds; default 600 |
protect |
Optional list of repository-relative paths or patterns protecting additional acceptance inputs; no absolute paths or .. |
Checks run in the task execution directory after the agent and postrun work stop. Every configured check must pass before a forward handoff is accepted; empty checks do not require a report or test framework. Results bind to the candidate and spec/workflow versions. New tests are allowed; changes to existing tests, common validation configuration, or explicitly protected inputs require operator review. Approval does not record a pass. Repair and return budgets persist across runs and restarts.
Lifecycle hooks react to accepted transitions; they are not acceptance checks.
after_transition runs for every accepted move, after:done reacts to completion, and
teardown runs before worktree removal. Events are recorded with the transition and
delivered with at-least-once semantics; use ENSO_EVENT_ID to avoid repeating external
effects. A failed reaction preserves acceptance and records its diagnostic for attention;
cleanup waits for required lifecycle work. See Workflows for ordering
and recovery.
Unknown fields, invalid types, duplicate stage/check names, invalid return destinations, and contradictory execution kinds are reported with their configuration paths. No mandatory number of stages, checks, or review steps is imposed on a simple workflow.
Interactive timeout
agent.timeout is a non-negative integer in seconds, defaulting to 3600.
0 disables the interactive timeout. Jobs and beats have their own timeouts.
Secrets
The service starts with almost no environment. Put KEY=value lines (an export prefix is
fine) in ~/.enso/secrets/*.env and enso serve exports them before anything starts; a
variable already in the environment wins. Blank lines and lines starting with # are
skipped and quotes around a value are removed, but there are no inline comments: everything
after the first = is the value.
Keyring passwords and service-account tokens that provider CLIs or job scripts read belong here. Restart the service after changing these files. Their values are inherited by every agent Enso runs — treat the directory as one trust boundary, not several.
Web
web.host and web.port are defaults for enso web start; command-line flags win. port
is an integer from 1 through 65535 and host a non-empty string. When config.json is
missing or invalid the viewer still starts, on 127.0.0.1:8787 unless flags say otherwise,
because its Health page is where you read the problem. The viewer is read-only and has no
authentication, so leave host at 127.0.0.1 unless something else is handling access.
See Web viewer.
Heartbeat
heartbeat.enabled defaults to true; setting it to false stops beat execution and keeps
saved records. heartbeat.retention_days is a positive integer, defaulting to 30, for
closed beats. Each beat keeps its own timing and saved agent. See Heartbeat
for its lifecycle, gate contract, and retention.
Memory
Available with the Memory feature, the optional memory
section controls conversation capture and the calendar used for recall:
{
"memory": {
"enabled": true,
"timezone": "local"
}
}
This is a configuration fragment for a build with Memory support. enabled defaults
to true; disabling it stops new capture and refinement while keeping saved history
readable. timezone defaults to local, the host’s timezone, or accepts an IANA name
such as Europe/London. It affects calendar filters and displayed dates; stored event
timestamps remain UTC. Neither run nor heartbeat retention settings prune Memory.
See Memory for the bundled refiner and removal controls.
Logging and runs
logging.level is one of DEBUG, INFO, WARNING, ERROR, defaulting to INFO. The
log rotates at max_bytes, defaulting to 10485760 (10 MiB), and keeps backups rotated
files, defaulting to 5.
runs.keep (default 500) and runs.max_age_days (default 30) bound job run history:
the newest keep finished rows within max_age_days survive each prune. Heartbeat runs
stay with their beat until its closed records are pruned under heartbeat.retention_days.
Chat commands
Slack uses !, Telegram /.
| Command | Effect |
|---|---|
stop |
Kill the running process for this conversation and drop its queue |
clear |
Forget the conversation’s sessions and attempt to delete their local provider data; refuses while a message is running, so stop it or wait first |
status |
Workspace, agent in effect and where it came from, session age, what is running, queue depth |
help |
List these |
restart |
Restart the service (or re-exec enso serve) after replying |
