Decision models¶
A decision model answers a typed question about some text or JSON: is this true, which of these options, where on this scale. It doesn't write text. It returns calibrated probabilities in one pass, for a fraction of an LLM's cost and latency. TypeSafe's Jev was the first with a public API. OpenAI's Decisions API, Cloudflare's Clef, and the open-weight Kev (which runs on Ollama or Ollaya) followed.
hlock uses decision models in three places:
- In your agent. A decider can answer your harness's permission prompts (
approve), keep it working when it stops before the task is done (stop), narrow the tools it's offered (tool), or pick which model takes a step (route). hlock locks the decider inharness.lockand wires it into the harness with a hook. - As the judge of an eval pack: it answers each criterion as a yes/no question, cheaply enough to run on every trial.
- For hlock's own checks: drafting eval packs from traces, and the meta harness's checks of its jobs and its built-in agent.
Name a decision model¶
A decider is provider:model. The provider decides how hlock reaches it:
| Reference | Protocol | Where | API key |
|---|---|---|---|
typesafe:jev-1.13.0 |
System One | https://api.typesafe.ai (TYPESAFE_BASE_URL) |
TYPESAFE_API_KEY |
openai-decisions:gpt-6-luna |
Decisions | https://api.openai.com/v1 |
OPENAI_API_KEY |
ollama:kev:4b |
System One | a local Ollama 0.40.4 or later (OLLAMA_HOST) |
none |
ollaya:kev:4b |
System One | a local Ollaya (OLLAYA_HOST) |
OLLAYA_API_KEY, if set |
openrouter:typesafe/jev-1.14 |
System One | https://openrouter.ai/api |
OPENROUTER_API_KEY |
cloudflare:clef |
System One | base-url, with your account |
CLOUDFLARE_API_TOKEN |
system-one:<model> |
System One | base-url (Kev's own server, SGLang, vLLM, LiteLLM) |
key-env, if any |
Use a version (jev-1.13.0), not an alias (jev-latest): the lock records the model
that answered either way, but an alias can change between runs.
Try one¶
hlock decide ask puts questions to a decision model about some text, a file (@path),
or stdin (-):
hlock decide ask @ticket.txt --noul "Is it urgent?" --choice "team=billing,auth,infra" \
--score "severity=cosmetic|broken|outage" --model ollama:kev:4b
It prints each answer with its probability, the model that answered, the input tokens,
and the cost where the provider publishes a price. Without --model it uses
[deciders.default] in harness.toml, else a Kev model a local Ollama or Ollaya is
serving, else Jev when TYPESAFE_API_KEY is set. --json prints everything.
Add deciders to your agent¶
List them in harness.toml, by what they decide:
[deciders.approve] # the harness's permission prompts
model = "typesafe:jev-1.13.0"
policy = ".hlock/approve.md" # your rules, in your own words
[deciders.stop] # keep going when the task isn't done
model = "ollama:kev:4b"
| Key | Description |
|---|---|
model |
The decider, or none |
any-of |
Candidates for hlock resolve to choose between, instead of model. none is one of them. |
may |
approve only: which of allow, ask and deny it may answer. Default ["deny", "ask"], so it can only make the agent more careful. Add allow to let it approve calls for you. |
policy |
A file of rules added to the question. hlock check reports when it changes. |
min-probability |
How sure it must be to act: a number, for approve a table ({ deny = 0.8 }), or a list for resolve to choose from. Defaults: allow 0.9, ask 0.5, deny 0.8, stop 0.9, route 0.6; tool drops a tool under 0.3. |
hooks |
The harness events to wire it to, instead of the profile's default |
timeout-ms |
How long the hook waits for an answer (default 2000) |
base-url, key-env, protocol |
Override the provider's defaults |
A decider never blocks your agent. When it's unsure, slow, can't be reached, or answers
something may doesn't allow, the hook prints nothing and the harness does what it would
have done.
hlock pin locks the deciders with the rest of your setup (here with --user, since the
settings live in ~/.claude):
$ hlock pin --user --harness claude
โ pinned claude-code 2.1.0 โ harness.lock (--harness)
โญโ ๐ harness.lock ยท pinned โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ โ
โ harness claude-code 2.1.0 โ
โ scope project + user โ
โ llm ยท default anthropic:opus โ
โ config {root}/settings.json โ
โ decider ยท approve typesafe:jev-1.13.0 hook: PermissionRequest ยท may deny, ask โ
โ decider ยท stop ollama:kev:4b hook: Stop โ
โ โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
vendored 1 files into .hlock/vendor/claude-code/ ยท review before committing
next โ hlock check ยท hlock sync
and hlock sync wires them in, and says what each still needs:
$ hlock sync --yes
Sync plan for Claude Code 2.1.0 โ .hlock/env/claude-code/config
โ harness Claude Code 2.1.0
โ config Write .hlock/env/claude-code/config/settings.json
โ decider Wire deciders (approve, stop) into .hlock/env/claude-code/config/settings.json
โ decider TYPESAFE_API_KEY for the approve (typesafe:jev-1.13.0) decider not set; the
hooks defer to the harness until it is
โ model kev:4b on ollama (the stop decider) not served by a running ollama
โ Write .hlock/env/claude-code/config/settings.json
โ Wire deciders (approve, stop) into .hlock/env/claude-code/config/settings.json
2 steps need you:
1. TYPESAFE_API_KEY for the approve (typesafe:jev-1.13.0) decider not set; the hooks
defer to the harness until it is
export TYPESAFE_API_KEY=โฆ # then re-run hlock sync --prefix .hlock/env
2. kev:4b on ollama (the stop decider) not served by a running ollama
ollama pull kev:4b
next โ hlock sync --skill (let your agent do it)
hlock sync --pull pulls a local decider's model through its runtime. The hooks land in
the harness's own config, next to any hooks of yours, which hlock leaves alone:
{
"hooks": {
"PermissionRequest": [
{"matcher": "*", "hooks": [{"type": "command", "timeout": 3,
"command": "hlock decide hook approve --format claude-code --event PermissionRequest --lock /path/to/project/harness.lock"}]}
],
"Stop": [
{"hooks": [{"type": "command", "timeout": 3,
"command": "hlock decide hook stop --format claude-code --event Stop --lock /path/to/project/harness.lock"}]}
]
},
"model": "opus"
}
Every word of the command is quoted for the shell when it needs to be (a project path with
a space or a ; stays one word). Copilot's hook file also gets a powershell command,
quoted the PowerShell way.
hlock pin leaves hlock's own hooks out of what it vendors, so a synced config isn't
drift. A decider you remove from harness.toml loses its hook at the next sync. hlock
knows its own hooks by their whole command (<hlock> decide hook <use> --format โฆ --lock โฆ),
so a hook of yours that merely mentions decide hook is never touched, and neither is an
empty hook group of yours.
Locks from someone else¶
A lock you pull is someone else's, so hlock doesn't let its decider entries choose where your keys go or what runs:
- Keys. A decider's API key is read from its provider's own variable
(
TYPESAFE_API_KEY,OPENAI_API_KEY, โฆ). Akey-envin a lock is used only when your ownharness.tomlsets the samekey-envfor that use. - Endpoints. A lock's
endpointis used when it's on this machine (localhost,127.0.0.1,::1), when it's the provider's own URL, or when yourharness.tomlsets the samebase-urlfor that use. Any other endpoint is ignored: the decider goes where the provider's base-URL variable says (SYSTEM_ONE_BASE_URL,OLLAMA_HOST), else to its default. A provider with no default (system-one,cloudflare) then can't be reached, and the hook makes no decision. - Policy files must be relative paths inside the project, and regular files; anything else isn't read, and the hook makes no decision.
- Hooks are written only for
approve,stop,routeandtool, on the eventshlock decide hookanswers for that harness (PermissionRequest,PreToolUse,Stop,BeforeTool, โฆ).synclists anything else as a manual step and writes nothing for it. - Settings out of range. A
min-probabilityoutside 0โ1 is ignored (the default applies), and answers' probabilities are clamped to 0โ1.
A hook finishes within its timeout-ms, retries included, so the harness never gives up on
it first. A refusal, an answer without a probability, or a route that isn't one of the
options makes no decision.
What each use does¶
approvegets the tool call (its name, input and directory) and your policy, and picksallow,askordeny. In Claude Code and Codex it's wired to thePermissionRequestevent by default: it only sees calls you'd be asked about anyway, so it adds no delay to the rest. There it can deny a call, or approve it whenmayhasallow. Sethooks = ["PreToolUse"]to have it see every call; it can then also ask you about a call that would otherwise run (Claude Code only).stopgets your first request and the agent's last message when the agent stops. If it isn't sure the request is done, the agent gets one more turn with a reason. It never fires twice in a row, so it can't loop.toolgets the tools the agent's next request offers and the recent messages, and drops the ones the next step clearly doesn't need (P(needed) under 0.3). When it would keep all of them or none, it does nothing.routepicks which of the agent's model roles takes the next step. Only code harnesses ask it (below).
Which harnesses¶
| Harness | How | Uses |
|---|---|---|
| Claude Code | hooks in settings.json |
approve, stop |
| Codex | hooks in config.toml's [hooks] |
approve, stop (its PreToolUse can't ask) |
| Gemini CLI | hooks in settings.json: BeforeTool, AfterAgent, BeforeToolSelection |
approve (allow or deny), stop, tool |
| Cursor | hooks in hooks.json: preToolUse |
approve |
| Copilot CLI | .github/hooks/hlock.json in the repository, the only place Copilot reads hooks |
approve (deny only) |
| Amp | a plugin hlock writes: plugins/hlock-deciders.js |
approve (allow or refuse), stop |
| OpenCode | a plugin hlock writes: plugins/hlock-deciders.js |
approve (refuse only) |
| Pi | an extension hlock writes: extensions/hlock-deciders.ts |
approve (refuse only) |
| Hermes | its own auxiliary.approval setting, recorded by hlock pin |
approve (read, not written) |
| Code harnesses | environment variables, and ctx.decide (below) |
any |
reference |
simulated by hlock resolve |
any |
pin and resolve say so when a [deciders.*] use can't be wired into the harness.
Goose has no hook a decider can answer yet. Pi's own classifier models are built-in
providers it reaches with your keys, not configuration, so pin has nothing of them to
record.
The plugins for Amp, OpenCode and Pi are small files that run hlock decide hook and act
on its answer; they do nothing when hlock isn't there or isn't sure. They're tested by
loading them with a stand-in for each harness's plugin API, not inside the harnesses
themselves. Cursor's docs don't say whether cursor-agent runs hooks.
Code harnesses¶
A code harness gets HLOCK_DECIDER_<USE> (the reference), HLOCK_DECIDER_<USE>_BASE_URL
and HLOCK_DECIDER_<USE>_PROTOCOL for each locked decider. For PydanticAI it also sets
SYSTEM_ONE_BASE_URL and SYSTEM_ONE_API_KEY, and for LangChain-based harnesses and
Google ADK TYPESAFE_BASE_URL and TYPESAFE_API_KEY, so their own decision-model support
uses the locked one with no code.
To ask hlock's questions instead, a Python entrypoint calls ctx.decide:
def run(prompt, ctx):
pick = ctx.decide("route", options=["planner", "executor"], state={"step": prompt})
role = pick.get("answer", "planner") # {} when there's no decider or it isn't sure
keep = ctx.decide("tool", tools=["search", "edit", "deploy"], state={"step": prompt})
...
ctx.decide("approve", tool=โฆ, input=โฆ) and ctx.decide("stop", request=โฆ, last_message=โฆ)
work the same way. Other code harnesses run $HLOCK_BIN decide hook <use> --format plugin
--lock "$HLOCK_DECIDE_LOCK" --input '<JSON>' themselves. During hlock resolve,
HLOCK_DECIDE_LOCK holds just the trial's deciders.
Check and change them¶
hlock check reports a decider that changed in harness.toml, a changed policy file,
and a newer hlock whose questions for a use differ from the ones locked (hlock sync
--plan shows whether the hooks are in place):
$ hlock check
โ drift: your claude-code setup changed since harness.lock was pinned
decider approve: policy .hlock/approve.md changed since the lock was written
hint: re-pin with hlock pin --user, or restore it with hlock sync --in-place
With any-of, hlock resolve measures each candidate on your evals and
locks the winner. A list of min-probability values is searched the same way, so resolve
can pick how sure a decider must be:
[deciders.stop]
any-of = ["ollama:kev:4b", "ollama:kev:9b"]
min-probability = [0.7, 0.9] # four candidates: each model at each threshold
Outside resolve, pin locks the first value of the list. The offline reference harness simulates deciders, so it can't show
what one adds; resolve against your own harness for that.
hlock localize moves hosted deciders to Kev on the local runtime (the 9B
with 16 GB or more to spare, else the 4B), and notes that their scores may change.
hlock upgrade lists a newer version of a locked decider
(typesafe:jev-1.13.0 โ typesafe:jev-1.14.0), and with --apply re-resolves with it,
behind the same evidence gate as model upgrades.
How they're locked¶
[[decider]]
use = "approve"
provider = "typesafe"
model = "jev-1.13.0"
protocol = "systemone"
endpoint = "https://api.typesafe.ai"
key-env = "TYPESAFE_API_KEY"
resolved-from = "typesafe:jev-1.13.0"
via = "hook"
may = ["deny", "ask"]
questions = "sha256:โฆ" # hlock's questions for this use
[[decider.wiring]]
file = "settings"
event = "PermissionRequest"
[decider.policy]
path = ".hlock/approve.md"
digest = "sha256:โฆ"
See the lockfile reference for every key.