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

min_words

4

“yes”, “keep going”

min_candidates

2

fewer claims than a choice needs

monthly_usd

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

claims_complete

0.9 or above, with tests_green 0.1 or under and a test run in the turn

tests_green

(with the above)

deferral

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.