Configuration
Chock reads ~/.config/chock/config.zon and never writes it, so you can give
the whole directory to home-manager.
.{
.providers = .{
.{ .name = "local", .kind = "openai-compat", .base_url = "http://127.0.0.1:5000/v1" },
.{ .name = "chatgpt", .kind = "openai" },
.{ .name = "work", .kind = "aiand" },
},
.defaults = .{ .provider = "local", .model = "glm4.7-flash:A3B" },
}
The kinds are anthropic, aiand, openai, and openai-compat. The openai
kind uses OpenAI’s Responses API and a ChatGPT web login. An openai-compat
instance uses Chat Completions and also needs a base_url.
The name is the identity
The name is the key and the kind is a property, so two accounts of one kind
sit side by side under different names. Leave .name out and the kind becomes
the name. Two instances of one kind with no name are refused, and the message
says a name is needed, because silently replacing the first is how somebody
loses a credential they cannot get back.
How much the model holds
Add .context_tokens to say how much the model behind an instance can hold:
.{ .name = "local", .kind = "openai-compat", .base_url = "http://127.0.0.1:5000/v1", .context_tokens = 65536 }
Chock then folds the middle of the context into a summary at three quarters of that, before a turn is sent. It tells the agent at three fifths, which is a turn or more earlier, so the agent can save what it learned first. Leave it out and only the backstop runs: when a provider refuses a request as too large, the context is compacted and the turn is taken again, rather than the session ending. Either way the session log keeps every turn, so a compaction shortens what the model reads and nothing else.
What one instance can do
.capabilities says what an instance can do beyond answering with words.
Every field of it defaults to false, and two instances of one kind may differ:
.{ .name = "personal", .kind = "aiand", .capabilities = .{ .images = true } }
A tool is offered to the model only when the adapter can express it and the provider instance does it.
Run chock models to see the effective local view: provider instances, the
default model, context limits, and the features each adapter can faithfully
carry. chock models --provider <name> narrows that report to one instance.
The command does not read credentials or contact the provider, so it reports
configured and adapter capabilities rather than making an unverified claim
about a remote model. Provider-side search is named as unavailable; web search
continues to use Chock’s separately configured, policy-controlled tool.
Add --probe to opt into credential lookup and one authenticated GET /models request per selected provider. The returned model identifiers are
sorted before they are printed. chock models --provider <name> --probe is the
narrow form. The same typed catalogue result is kept in chock-auth, so the
daemon and graphical clients can use it for a model switcher without parsing
terminal output or handling credentials themselves.
Naming a credential
A provider names its credential one of three ways:
.{ .name = "work", .kind = "aiand", .token_file = "/run/secrets/aiand" } // sops-nix, agenix
.{ .name = "work", .kind = "aiand", .token = "sk-..." } // in place, mode 0600 only
.{ .name = "work", .kind = "aiand" } // look it up, and send none if there is none
An instance that gives both .token and .token_file is refused, because
which one to read would not be decided. A field that is present and holds
nothing is refused too: absence already means “look it up”, so “the field is
not there” and “the field is there and holds nothing” stay two different
facts.
The third line above is what a local endpoint needs. chock run still refuses
it for an instance of kind anthropic, aiand, or openai that talks to that
kind’s own address, because those addresses refuse every request that carries no
credential. The refusal comes before anything is built, so no workspace and no
log are left behind.
Use the third spelling for openai, then run chock login --provider openai.
An OpenAI API key is not a credential for this kind. Use openai-compat with
.token_file when you want to call an OpenAI-compatible endpoint with an API
key.
Where a lookup goes, and what chock login writes, is in
credentials.md.
The machine’s own resource limits
config.zon also takes a limits block, in the same shape a project’s own
chock.zon takes one:
.{
.limits = .{
.processes = "50%",
.memory = "4GiB",
},
}
This is the machine’s own default: every project run from this machine that
names no limits field of its own gets the number here instead of Chock’s
own machine sized default. A project’s own chock.zon still wins over it, the
same way a project’s own choices win over everything else this file sets. See
Sandbox resource limits for the field
syntax and the full order the layers fold in.
The machine’s own Nix store caps
config.zon also takes a nix block, in the same shape a project’s own
chock.zon takes one:
.{
.nix = .{
.max_object_bytes = "16MiB",
.max_session_bytes = "256MiB",
},
}
This is the machine’s own default for how much a Nix evaluation may add to
the store: every project run from this machine that names no nix field of
its own gets the number here instead of Chock’s own built in default. A
project’s own chock.zon still wins over it. See
Nix store byte caps for the field syntax and
the full order the layers fold in.
The project’s own file
config.zon is yours and it is about providers. chock.zon, in the project
root, is the project’s own file and it is about what an agent may do there.
What an agent may do lives in it: the policy table, the budget, the subagent
limits and the denied paths, and so do the plugin list, the MCP servers, the
language servers and the container image. Chock binds it into the workspace
read only, so the agent works under rules it cannot edit.
The whole file is one struct literal, and each block is one field of it. This
is a complete chock.zon with every block a first project needs:
.{
.policy = .{
.agents = .{
.{ .kind = "main" },
},
.rules = .{
.{ .action = "net.fetch.org.ziglang", .decision = .allow },
.{ .action = "git.push", .decision = .deny },
},
},
.budget = .{ .max_cost = 5.0, .currency = "USD" },
.deny_read = .{
".env",
},
.subagents = .{ .max_width = 2 },
.context = .{ .compact_at_percent = 80 },
.apply = .{ .mode = .merge },
}
.subagents also takes .max_depth. Both default to 6. See
subagents.md.
The context block controls when the transcript is compacted and how much of
its recent tail survives unchanged:
.{
.context = .{
.warn_at_percent = 60,
.compact_at_percent = 75,
.keep_recent_entries = 6,
.keep_recent_kib = 16,
.summary_max_kib = 16,
},
}
The percentages apply to the provider’s model limit. The warning must precede
compaction; compaction is bounded to 10–95%, at most 64 recent entries and 256
KiB of recent text, and a summary of at most 64 KiB. Leaving the block out uses
the values above. Files supplied with --instructions, AGENTS.md, and files
in the instructions block are pinned in the system prompt rather than the
compactable transcript. Each file is already bounded to 8 KiB, and its
operator/session/project layer fixes precedence; pinning one cannot lift an
organisation policy or turn project text into operator instructions.
Inspect the effective result with chock sessions show <session> --only context. The log records the threshold and every retained, summarized, or
dropped event range, so replay gives the same provenance.
.apply.mode says how an approved apply lands. It defaults to merge, which
parks the work at refs/chock/<session> and then merges it into the branch you
have checked out. The approval prompt names the branch and the landing before
you answer, so the y you give is a y to that act. There is no mode that
means “move nothing”: say n to the apply, or write the policy row below.
approvals.md shows both prompts.
A rule goes under .policy.rules, and never directly under .policy.
.policy is a struct with named fields, so a rule written beside .agents is
a syntax error and the session does not start. Every block is optional: leave
out the ones you do not want.
Every block of this file narrows what an agent may do, and two rows of the
policy table widen: sandbox.jit and workspace.integrate. Each is a row and
not a block of its own, because the table is where an organisation can forbid
what a project cannot take back. actions.md has both.
- policy.md for how a rule is read, and for the denied paths.
- actions.md for every action name a rule can carry.
- secrets.md for the
secretsblock, which says what a tool call may be given without the agent ever seeing it. - skills.md for the
skillsblock, which says which directories of this project hold skills. - commands.md for the
commandsblock, which says which directories of this project hold the messages a person can run by name. - hooks.md for the
hooksblock, which says what this project runs at a point in a session. - remote-mcp.md for an
mcp_serversentry that names aurlrather than acommand, and the rules above it. - org.md for the bundle an organisation puts above the project.
- subagents.md for the subagent limits.
- plugins.md for the plugin list.
The project’s own skills
The skills block names directories that hold skill directories. Each path must
be under the project and must not climb out of it:
.{
.skills = .{ ".chock/skills" },
}
There can be at most four. Leave the block out and the project ships none, which is what every project does today. skills.md has the format and the three layers.
The project’s own commands
The commands block names directories that hold a project’s commands, and it
is read by the same reader with the same path checks:
.{
.commands = .{ ".chock/commands" },
}
A command is a message a person runs with chock run /<name>, so this block
adds nothing to what the agent may do.
commands.md has the format and the arguments.
The project’s own hooks
The hooks block names programs this project runs at a point in a session:
.{
.hooks = .{
.{
.name = "tests-pass",
.on = "tool.before",
.tool = "request_action",
.run = .{ "zig", "build", "test" },
.output = "refuse",
},
},
}
A hook runs through the gate a tool call goes through, so it narrows what a session may do and never widens it. There can be at most eight. hooks.md has the triggers, the fields and what the log holds.
The project’s own style rules
The style block names which grammars this project has and which of its own
tree-sitter queries refuse a write:
.{
.style = .{
.parsers = "TREE_SITTER_PARSERS",
.grammars = .{
.{ .language = "zig", .extensions = .{".zig"} },
},
.rules = .{
.{ .name = "comment-block", .query = "style/comments.scm", .max_lines = 10 },
},
},
}
parsers names an environment variable and never a path, because a store path
changes whenever the grammar package is bumped. A rule carries exactly one
bound, and style.parse in the policy table decides whether any rule runs at
all. There can be at most 32 rules and 16 grammars.
Two more keys pick the parser. engine is command, which runs
tree-sitter, or wasm, which runs a module inside Chock’s own engine and
needs no tree-sitter on the host. modules names the variable holding the
parser modules, one <language>.wasm a language, and defaults to
CHOCK_PARSER_MODULES. style.md has the bounds, the two
decisions, what the log holds, and what the wasm engine costs before the first
write.
Which way of sandboxing a session uses
config.zon is the only file that names this, and that is deliberate: a
project may only narrow what it may do, and a project that could name its own
sandbox driver could name a weaker one.
.{
.sandbox = .{ .driver = "microvm" },
}
native is the default and is today’s behaviour. See
microvm.md for what a guest adds, and
org.md for how an organisation pins or forbids one.
How big a guest is
The same block names how many processors a guest gets and how much memory. Both are optional, and a number here is used as it is.
.{
.sandbox = .{ .driver = "microvm", .cores = 4, .memory_mb = 4096 },
}
Leave them out and both scale with the machine. A host with 128 cores and 511GB
gives a guest 12 processors and 12GB, one with 8 cores and 8GB gives 2 processors
and 1GB, and a guest never gets more than one eighth of the memory of the machine
it runs on. The processor count is also limited by that share of memory, because a
build runner starts one job for each processor and a guest with more processors
than memory loses a compiler to the kernel. cores is further limited to 8 on a
Mac, where the interrupt controller a guest gets has no more than that. The curves
and the reasons for them are in
microvm.md.
The dev shell the agent gets
The nix block of chock.zon names which devShells attribute the tool
environment comes from:
.{
.nix = .{
.dev_shell = "ci",
},
}
Leave it out and Chock reads default, which is what nix develop reads.
--dev-shell <name> overrides it for one run. A name no flake carries stops
the session with Nix’s own message. See
toolchains.md.
Where a provisioned tool may come from
The same block says what a tool provide_tool builds must say about its own
origin:
.{
.nix = .{
.provenance = .{
.required = true,
.from = .{"https://cache.flakehub.com"},
.built_by = .{"builder-one"},
},
},
}
Left out, nothing is asked and nothing is read. What each field means, and what a refusal says, is in tools.md.
Paths the worktree does not carry
The agent works in a git worktree at HEAD, so a file git ignores is invisible
to it. A generated configuration and an automation directory are the two such
paths a real project tends to need. The workspace block names them:
.{
.workspace = .{
.binds = .{
.{ .name = "config.local.*", .mode = .read_only },
.{ .name = "scripts/release", .mode = .copy, .write = .ask },
.{ .name = "vendor/cache", .mode = .temp_copy },
.{ .name = "generated/maybe", .mode = .read_only, .required = false },
},
},
}
Each bind names a path under the project root. The mode says how it reaches the agent and what happens to a change the agent makes to it:
| Mode | How it arrives | What happens to a change |
|---|---|---|
read_only |
a bind mount, read only | impossible |
write |
a bind mount, read write | lands on your real file as it is written |
copy |
copied into the workspace | written back when the work applies |
temp_copy |
copied into the workspace | discarded with the workspace |
mode is required and has no default. Every value of it decides how much of
your own disk the agent reaches, and a default would make that invisible.
A bind can name a directory. read_only and write bind the directory and the
two copying modes copy it whole.
What may be written back
write carries the same three answers a policy rule carries: allow, ask
and deny. It is meaningful for write and copy alone, because on
read_only or temp_copy it is refused when the file is read: neither of
those reaches your files at all. Leave it out and it is ask.
The policy table decides beside it, under the action
workspace.bind.<name>, and the narrower of the two answers wins. So an
organisation can refuse the whole mechanism with one row, and you can deny one
path without editing a file somebody else wrote. See
actions.md.
The moment the question is asked differs by mode, because it is asked where it can be:
- A
writebind is decided at session start, before the bind is made. It is read only until something answersallow, and the session start says so. - A
copybind is decided at the end, in theworkspace.applyprompt, which names every file that is copied back. Refuse that apply and your files stay as they are, with the session’s copies left in the workspace.
Names, patterns and what is refused
A name holding * or ? is a pattern. * matches inside one path component,
** matches any run of components, and ? matches one character that is not a
separator. Every other character matches itself. The patterns run against the
project directory, and never against git’s list of ignored files, so a name
that happens to be tracked already is simply in the workspace twice over.
required says whether a name matching nothing stops the session. Its default
is derived: true for a name with no pattern character, false for a pattern. A
person who wrote scripts/release asked for that path, while a pattern
matching nothing is ordinary. Write .required yourself to say otherwise, in
either direction.
A match may be a symbolic link. Chock resolves it and binds the real file,
because a mount source cannot be a link. Every resolved path has to stay inside
the project: a name holding .., an absolute name, and a link that reaches out
of the project are each refused by name, and the session does not start. So are
chock.zon and anything under .git, which Chock holds for itself.
The session start names every bind it made, with what the name resolved to and the mode it arrived under.
When Chock refuses the file
A fault in chock.zon stops the session before the agent runs. The message
names the file, the line and the column.
Chock reads the file as a whole, and reads the deny_read block, before it
builds the workspace. A fault that early is named against the workspace:
chock run: the workspace for /home/you/site could not be built: chock.zon is
not valid:
4:9: error: expected field initializer
Every other block names its own block instead. The policy and workspace
blocks are read before the workspace is built, because a bind is decided
against the policy table. The budget, subagents and plugins blocks are
read after it is there:
chock run: the budget in chock.zon could not be read: chock.zon: the budget
block is not valid:
1:18: error: unexpected field
A message that says chock.zon is not valid is about the whole file, and
the fault can be in any block of it. A message that names a block, such as
the deny_read block is not valid or the budget block is not valid, is
about that block alone.
A project with no chock.zon has no spend cap. The two defaults run in
opposite directions on purpose: an action no rule names resolves to ask, so
a project that wrote no policy permits nothing by itself, while a budget
nobody wrote is no budget, so a session runs until it finishes. Write a
budget block to bound what a session may spend:
.{ .budget = .{ .max_cost = 5.0, .currency = "USD" } }
The cap lives in that file and nowhere else, so the model cannot raise it. A misspelled field inside the block is refused rather than quietly read as no cap.