Skip to Content
Referencesettings.json

settings.json

Settings are read from .freecode/settings.json (project) and ~/.freecode/settings.json (user).

The file is hand-written and holds no secrets, which is the point: the project copy is meant to be committed, so the permission rules and hooks your team agreed on travel with the repository. Credentials, the current model, and MCP servers are not here — they live in ~/.freecode/config.json; see the overview.

Exactly four top-level keys are read. Anything else produces a warning on startup naming the key — and, when it is a near miss, the key you probably meant.

KeyRead byPurpose
permissionspermission/settings.tswhich tool calls are allowed, asked about, or denied
hookshooks/settings.tsshell commands to run at lifecycle events
memorymemory/extract-policy.tsautomatic memory extraction
redirectagent/redirect/settings.tstrajectory redirection (off by default)

Editor completion

A JSON Schema ships at schemas/settings.schema.json. Point $schema at it and your editor completes and validates the file as you type:

{ "$schema": "https://raw.githubusercontent.com/ayandexyz/omacode/main/schemas/settings.schema.json" }

$schema is the one key FreeCode ignores on purpose.

A complete file

{ "$schema": "https://raw.githubusercontent.com/ayandexyz/omacode/main/schemas/settings.schema.json", "permissions": { "allow": ["Read", "Grep", "Bash(pnpm test:*)"], "ask": ["Bash(git push:*)"], "deny": ["Read(//etc/**)", "Bash(rm -rf:*)", "Write(.env)"] }, "hooks": { "PostToolUse": [ { "name": "format-ts", "matcher": "edit|write", "command": "pnpm prettier --write \"$CLAUDE_TOOL_INPUT\"", "timeout": 30 } ], "PreToolUse": [ { "name": "no-force-push", "matcher": "bash", "if": "bash(git push --force*)", "command": "exit 2" } ] }, "memory": { "autoExtract": true, "extractEveryNRuns": 8 }, "redirect": { "enabled": false, "maxPerRun": 2 } }

permissions

Three arrays of rule strings. Every rule is Tool or Tool(pattern); the tool name is case-insensitive, so Bash, bash, and BASH are the same rule.

