How-to
Wire the seat into an agent runner#
One entry, for any runner that speaks the Model Context Protocol (MCP) over stdio:
$ ljos onboard
{"mcpServers": {"ljos": {"type": "stdio", "command": "ljos-mcp", "args": [], "env": {}}}}
Paste it where the runner keeps its MCP servers. The env is empty on
purpose. The server names the seat after the client that connects, so a
runner that calls itself acme-cli is the seat acme-cli and a
runner that calls itself brio is brio, with nothing set per runner.
A shell the runner opens finds the same seat through LJOS_SEAT, or through the markers the
runner leaves in the environment. A derived name is a seat. Each conversation keeps its claims as
the seat tagged with its session, so two conversations of one runner hold two tickets.
$ ljos seat
seat acme-cli
holder acme-cli-39u
source the client name the MCP server received
The protocol goes where the agent reads its instructions: ljos protocol
prints it, the server serves the same text at ljos://protocol and names
it in its instructions, so a runner that loads neither skills nor
resources still reads it first. Each tool description opens with when to
call it.
A runner that registers servers by a command of its own, or reads them
from a config file, can be described once in
~/.config/ljos/harnesses.toml and onboarded in one verb.
ljos onboard --example prints the shape:
[[harness]]
name = "runner-with-a-command"
register = ["runner", "mcp", "add", "-s", "user", "ljos", "--", "{server}"]
registered = ["runner", "mcp", "get", "ljos"]
skills = "~/.runner/skills"
[[harness]]
name = "runner-with-a-config-file"
config = "~/.other/config.toml"
marker = "[mcp_servers.ljos]"
snippet = "\n[mcp_servers.ljos]\ncommand = \"{server}\"\nargs = []\n"
skills = "~/.other/skills"
$ ljos onboard --harness runner-with-a-command
ok runner-with-a-command mcp ran runner mcp add -s user ljos -- ljos-mcp
ok skill wrote /home/you/.runner/skills/ljos/SKILL.md
--dry-run reports what would be written.
Codex runs a hook only after you trust it. After ljos onboard --harness codex,
open /hooks and trust each ljos hook before it runs.
Trust it again after an upgrade rewrites one. Until then the hook does not run,
and policyd sees none of Codex’s commands. ljos doctor reports an untrusted hook when
the trust file has no trusted_hash, or is older than the hooks file.
The example carries the runners this seat has carried through one piece
of work, each in the shape it takes the server: a runner with an mcp add of its own by that command; grok and a runner with a TOML config
by a table appended to it; opencode and omp by a JSON pointer set in
their MCP file; hermes by its mcp add with the tool question
answered. The same verb takes cursor (the IDE and the agent CLI share
~/.cursor/mcp.json), windsurf, zed, vscode, claude-desktop,
gemini, amazonq and kiro. Copy the tables for the runners on the
machine into harnesses.toml, then one verb each:
$ for h in opencode hermes omp grok; do ljos onboard --harness $h; done
$ ljos doctor --all | grep runner
ok runner mcp opencode: ljos registered
ok runner skill opencode: /home/you/.config/opencode/skills/ljos/SKILL.md
ok runner mcp hermes: ljos registered
ok runner skill hermes: /home/you/.hermes/skills/ljos/SKILL.md
...
A runner with a hooks file (hooks in its table) takes the memory hook
too; for the others the skill and the ljos://protocol resource carry
what the seat knows, and the sitting’s island brings the memories in.
Seat a shell-only agent#
Grok Bot runs shell commands on a Linux box. It is not an MCP runner and it has no command hook, so the env file names its seat.
$ ljos onboard --harness grokbot --skills ~/.grokbot/skills
ok skill wrote /home/you/.grokbot/skills/ljos/SKILL.md
ok env wrote /home/you/.config/ljos/grokbot.env; source /home/you/.config/ljos/grokbot.env
$ source ~/.config/ljos/grokbot.env
$ echo "$prompt" | ljos hook --prompt
$ ljos policy --fail-on-deny -- git push --force
--skills DIR writes the protocol there. ljos onboard --harness shell --skills DIR
is the same shape under the name shell. The env file sets
LJOS_SEAT to the runner’s name. Nothing is registered as an MCP server
or a hook. --fail-on-deny exits 1 when the answer is a deny. Without
the flag, ljos hook and ljos policy still exit 0, so a runner that
reads the JSON sees no change. An ask still exits 0; the text says ask.
Add a seat, and let it close tickets#
Coming in 0.35.0. The tracker’s vissue.toml lists the seats. ljos seat add NAME writes the row,
so nobody edits the file by hand.
$ ljos seat add carol
By default a seat cannot close a ticket. ljos finish leaves the ticket open, and a person closes it.
A grant changes that for one seat. Here a person says in their own words that carol may close
the tickets she filed or has claimed, and a recorder writes it down:
$ ljos seat grant carol close=own --quote "carol may close what she files" --at 2026-10-29T02:00:00Z --via alice
The row lives in vissue.toml. It holds the quote, the time, the recorder named by --via and
the tracker commit. A grant edited into the file by hand is ignored. A seat cannot grant itself.
Every close that uses the grant cites the record.
Write to another seat#
Seats on one pack write to each other as pack atoms. ljos sync carries
those atoms in the sealed log, so a seat on another machine sees them
after a sync.
$ ljos group ops --add alice --add bob
$ ljos send bob "the build is red" --issue acme-4 --interrupt
$ ljos inbox
$ ljos read ID
$ ljos reply ID "looking"
ljos send SEAT TEXT writes to one seat. ljos send --group NAME TEXT
writes to the other members. --interrupt puts the message ahead of the
others on the next prompt. --issue ID threads it on that vissue issue,
and a reply keeps the issue. ljos inbox lists what this seat has not
read, and writes no receipt. A pack that does not answer is an error.
A writer that refuses kind message is an error too: the command exits
non-zero, names packset 0.13.0, and does not print an empty inbox.
ljos send names the packset version that keeps mail. ljos read ID
writes one receipt. ljos reply ID TEXT writes back to the sender.
ljos hook --prompt shows unread mail at the start of the next prompt,
at most eight messages and interrupts first, and writes one receipt for
each message it shows. The rest stay unread. The receipt atom’s text is
four lines, read ID, by SEAT, at STAMP and msg ID. The sender’s
next inbox prints that receipt once, as the message id, the reader and
the stamp, and a second prompt writes nothing. Shown there is read.
A shell writes the receipt before the hook exits. Cursor holds the note
on the prompt and writes the receipt when it is delivered: postToolUse
puts the note in additional_context, and a turn with no tool puts it
in followup_message on Stop.
A shell seat has no hook, so it polls ljos inbox and reads with
ljos read.
Wake a seat when mail reaches it#
Mail waits until the recipient’s next prompt, and an idle agent has no next prompt.
A seat names how to wake it, and ljos send starts that wake after the message is written.
The sender does not wait on it and needs to know nothing about the recipient’s runner.
$ LJOS_SEAT=claude-review ljos wake --timeout 1800 --command 'cd ~/src/app && flock -n /tmp/claude-review.lock claude -p --resume SESSION "Read your ljos mail and act on it."'
$ LJOS_SEAT=codex-ci ljos wake --timeout 1800 --command 'cd ~/src/app && codex exec resume --last "Read your ljos mail and act on it."'
$ LJOS_SEAT=cursor-cli ljos wake --timeout 1800 --command 'cd ~/src/app && agent -p --resume=CHAT_ID "Read your ljos mail and act on it."'
$ LJOS_SEAT=grok-bot ljos wake --webhook https://api2.cursor.sh/automations/webhook/ID --key-file ~/.config/ljos/grok-bot.key
$ ljos wake claude-review --fire
A command runs under sh -c with LJOS_SEAT set to the recipient, so ljos inbox in it reads the right mail.
With a prefix wake, LJOS_SEAT is still the run seat that matched the prefix.
LJOS_WAKE_FROM, LJOS_WAKE_MSG, LJOS_WAKE_ISSUE and LJOS_WAKE_INTERRUPT name the message, so a wake can ignore plain mail and act only on an interrupt.
policyd judges the command when it is set and again on every run.
A deny or an ask keeps it from running, since nobody is there to answer an ask.
A command is stopped after 30 seconds; --timeout S changes that, and an agent run such as claude -p needs a longer one.
Two sends close together start two wakes, so a wake that resumes a session holds a lock (flock -n) and leaves a running session to its Stop hook.
A webhook gets the envelope as JSON: the seat, the sender, the message id, the issue, the interrupt flag, and via when a prefix matched.
The body stays in the pack, and the woken agent reads it with ljos inbox.
The key is read from --key-file or the variable --key-env names, and never written to wake.toml.
A key is a prefix only when it ends in one trailing - after a non-empty stem; names such as -, -- and a-- are exact names.
A Grok Bot wakes this way: make a routine with a webhook trigger, copy its URL and sender key from the routine’s panel, and save the key in a file only you can read.
A wake that fails or runs past its timeout is a line in ~/.local/state/ljos/mail/wake.log, and the message is still in the pack.
ljos wake SEAT --fire runs the wake now and prints what happened.
A session that is still working needs no wake.
When it is about to stop, its Stop hook holds the turn once for unread interrupt mail.
Claude Code, Codex and Grok Build get decision: block with the mail as the reason, and Cursor gets a followup_message.
Each letter is receipted once it is delivered, so the next stop is not held for it again.
Plain mail waits for the next prompt or the wake.
Inject memory before the agent acts#
Add hooks to the runner’s table and onboard again:
[[harness]]
name = "runner-with-a-command"
register = ["runner", "mcp", "add", "-s", "user", "ljos", "--", "{server}"]
registered = ["runner", "mcp", "get", "ljos"]
skills = "~/.runner/skills"
hooks = "~/.runner/settings.json"
$ ljos onboard --harness runner-with-a-command
ok host key /home/you/.config/deedar/host.key exists
ok runner-with-a-command mcp ljos registered
ok hook memory hook: add it on UserPromptSubmit in /home/you/.runner/settings.json
ok skill /home/you/.runner/skills/ljos/SKILL.md is current
From then on, before each prompt, the runner pipes it to ljos hook and
the memories it activates return as context, preferences first.
Do not add PreToolUse as a search event. A turn issues many tool
calls and one prompt. On a tool call the hook asks ljos-policyd, the
Trusted Computing Base (TCB), and then applies pack rules. A deny blocks.
If policyd is missing, slow or crashes, the command runs and ljos logs a warning.
$ echo '{"hook_event_name":"PreToolUse","tool_input":{"command":"git push --force"}}' | ljos hook
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"git-force-push: it rewrites shared history, so the agent asks you first (ljos rule `git-force-push`)"}}
An ask rule uses the caller’s approval UI when that UI is supported.
Otherwise the hook stops the command and prints a request id. Calling
ljos_request_approval with that id shows a consent form in a client
that can display one. Otherwise the person replies approve REQUEST_ID
in the same conversation, where the prompt hook records the grant, or runs
ljos approve REQUEST_ID in a terminal of their own. The command refuses under an agent
runner or without a terminal. A request from another conversation is not
granted from this one. An agent therefore cannot grant itself consent. Once the person has
approved, the agent retries the original tool call.
$ ljos approve REQUEST_ID
Approved once: git push origin main
Directory: /work/project
Conversation: example-session
Retry the original command.
REQUEST_ID is the 32-character hexadecimal id printed by the hook, not
an arbitrary command or a wildcard. The receipt names the exact command
and the directory and conversation reported by the hook. A tool-level
working directory takes precedence when the caller includes it. A caller
that supplies only its conversation directory does not expose shell
working-directory overrides to this check.
The grant lasts for one matching attempt and expires fifteen minutes after the request. A changed command, reported directory, conversation, tool, or rule needs its own approval. Retrying an expired or consumed id is refused. Deny rules and policyd still run on every attempt and take precedence over consent. Native execution permissions still apply after the hook accepts the retry.
Requests live in the private login runtime directory, under
ljos/approvals/requests.json. They are temporary consent records, not
standing rules or memories. The file is locked while a grant is recorded
or consumed; unavailable or invalid state keeps the command blocked.
Grok Build. ljos onboard --harness grok writes
~/.grok/hooks/ljos.json. The hook searches on the prompt, keeps the
text, and emits it on PostToolUse. The page Grok Build
is the install.
A policy daemon does the same with the argv:
$ echo "cargo test --release" | ljos hook
- [preference] Build and test on the cluster through Slurm, never on the laptop.
- [lesson] The lme dense run needs 40G and sessions only; windows and sessions ran past four hours.
$ ljos policy cargo test --release
cargo test --release
Remembered, oldest first (`ljos search` for more):
Let policyd fix a command#
Coming in 0.35.0. policyd can answer a command with a fixed one instead of a refusal.
The answer is one line of three tab-separated fields: allow, rewrite and the new command.
The first rewrite runs a command through uv run.
The new line runs only when the guard also allows it. Claude Code, Codex, Grok Build and
Cursor’s preToolUse take it through the hook’s updated input. Other runners get a refusal
that names the new command, so the old line never runs. ljos policy -- COMMAND
prints the answer without running anything, so you can see a rewrite before an agent meets it.
Each swap also shows a note naming both commands and leaves a row in
$XDG_DATA_HOME/ljos/rewrite.jsonl. Set rewrite = false in a repository’s
ljos.toml to run its lines as written. The log keeps a row for a swap it declined too.
Choose which branches the push check covers#
ljos keeps one push check. A push onto a protected branch asks when the ticket of your
sitting has no settled consensus. The check asks and never denies. It covers develop and main
unless consensus_branches in ljos.toml names others. The user and seat files set the list.
The repository’s ljos.toml, LJOS_CONFIG and .ljos/gates.toml only add branches to it.
# ljos.toml
consensus_branches = ["main", "release"]
Everything else that concerns a push is policyd’s. An ordinary push of a branch runs, and so does a
lease push to a feature branch. A lease push or a force push onto a protected branch asks.
For that ask, main, master and develop are always protected, and protected_branches adds more.
Deleting a remote branch and rewriting published history ask or deny.
With one active seat in vissue.toml, that seat’s own vote settles consensus for the check.
This is solo mode, and it is coming in 0.35.0. With two or more seats, the normal quorum applies.
For a push that must never happen, protect the branch on the forge as well.
Let Jev judge the prompt#
The prompt hook makes three judgments locally. A cross-encoder picks
the claims that bear on the prompt. Two phrase lists spot a correction
and a choice put to the agent. Jev, TypeSafe’s decision model, can answer
all three in one call, and a fourth route, review, weighs a due claim.
It stays off unless the machine turns it on, because the call sends the
prompt and up to ten candidate claims off the machine. When the judge
does not answer, the hook reranks with the local cross-encoder. A
correction the hook files is a proposal, and a pasted or quoted prompt
files none. A standing choice the person states (“from now on”,
“always”, “never”) is saved in their words, outside code and quotes.
The same call asks two more questions, since a call costs its input
tokens and each question adds about 70 of them to a median of 933. An
injection question asks whether quoted or pasted text addresses the
agent with instructions the person did not write; at cue_at the hook
adds a note telling the agent to treat that text as data. An effort
question scores the prompt from 0 (a lookup) to 3 (a design or a hard
debug); it changes nothing in the hook and goes to
$XDG_STATE_HOME/ljos/jev-log.jsonl as the record a router is scored on.
Keep the key in pass and write the setting:
$ pass insert api/typesafe/jev
$ cat > ~/.config/ljos/jev.toml <<'TOML'
enabled = true
key_cmd = ["pass", "show", "api/typesafe/jev"]
TOML
$ ljos doctor --all | grep jev
ok jev on jev jev-1.13.0 0 calls, 0 cached $0.0000 of $4.00 this month
The hook runs the key command once per login, with no terminal and
three seconds to answer, and keeps the key in
$XDG_RUNTIME_DIR/ljos/jev-key (mode 0600). key_file names a file
instead.
The call goes out only where it can change the answer:
check |
default |
skipped prompt |
|---|---|---|
|
4 |
“yes”, “keep going” |
|
2 |
fewer claims than a choice needs |
|
4.0 |
any, once the month’s spend reaches it |
A skipped prompt gets the lexical search and the phrase lists. With Jev on, the hook never loads the cross-encoder, so its memory stays free. A prompt past 2,000 characters goes out cut to that length.
A prompt with ten candidates takes about 900 input tokens. At $0.042 per
million input tokens, with output free, that arithmetic is a prompt cost
of $0.00004. The
hook records each call’s tokens at usd_per_mtok_in, or the API’s own
figure when OpenRouter sends one, in $XDG_STATE_HOME/ljos/jev-cost.toml.
For OpenRouter, set endpoint = "https://openrouter.ai/api/alpha/decisions"
and model = "typesafe/jev-1.13".
Jev answers each question with a probability. bears_at (0.5) is the
one at which a claim goes in, and cue_at (0.5) the one at which a
prompt counts as a correction or a choice. Raise bears_at to 0.7 when
off-topic claims come through.
Jev keeps no cache of its own, so ljos keeps one. An identical request
within cache_days (7) is answered from $XDG_CACHE_HOME/ljos/jev at no
cost, and the doctor row counts those answers as cached. A panel asked
twice, a retried ballot and a repeated prompt with the same claims all
hit it. cache_days = 0 turns it off.
Two or more judges can answer side by side. Each [judges.NAME] table
names a judge. [route] says which judges answer each decision. Here a chat
model on an OpenAI-compatible endpoint judges the prompt, Jev and that
model pool their ballots, and the chat model reviews due claims:
enabled = true
key_file = "~/.config/ljos/jev.key" # the default judge: Jev
[judges.chat]
backend = "chat"
endpoint = "https://llm.example.org/v1"
model = "gpt-oss-120b"
key_env = "LLM_API_KEY"
budget_ms = 6000
[route]
prompt = ["chat"]
ballot = ["default", "chat"]
audit = ["chat"]
review = ["chat"]
A decision with two or more judges is answered by their pool. A
probability is the weighted mean of the judges’ log-odds. Two judges at
0.8 stay at 0.8, and judges at 0.9 and 0.1 meet at 0.5. A choice is
the normalised weighted geometric mean of their distributions, and a
score the weighted mean. weight in a judge’s table moves its share. A
question no judge answered is missing from the pool, so the caller falls
back as it does on a partial reply. A caller with no judge at all takes
its local path on any of these: no file, enabled = false, no key, an
error, or no answer inside budget_ms (2000). The log keeps every judge’s answer
beside the pool, so each judge can be scored once outcomes are known.
These judges are fast and calibrated, and they do not reason. A decision they leave open goes to a persona that does: a persona with a runner reasons in a session of its own.
$ ljos persona buildengineer --anchor 0.25 --view "Runs production pipelines." --runner grok
$ ljos vote surf-ab12 --as buildengineer --jev
buildengineer: Jev leaned A at confidence 0.61, under the 0.80 cut; not cast.
buildengineer reasons in its own session in herdr pane w1:p2; then `ljos consensus surf-ab12`
The runner is any coding agent the machine runs: Claude Code, Codex,
Grok Build, omp, hermes or Antigravity’s agy, each on whatever model it
is set to, from Claude Opus or GPT-5 to gpt-oss-120b on an
OpenAI-compatible endpoint. It is a [[harness]] in
~/.config/ljos/harnesses.toml. Its
start and resume argv say how it opens a session and how it resumes
the latest one in a directory, for example resume = ["grok", "--continue"]. It starts in a pane the person can watch and talk in: a
herdr workspace when herdr’s server is up, else a window of the tmux
session ljos-personas. A [[tool]] the runners file declares is tried
before either (see Open persona panes in another tool). A runner that
exits non-zero is resumed in its pane, at most three times a minute. It
works in the persona’s home, $XDG_STATE_HOME/ljos/personas/NAME, as the
seat named after the persona. Its memories, ballots and trust rows are
therefore the persona’s. It reads the issue and the pack, writes its
reasoning with ljos note, and casts ljos vote --as NAME. Each
runner continues the latest session of the directory it starts in, so
the next hand-off resumes the same conversation, and an open pane takes
the next task in place. A task goes to the persona’s inbox, and the pane
is told the file in one line. ljos ask NAME "..." puts a question to
the persona the same way. ljos supervise NAME --harness RUNNER --exit crash --at SECONDS
says whether that child should be started again, under the restart
policy (permanent, transient, temporary, three strikes in a minute), and
prints the herdr line when the answer is yes. It does not start the pane.
ljos panel --jev hands each unsure persona’s
brief to its own session when the panel splits. A persona with no runner
gets a brief for a subagent, as before.
ljos due --judge puts the due page to the review judges. Each claim
goes with the newer claims the pack keeps on the same subject. A claim
the pool puts at 0.9 or more is graded recalled. One at 0.1 or less
is printed as contradicted, for the agent to supersede or withdraw, and
stays due. Everything between stays due. No judge lapses a claim,
because a lapse says that a reader forgot it.
Open persona panes in another tool#
ljos doctor names the tool persona panes open in:
$ ljos doctor --all | grep panes
ok panes herdr opens persona panes; tmux also here
herdr opens one workspace a persona, in the persona’s home and as its
seat, and types the pane script into the root pane. It hands the runner
each task through herdr agent prompt, or as typed keys when herdr does
not know the runner as an agent. tmux gives each persona a window in the
session ljos-personas. LJOS_PANE_TOOL narrows the choice to the one it
names. Any other tool is a [[tool]] table in
~/.config/ljos/harnesses.toml, with each verb but pane_pointer an argv
or a list of argvs. A table named like a built-in shape replaces it. A
terminal multiplexer with a command line takes a table like this:
[[tool]]
name = "mytool"
detect = ["mytool", "--version"]
open = [["mytool", "new-pane", "--cwd", "{home}", "--name", "{label}", "--", "sh", "{script}"]]
prompt = [["mytool", "send", "--pane", "{label}", "{line}"], ["mytool", "send-key", "--pane", "{label}", "Enter"]]
alive = ["mytool", "has-pane", "{label}"]
ready_s = 8
A tool that refuses says so in the line ljos ask prints, with the
tool’s own words, and the next tool is tried.
Wire the seat into Cursor#
$ ljos onboard --harness cursor
ok cursor mcp set /mcpServers/ljos in /home/me/.cursor/mcp.json
ok hook added the seat's hook on beforeShellExecution, preToolUse, beforeMCPExecution, beforeReadFile, beforeSubmitPrompt, postToolUse, postToolUseFailure, preCompact, stop, sessionEnd in /home/me/.cursor/hooks.json
ok skill wrote /home/me/.cursor/skills/ljos/SKILL.md
ok agents wrote 5 and removed 0 agent definitions in /home/me/.cursor/agents
Cursor’s IDE and its agent command line take the server from the same
~/.cursor/mcp.json and the hooks from ~/.cursor/hooks.json. Cursor
does not hand a prompt hook’s context to the model, so the memories a
prompt activates arrive with the first tool result instead, as on Grok
Build. A shell command gets an ask rule’s question as Cursor’s own
permission prompt. Cursor runs the Claude hooks itself where
ljos onboard --harness claude already ran. The events Claude’s file
names stay there. beforeShellExecution, preToolUse,
beforeMCPExecution, beforeReadFile and postToolUseFailure are
still written to ~/.cursor/hooks.json. If a hook fails, the command runs. The seat answers
both files in Cursor’s shape.
Spawn a persona as a subagent#
$ ljos agents --harness grok
ok agents grok wrote 5 and removed 0 agent definitions in /home/me/.grok/agents
Every persona becomes ~/.grok/agents/ljos-NAME.md, and Grok’s main agent
can spawn it by name on a decision with spawn_subagent and
subagent_type ljos-reviewer. The persona reads ljos brief, casts one
ballot before reading the others, notes why, and stops. Grok runs it in
capabilityMode: execute, with commands and no edit tool. Claude Code
reads the same definitions from ~/.claude/agents, and Cursor’s copies go
to ~/.cursor/agents; both ignore that mode, and the body tells the
persona not to edit. onboard writes the definitions for a runner whose
table names agents, and a persona written later refreshes each such
directory that exists.
Show the seat in the status bar#
In ~/.grok/config.toml:
[ui.status_line]
type = "command"
command = "ljos statusline"
In ~/.claude/settings.json:
{"statusLine": {"type": "command", "command": "ljos statusline"}}
The row reads ljos grok · surf-ab12 · 3 due: the seat, the issue the
conversation has open, the claims due for review. A session’s line is cached
for fifteen seconds. A pack that does not answer in 300 ms shows as
pack down.
Put the judgments to a local model or a runner#
The same four judgments go to any judge that answers in Jev’s shape.
backend in jev.toml picks it; the parsers, the cache, the ledger and
the doctor row are shared, and every fallback above still applies.
A chat-completions endpoint, hosted or a local llama-server, takes one
JSON-mode chat completion per judgment set. endpoint is the base URL
and model the name the server knows. A local server needs no key;
usd_per_mtok_in = 0 keeps the ledger exact for one that is free.
enabled = true
backend = "chat"
endpoint = "http://127.0.0.1:8089/v1"
model = "Qwen2.5-7B-Instruct"
usd_per_mtok_in = 0
budget_ms = 6000
A runner on the machine is the judge through command: an argv that
reads the request JSON on stdin (state, and questions, each with a
type of noul or choice, instructions and criteria) and prints
{"answers": {...}} on stdout inside budget_ms. A noul answer is
{"noul": p} or a bare number; a choice answer is {"choice": KEY, "confidence": p, "probabilities": {...}}, and a bare key means a
sure one. A question with no answer refuses the whole reply, so the hook
takes its local path rather than half a judgment.
enabled = true
backend = "command"
command = ["/path/to/judge.sh"]
budget_ms = 20000
The hook budget bounds the prompt path, so a harness that takes seconds
suits ballots and the stop audit more than the prompt hook; min_words
and min_candidates keep the prompt calls few. The log carries the
backend name, and ljos doctor prints it before the model.
Give an agent the protocol without a runner#
$ ljos protocol > instructions.md
The text is plain markdown: which store answers which question, the order of verbs before, during and after the work, and the refusals worth knowing. Put it where the agent reads its instructions.
Ask what a task involves#
$ ljos island "write the handbook tutorial for the seat"
1.000 seed ab8cb4d1bfca05ca60730141343dc177 3 weeks ago Encoding under the write lock capped the pack at
0.899 seed 51778929d855529fcb8df3f4d1c4d692 yesterday A new claim enters the review clock on write. Tr
0.890 seed 27e39df0198b67538f8487b1b44e9dfd today The decay slot reads the review clock when PACKS
The first column is activation relative to the strongest; seed marks a
claim search found itself, the rest were reached along the pack’s links;
the age column says when each was written. Add --fire when you go on to
use the island: the strongest eight fire together and their links gain
weight.
Bump a recipe with eb-stack, and keep what the campaign taught#
One sitting on the ticket, one island per recipe before it is touched:
ljos sitting demo-319z
ljos island "eOn 2.17.10 foss 2026.1"
Then the ladder, each rung its own artifact: eb-stack recipe check,
eb-stack package bump (the lock under out/locks is resolves),
eb-stack recipe lint, eb-stack target doctor, eb-stack campaign run and campaign status (builds, binary-verified). Over MCP the
same verbs are eb_recipe_check, eb_package_bump, eb_recipe_lint,
eb_target_doctor, eb_campaign_run, eb_campaign_status, beside
ljos_sitting, ljos_island, ljos_remember and ljos_finish.
For a whole generation, put the bundle on the tracker before anybody builds, and let the graph hand out the modules, each up the same ladder:
ljos bump-plan out --project demo --parent demo-319z --dry-run
ljos bump-plan out --project demo --parent demo-319z
vissue ready -p demo
The plan writes one child issue per module the lock builds, blocked by
the modules built before it; ready is the frontier a seat can sit on, and ljos sitting
refuses a module whose blockers are still open. The ids are a hash of
the module and the generation, so running it again after the bundle
changed adds the new modules and keeps the rest.
When the campaign ends, its typed findings are the lessons:
ljos findings out/campaign.json
ljos findings out/campaign.json --remember --issue demo-319z
The first prints one line per finding. The second files one lesson per
finding a person or a seat resolved, under the recipe’s name, the
package and the failure class, with origin tool-echo at L0 (derived).
It does not write the atom. ljos accept ID promotes it to explicit
and writes it as a standing lesson, and the command cites the
state file on the issue as a deed. A finding a later attempt merely got
past is skipped; --all takes it too. Once someone accepts the
proposal, the recipe’s next bump recalls this lesson from its island.
GCCcore-15.2.0 on terra: compile failed in the build step with fatal error: linux/scc,h. Fix: applied the GCC 14 libsanitizer kernel headers patch in GCCcore-15.2.0.
Keep a number#
A number the seat measures again and again, a benchmark score, a hook latency, a count of open tickets, is a habit. A reading is one verb. The one below is an example:
$ ljos habit mab-cr-all 0.535 --unit acc --every 7d --source example-0000
7b1c... habit due 2026-09-26T10:00:00.000Z habit mab-cr-all stands at 0.535 acc (example-0000).
$ ljos habit mab-cr-all 0.579 --unit acc --every 7d --source example-0001
9e0a... habit due 2026-10-03T09:12:41.000Z habit mab-cr-all stands at 0.579 acc (example-0001).
was 0.535 acc (8 days ago), now closed
$ ljos habit
mab-cr-all 0.579 acc +0.044 since 0.535 (8 days ago) today next reading in 7 days example-0001
The second reading supersedes the first, so the pack keeps one live value
a habit and ljos search mab-cr-all --as-of 2026-09-15 still answers
what it stood at then. The cadence is the reading’s review clock: when a
week passes with no new reading, ljos due lists the habit and the hook
says so once a session. The sheet a paper’s table is written from is the
pack, and it is dated.
Ask when#
$ ljos timeline proj-1a2b
timeline of proj-1a2b: Ship the fuse change?
2026-08-30 13 days ago tracker created
2026-09-02 09:14 10 days ago +3 d memory [lesson] RRF lost two points to CombMNZ on turns.
2026-09-02 11:48 10 days ago same day tracker TODO -> STARTED
2026-09-02 11:48 10 days ago same day tracker claimed by you
2026-09-05 16:02 7 days ago +3 d deed deed-run-fuse-at-ten produced by you
2026-09-12 08:30 today +7 d tracker note: the fused panel held at ten conversations
Three stores, one dated list, oldest first: the tracker’s logbook, the
deeds the issue cites with the time each was produced, and the memories the
issue’s title activates with the time each was written. Each line carries
its date, its age, the gap since the line before, and the store it came
from. The reader gets time as data rather than timestamps to subtract, and a
later line supersedes an earlier one on the same matter. ljos sitting
prints the last twelve as its timeline section.
ljos search TOPIC --as-of 2026-09-01 asks the pack as it was then:
memories withdrawn since included, memories written since left out. The
pack tombstones rather than erases, so the question has an answer.
Start a sitting, and end one#
$ ljos sitting proj-1a2b
== doctor
ok vissue ...
ok seat alice, holding as alice (from LJOS_SEAT)
== cards
== due
unreviewed conclusion 3f9c... The lexical default is BM25+.
1 due; 4 scheduled, next at 2026-09-13T09:12:00Z
== island: Ship the fuse change?
1.000 seed ab8c... 3 weeks ago CombMNZ over RRF for fusing two ballots.
== blockers
none open; the issue is workable
== playbook
sit
A sitting on one issue. Name this recipe at open (`ljos sitting ISSUE --playbook sit` or `ljos playbook ISSUE sit`). The sitting prints this body before recall and holds the name until finish or release.
...
== recall
...
== timeline
timeline of proj-1a2b: Ship the fuse change?
2026-08-30 13 days ago tracker created
...
== reclaim
no claim quiet longer than 900s
== claim
gen=2
One verb, in the protocol’s order; it stops at the first store that does
not answer and claims nothing. --playbook NAME copies a recipe
(sit, arena, land, company-panel, overnight, feature) into the
playbook section before recall; absent a name, a closed-set token in the
title else sit. The name is a tracker playbook: note until finish or
release. ljos panel refuses until one is bound. The start_a_sitting prompt
gives the same order to a runner that prefers single tools. A claim
refused as busy names the issue you still hold; ljos complete finishes it
and ljos release hands it back.
$ ljos finish proj-1a2b --lesson "CombMNZ held on turns. RRF lost two points." --outcome ship
completed the session node for proj-1a2b as done
remembered the lesson (id 51778929) as agent-derived; the hook shows it from the next prompt, and `ljos memories review` prunes it
fired the island for "Ship the fuse change?": 8 memories. Those links gained weight under the seat, not under a persona. The next walk of this title follows them.
Learned. 6 trust rows rewritten. ... persona anchors moved. The outcome is kept: once 5 issues have one, ljos consensus discounts voters who err together. ... No stated probability, so there is no Brier score. A hard vote is not a claim of certainty.
the ticket proj-1a2b stays open and nobody holds it; a person closes it when the work is accepted
Vote with personas#
$ ljos persona reviewer --anchor 0.2 --view "Reads for what breaks in production." --about release
$ ljos persona reader --anchor 0.8 --view "Reads as a first-time user of the docs." --about docs
$ ljos brief reviewer $id
$ ljos brief reader $id
Each brief goes to its own subagent or run, which casts as its persona:
the reviewer’s runs ljos vote $id --for hold --as reviewer and the
reader’s ljos vote $id --for ship --as reader. One session that cast
both would be refused at the second, since a seat argument does not
make another voice. The seat then runs ljos consensus $id.
Each persona is one atom in the pack; its ballots carry its name. The
settle takes its anchor, never below the 0.5 every other voter on the
ballot, the seat too, settles at: the reviewer at 0.2 settles at 0.5 and
counts as much as a seat, and the reader at 0.8 moves most of the way
toward the others. A trust row scoped with --about docs weighs only on issues whose title says docs, and --about is
also what seats a persona: ljos panel ISSUE and the run_a_panel
prompt brief only the personas whose domains the issue’s title or its
island names, and every persona when none does.
A persona keeps its own tree over the seat’s facts. ljos remember --as NAME writes into the set persona-NAME, where the duplicate and
replacement rules run among its own conclusions and never against the
seat’s or another persona’s; the seat still reads every set. ljos island CUE --as NAME walks the pack through that persona’s link
weights, and with --fire tightens the paths it walked under its own
name (link_weights_by), leaving the seat’s weights as they were. Two
personas that read the same island differently end up with different
islands over the same claims.
Cast a reviewer’s ballot from its own run#
Each reviewer is its own subagent or run, such as a cloud agent or a CI job, and casts its own ballot rather than reporting a verdict in prose.
A subagent’s ballot binds the parent session plus the subagent, SESSION:AGENT: the agent_id Claude Code’s hook sees, or the Codex thread under its root session.
An opencode subagent is a session of its own.
Two subagents of one session count twice, the parent and a subagent count as two, and a subagent that wrote a candidate cannot vote.
A run on another machine needs ljos and the tracker there.
ljos cloud up installs ljos on that machine and clones the shared tracker repository that LJOS_TRACKER_REMOTE names, so the ballot is pushed where the other runs fetch it.
$ ljos cloud up
$ ljos vote $id --for ship --confidence 0.8 --used none
The run’s harness id is the ballot’s session, so two runs count twice and a run or subagent that votes again counts once.
When nothing names the run but the seat, ljos vote and the tally call the session unverified.
Persona ballots and Jev ballots are advisory.
The push check reads seat ballots only. See Choose which branches the push check covers.
Cast persona ballots through Jev#
With Jev on, a persona’s ballot can come from Jev instead of a subagent.
The issue needs an Options: A, B line.
ljos file "TITLE" --options A,B writes that line as the first line of the body.
ljos options ISSUE A,B appends it to an issue that already exists, and refuses fewer than two.
$ ljos vote ljos-abcd --as security-reviewer --jev
judge:jev-1.13.0: cast age for security-reviewer at confidence 0.96
$ ljos panel ljos-abcd --jev
3 personas on ljos-abcd through Jev: all sure, all age; one judge ballot cast
Jev reads the persona’s brief and answers two choice questions: the
persona’s vote, and what the rest of the panel will pick. The option
order is shuffled per persona. A cast ballot is recorded as
judge:MODEL, not as the persona, and --used names that judge. The
chosen option’s probability is the ballot’s confidence. The forecast is
the judge’s prediction, and consensus does not read it. A note on
the issue says the ballot came from Jev. learn updates the trust row
whose to is that judge.
Jev’s confidence is (K·p_max − 1)/(K − 1), the same cut on every
backend. Under escalate_below (0.8), vote --jev does not cast. It
notes Jev’s lean. A persona with a runner gets the ballot in its own
session; for any other, the command names the brief to start a subagent
from.
A panel casts only when every seated persona is sure and all agree. An all-one-model panel is one voter, so it casts one ballot. A split or unsure panel casts nothing and writes a brief per seat for subagents. The metered model then spends only on the contested questions. A ballot costs about 2,000 input tokens, or $0.00008.
Every Jev answer goes to $XDG_STATE_HOME/ljos/jev-log.jsonl, with the
claim ids, a hash of the prompt, the latency, a timeout or a failure,
and the backend and model. The file does not keep the prompt or the
claim text. ljos judge-score joins that log with outcomes and reports
accuracy and calibration per decision. ljos learn scores a cast
ballot’s confidence as it does any voter’s.
Hold a done claim beside a red test#
Stop also blocks one turn, with no model in the loop, when the
conversation has no issue and the turn used tools without touching
the seat. The agent is told to file an issue and sit. The next stop of
that same turn goes through.
With Jev on, the Stop and SubagentStop hooks audit the turn once
before the agent stops. The audit reads four parts of the turn, bounded
to a few thousand tokens:
the person’s last request
the shell commands the turn ran since that request
the latest test output, or the last output when no test ran
the final message from the agent
Jev answers three questions about it:
question |
blocks the stop at |
|---|---|
|
0.9 or above, with |
|
(with the above) |
|
0.9 or above |
Whether a test ran is decided in code, from the command line. A held
agent gets the reason and one more round; its second attempt to stop is
never audited, so it cannot loop. A final message under 80 characters is
not sent. The cuts sit in jev.rs beside the questions. A stop costs
about 3,000 input tokens, or $0.00013.
Move the trust rows without an outcome#
$ ljos calibrate -p demo
alice weighs bob at 0.917
alice weighs carol at 0.643
...
Every issue of the project with two or more ballots is an item; Dawid and Skene’s estimate gives each voter an accuracy from how often it agrees with the answer the others make likely, and each accuracy becomes the weight every other voter gives that voter. Run it once a project has a few voted issues, and again when it has many more.
Keep the cards#
Cards are two files a person writes: USER.md and MEMORY.md. The seat
reads them and never writes them; ljos cards prints them. The pack is
where the seat writes.
Sign what you hand over#
$ head -c 32 /dev/urandom > ~/.config/deedar/host.key && chmod 600 ~/.config/deedar/host.key
$ export DEEDAR_HOST_SIGNING_KEY=~/.config/deedar/host.key
$ ljos doctor --all | grep 'host key'
ok host key /home/you/.config/deedar/host.key (32-byte seed)
handover then signs the manifest and the log head. A receiver adds
signer = ed25519:<hex> to their deed store’s layout and receive
reports (accepted). Two seats share one ticket by this walk, in
sequence: A sits and hands over, B imports and sits after A releases,
both vote, consensus, finish closes. scripts/smoke.sh runs it on
scratch stores; scripts/herd.sh is the concurrent contention check.
Receive from the same sender again#
$ ljos receive /tmp/bag --keep ~/.local/share/ljos/peer.head # the first time
...
3 deeds proven against a log of 34 entries, root 9c1e...
kept this head in /home/you/.local/share/ljos/peer.head; pass it to --since next time
$ ljos handover --out /tmp/bag2 --issue proj-1a2b --since-size 34 # the sender, next time
$ ljos receive /tmp/bag2 --since ~/.local/share/ljos/peer.head --keep ~/.local/share/ljos/peer.head
--keep writes the head of the log the bag carried. Tell the sender its
size; handover --since-size puts the bridge from that size into the
bag. --since then needs the bridge to start at the head you kept and end
at the one in the bag, so a sender who rewrote the log in between fails the
check. A bridge alone proves nothing, because the sender picks both of its
ends. This needs deedar with check --keep.
Close what a later lesson replaced#
$ ljos consolidate
closes 3f9c… The default fuse is Borda.
for 51ee… The default fuse is CombMNZ.
1 of 97 live memories would close; `ljos consolidate --apply` closes them
$ ljos consolidate --apply
closes 3f9c… The default fuse is Borda.
for 51ee… The default fuse is CombMNZ.
1 of 97 live memories closed
A write closes the earlier claim it rewrites on arrival (same opening
words, a new object; a correction; an explicit supersedes). A pack written
before that rule, or filled by a handover, has pairs the rule never saw;
consolidate reports them, --apply closes them. The closed claim keeps
its window: ljos search --as-of finds it at the time it was live.
Retire a claim with its reason#
$ ljos forget 3f9c... --why deed-review-2026-09
The pack tombstones the claim and records the deed that withdrew it. Disagreeing with a claim is not showing it wrong; a retraction names its evidence.
Set trust by hand#
$ ljos trust alice carol 0.9 --why deed-postmortem-2026-09
One row, one weight in (0, 1], the deeds it rests on. A later row for the same pair supersedes it.
Settle under the pack’s trust#
ljos consensus ID reads the live trust rows and passes them to the
settle (and to the tracker’s, with --compare). To anchor voters to their own ballots (Friedkin-Johnsen) on the
model crate alone, pass the rows as JSON tuples:
$ ljos-consensus settle --issue ID --susceptibility 0.8 --trust '[["alice","carol",0.9]]'
Check the seat#
ljos doctor prints whether the seat is ready and the rows that need action, and exits 1 when a required part does not answer or the note queue is damaged.
ljos doctor --all prints every row.
The block is the ljos row that command printed, taken from ljos 0.32.1.
ljos --version printed ljos 0.32.1.
The row is the shape when the binary on PATH is that version: no behind and no ahead of in front of crates.io.
A day cache that already holds the crates.io answer prints crates.io (cached) in that place.
The path stands in for the binary.
On this tree the same run also read vissue-cli 0.21.0 and packset 0.15.3 from crates.io.
Those binaries were not on PATH, so the sample does not show them as ok.
$ ljos doctor --all
ok ljos /path/to/ljos 0.32.1 crates.io 0.32.1
Exit 1 when the tracker, the deed store, or the pack does not answer. The
tracker row names how many commits origin lacks, and fails when that count
sits through the push wait. It also fails when another remote of the
tracker has a different head of the branch, as of the last fetch: two
seats pushing to two remotes each read only their own writes. A sitting’s own tracker push goes to
every remote that carries the branch. A missing
claim graph is reported and is not a failure: the first claim creates it.
A seat nobody named is a derived seat, and a derived seat is a seat. The seat source row says where the name came from.
A damaged note queue is a no row and fails: it holds lessons the pack has not taken.
An empty or missing log of either is whole.