{ "permissions": { "allow": ["Read"], // this tool, any arguments "ask": ["Bash(git push:*)"], // this tool, arguments matching the pattern "deny": ["Read(//etc/**)"] } }

How a decision is reached

Evaluation order is fixed (permission/evaluate.ts):

danger mode? ─── yes ──▶ allow (rules are never consulted) │ no mode enforcement (plan/review/explore are read-only) ──▶ deny │ deny rules ──▶ deny ← absolute; a hook cannot argue with it │ ask rules ──▶ ask ← in explore mode an "ask" becomes deny │ allow rules ──▶ allow │ no rule matched ──▶ the agent mode's default

Deny beats ask beats allow, regardless of which scope a rule came from. That matters for the merge below.

Pattern syntax by tool family

FamilyToolsMatched againstPattern rules
Shellbashcommandexact string, or a prefix:* rule
Pathread, ls, write, edit, glob, grepfilePath | path | cwdglob
Networkwebfetch, websearchurl | queryglob
Sub-agentagentagentTypeexact, or *
MCPmcp__<server>—server-level only

Bash prefix rules are deliberately strict. Bash(npm:*) matches npm test but not npmevil (a word boundary is required after the prefix) and not npm test && rm -rf / — a command containing any of || && ; | newline backtick $( never matches a prefix rule. A rule that granted npm:* cannot be turned into a grant for everything by appending && …. Bash(:*) degenerates to match-all; write Bash if that is what you mean.

Path patterns are project-relative unless you say otherwise:

PatternMatches
src/** or ./src/**paths under src/ inside the project
//etc/**absolute paths — the leading // means “filesystem root”
~/notes/**paths under your home directory
dir/**also matches dir itself, so grep/glob targeting the directory match

A project-relative pattern never matches a path outside the project root — it returns false rather than escaping via ...

MCP rules are server-level. mcp__linear matches every tool that server exposes; mcp__linear(pattern) matches nothing (fail closed) because argument patterns for MCP tools are not implemented.

Scope merge

Both files are read and the tiers are concatenated, not overridden:

allow = project.allow + user.allow + in-memory session grants ask = project.ask + user.ask deny = project.deny + user.deny

So “project overrides user” is not how this section behaves. A project cannot remove a rule granted in your user file — but because deny is checked first, it can neutralize one by adding the corresponding deny. Session grants (“allow for this session”) join the allow tier in memory only and never beat a deny.

Both directories are watched, so edits take effect without a restart. A file that does not parse is treated as an empty rule set for that scope — it fails closed, and logs a warning.

Writes back to these files come from the permission prompt: “always allow” appends to the user file, “allow for this project” appends to the project file, both under permissions.<tier>.

hooks

An object keyed by event name; each value is an array of hook definitions. All 14 event names are listed on the hook events page.

FieldTypeDefaultMeaning
namestringrequiredunique within the event; also the merge key
commandstringrequiredshell command to run
matcherstringall toolstool-name pattern: *, exact, regex, or write|edit
ifstring—argument condition, Tool(pattern) — e.g. bash(git *), write(*.ts)
shell"bash" | "powershell""bash"interpreter
timeoutnumber300seconds (multiplied by 1000 internally)
oncebooleanfalseparsed and stored, but not enforced — treat as reserved

An entry missing name or command is skipped with a warning naming it; an unknown event name is skipped with a warning listing the valid ones. Bad configuration never takes the agent down.

Claude Code’s nested shape is accepted too, so existing config pastes in unchanged:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "name": "audit", "command": "…" } ] } ] } }

Scope merge is by event + name: a project hook named format-ts replaces the user hook of the same name rather than running alongside it. Both directories are watched with a 300 ms debounce, and each reload unregisters the previous generation first, so reloading is idempotent.

The exit-code protocol (0 continue, 2 block, anything else block) and the JSON stdout form are documented under hook events.

Both freecode serve (the backend the TUI and the other frontends spawn) and freecode run load these, through the shared bootstrap in apps/core/src/hooks/bootstrap.ts. Only serve watches the file for changes — a one-shot run exits before an edit could apply.

memory

FieldTypeDefaultMeaning
autoExtractbooleantruemine finished turns for facts worth remembering
extractEveryNRunsnumber8how often extraction is even considered; values < 1 are ignored, non-integers are floored
retrievalJudgebooleantruejudge retrieved memories for relevance before injecting them; fails closed
autoConsolidatebooleantrueone cheap merge pass per project per day — merges only, never deletes
consolidateMinHoursnumber24minimum hours between consolidation runs
consolidateMinSessionsnumber5minimum sessions since the last run before consolidating again; values < 1 are ignored
{ "memory": { "autoExtract": false } }

Scope merge here is first definition wins, project → user → default, per field. FREECODE_DISABLE_MEMORY_EXTRACTION=1 overrides both files. Throttling is safe because each extraction rebuilds the transcript from the session’s whole history, so a skipped run is covered by the next one. FREECODE_DISABLE_MEMORY_JUDGE=1 and FREECODE_DISABLE_MEMORY_CONSOLIDATION=1 do the same for the other two. Background: memory.

redirect

When the loop detects it is going in circles, spend one small model call on asking for a different approach and inject the answer into the next turn.

FieldTypeDefaultMeaning
enabledbooleanfalseallow redirection at all
maxPerRunnumber2redirections per run; 0 disables as surely as enabled: false
{ "redirect": { "enabled": true } }

Same scope merge as memory. FREECODE_DISABLE_REDIRECT=1 overrides both files. Off by default until the measurement in the spec says the delta is non-negative — shipping it on and measuring afterwards would mean every user pays for an unvalidated model call. Background: trajectory redirection.

tasks

What happens when a background task finishes: a sub-agent spawned with agent(run_in_background: true), or a background shell exiting.

FieldTypeDefaultMeaning
notifybooleantruedeliver the result to the agent as a <task-notification>: mid-turn at the next tool batch, or by starting a turn if the session is idle
{ "tasks": { "notify": false } }

Project file first, then ~/.freecode/settings.json. FREECODE_TASK_NOTIFY (1/0) overrides both. Off means no turn is ever started without your input, and agent(run_in_background) runs the sub-agent in the foreground instead, because the notification is the only way its result reaches the model. A background shell still runs; the model checks it with bashoutput. Background: Sub-agents.

Not in this file

You wantWhere it lives
API keys~/.freecode/config.json → providers.<id>.apiKey, or ANTHROPIC_API_KEY and friends
Current provider + model~/.freecode/config.json → current
Fallback providers~/.freecode/config.json → recovery.fallbackProviders
MCP servers~/.freecode/config.json → mcp.servers (or freecode mcp add)
Timeouts, budgets, debugenvironment variables
Project instructionsCLAUDE.md / AGENTS.md
Skills, commands.freecode/skills/, .freecode/commands/

Known gaps

Found while writing this page; each is also tracked in TODO.md.

  • One file, three loaders, three merge rules. permissions concatenates scopes, hooks override by name, memory takes the first definition. All three are defensible in isolation, but nothing states the difference, and /getting-started/configuration currently claims a single “project wins” rule that is only true for hooks. A shared loader (parse once, hand each subsystem its section) would make one answer true.
  • Unknown keys are silently ignored. Fixed: settings/known-keys.ts reports any key nothing reads, with a “did you mean” for near misses. It is a name check only — values stay each loader’s business, since they already validate and default their own. Hook event names are still left to hooks/settings.ts, which already names the valid list.
  • No published schema. Fixed: schemas/settings.schema.json, referenced via $schema (see above). A test asserts the schema’s keys and the runtime key list agree, so the two cannot drift.
  • once is parsed but never enforced (hooks/settings.ts → RegisteredHook). Either implement per-session tracking or reject the field so it cannot look configured when it is not.