The runtime that drives a whole Linux desktop without an AI host in the middle. It is an ordinary MCP client and holds no privilege the socket does not grant — if it can only do what Claude Code can do through the same socket, then the socket is the security boundary and there is one boundary rather than two.
It has two faces over one runtime: a session in your terminal, and a chat panel in the browser beside the desktop it is working on. Same loop, same tools, same policy, same history.
This is how to use it. providers.md covers the models,
skills.md covers the instructions it can be taught,
stage-2-agent-engine.md covers why it is built this
way, and NOTICE.md says what was taken from whom.
One binary, one loop, one agent.db. Which face you are looking at changes who
is typing, not what is running.
| the terminal | the browser | |
|---|---|---|
| how it starts | sentineldesk-agent |
sentineldesk-agent -serve |
| who is at it | you, at a prompt, driving one goal | whoever is sitting in front of the desktop, in the panel beside the screen |
| what draws it | a TUI in this binary: session panel, command palette, scrollback, working mouse wheel | the desktop's own chat panel — the agent dials, the desktop listens |
| approving a tool | shift+tab toggles asking before anything that changes something |
never asks per call: the gate is the controls, on the socket, where the room already answers |
| with no model configured | starts anyway and offers /connect |
waits, and comes alive the moment a model is set, with nobody reloading a browser |
| its history | agent.db |
the same agent.db |
The last row is the one that matters. A session typed into a terminal and a
conversation typed into the browser are one runtime doing one kind of work,
which is the only arrangement in which the two can ever show the same history:
export 81 produces the same document whichever face produced session 81, and
costs counts both.
Neither face wraps the other and neither is the real one. serve is this binary
with no terminal attached; the terminal session is this binary with one. There
is no server process between them, no port, and no second implementation of the
loop to drift out of step with the first — the detail is under
serve below.
Most agent runtimes worth reading are coding agents. What they act on is a
repository, the unit of work is a file, and the answer to "that was wrong" is
git. Claude Code, opencode and Kilo Code all live there and are good at it.
This one acts on a desktop: a screen, windows, a browser, a terminal somebody may be watching, and a mouse there is only one of. Four things follow, and they are the whole of the difference.
1. There is no undo, so there is a trail. You cannot git revert a package
install or a form somebody submitted. So every run records the goal, every turn,
every tool call with its arguments and its result, and what each cost — into
agent.db on your machine, and into the desktop's own action log under a task
id the runtime generates. history <id> answers what did it do; export <id>
writes the whole record as Markdown you can search, diff and paste into a
review. The invariant the project is built around is that the agent is either
supervised by a person or leaving a trace, with no third state.
2. Somebody may be watching, so being observable is a setting, not a
request. --role witnessed closes the invisible path: where a visible
equivalent of a tool exists the runtime substitutes it, so run_command becomes
terminal_run and the work happens in a terminal on screen. The substitution is
enforced by the runtime and is not asked of the model, because evidence that
depends on a model remembering to be observable is not evidence. efficient is
the default — making a desktop flicker for a package install is theatre.
3. The desktop is shared, so concurrency is decided mechanically. Two sub-agents typing at once interleave keystrokes into one X display. Rather than guess, the runtime reads the annotation the server already puts on every tool: work that touches nobody's screen may run in parallel, anything that reaches the display takes its turn. A person taking the controls ends a run and the runtime is told, rather than finding out when its next click is refused.
4. One security boundary, on purpose. The agent is a plain MCP client over a unix socket. It does not import the server's dispatch, it cannot widen what the socket grants, and a policy overlay may only ever narrow it. Anything the runtime needs that cannot be expressed as an MCP call is a server gap to fix there, never a hole opened here. Nothing between the agent and the desktop uses a port or the network.
| runs anywhere | one static Go binary, CGO_ENABLED=0, no runtime, no node, no Python. linux and darwin, amd64 and arm64, from one machine |
| any model | Anthropic, OpenAI, OpenRouter, Ollama local, Ollama Cloud, or the claude CLI's own login. --model takes any string, so a model released this morning needs no new build |
| runs on your hardware | with Ollama there is no key, no bill and nothing leaving the machine |
| cheap on purpose | a measured 21× reduction per run, from caching the prompt prefix and offering a goal-ranked subset of the catalogue instead of all 120 tools |
| honest about money | tokens recorded exactly as the provider reported them; a model with no rate prints ? rather than zero, because reporting a day's work as free is the worst way to be wrong about a bill |
| teachable | skills in the published format — the same directories Claude Code and opencode look for, theirs included, so a skill written for either loads here unchanged. Twenty-one authored ones are compiled in, and skills install adds more from the ecosystem |
| speaks three languages | English, Spanish and Portuguese, compiled in. It changes the interface and the replies and never the tool arguments, because a shell does not speak Spanish |
| remembers | facts you tell it to keep, and an auto-compaction that folds a long run before it dies on a context limit rather than after |
| does not invent reasoning | there is no stored chain of thought, and the export says so rather than manufacturing one out of the tool log |
| fails loudly | a refusal carries its kind, so "wait your turn" and "never allowed" are never collapsed into "it did not work" |
1. Build it.
make build # this machine
make all # linux and darwin, amd64 and arm64, which is where it ships2. Run it.
./bin/sentineldesk-agentThat is the whole of it. With no arguments it finds the desktop's socket, opens
a session, and asks what you want done. No key is needed to get that far —
/connect inside the session takes one, checks it with the provider, and writes
it to ~/.sentineldesk/ for you.
3. If something is wrong, ask before spending anything.
./bin/sentineldesk-agent -doctorFifteen checks against a real desktop, no model involved. If this fails the problem is not the key.
You should not have to know, and normally you do not. With no -sock the
runtime looks, in this order, and takes the first one that actually opens:
$SENTINELDESK_SOCK |
stated, and never second-guessed |
$MCP_SOCK |
what the daemon itself was told to listen on |
/run/sentineldesk/mcp.sock |
inside the container, where the image puts it |
$XDG_RUNTIME_DIR/sentineldesk-mcp.sock |
a daemon running as a person |
the sentineldesk-run volume |
the host's view of the first one, asked of docker |
/var/lib/docker/volumes/sentineldesk-run/_data/mcp.sock |
the same, when docker cannot be asked |
Each candidate is opened, not stat-ed. The socket in the docker volume
exists for every user on the host and answers to root alone, so "the file is
there" and "I can talk to it" are different answers — and a failure prints every
path it tried with what was wrong at each, because the commonest outcome is a
socket sitting right there refusing your uid, which is a sudo away and nothing
like a desktop that is not running.
Naming one is still a flag, and a named path is obeyed exactly:
./bin/sentineldesk-agent -sock /var/lib/docker/volumes/sentineldesk-run/_data/mcp.sock
export SENTINELDESK_SOCK=/var/lib/docker/volumes/sentineldesk-run/_data/mcp.sockNothing is detected about the TRANSPORT, only about the path. A missing socket
never quietly becomes docker exec: a runtime that reached for docker when its
socket was absent would hide a misconfiguration on the box where it matters, and
"it worked on the laptop" is exactly how.
So -container stays opt-in. It is the development path — the binary runs on
your machine, reaches the desktop through docker exec, and the API call goes
out from here. The key never enters the container.
./bin/sentineldesk-agent -container sentineldesk -run "what windows are open?"Without it, the binary opens the daemon's socket directly — which is how it ships, as a supervised process inside the container beside the daemon (ADR-004).
/connect is the ordinary way. This is the same thing by hand:
mkdir -p ~/.sentineldesk && chmod 700 ~/.sentineldesk
cat > ~/.sentineldesk/anthropic.key # paste, Enter, Ctrl-D
chmod 600 ~/.sentineldesk/anthropic.keycat > rather than echo, so it does not land in shell history. A key file
readable by others is refused, not warned about.
doctor |
fifteen checks against a real desktop: the catalogue, the annotations the runtime needs, denial kinds, events, progress, cancellation, the audit trail. No model. |
run "goal" |
work towards a goal |
serve |
run as a daemon and answer the desktop's chat panel — see below |
tools [query] |
the catalogue, or the goal-ranked search of it |
providers |
which models can be reached and whether their keys are present |
costs |
what has been spent, and what the catalogue costs |
history [id] |
past runs, or one run in full — including the system prompt as sent |
export [id] |
one whole session as Markdown on stdout: every question, every reply, every tool call with its arguments and its result, and what each turn cost. With no id, the sessions to choose from. |
roles |
the ways of working available, and what each one sets |
memory |
what the agent has learned and kept |
skills |
the skills found, and where each came from — see skills.md |
skills install <repo> |
add one from the ecosystem (needs npx) |
skills-dump |
write the built-in skills out as files you can edit |
costs, history, export, providers, roles, memory and skills read
what is on this machine and need no desktop. providers also prints the model
catalogue this build knows — the same list /model draws, from the same array.
sentineldesk-agent -serve is this binary with no terminal: it connects to the
desktop and answers the chat panel a person opens beside the screen. Same loop,
same tools, same policy, same ceilings, same agent.db — a session typed into a
terminal and a conversation typed into the browser are one runtime doing one
kind of work, which is the only arrangement in which the two can ever show the
same history.
It dials; the desktop listens. The socket is AGENT_SOCK on the daemon's
side and -chat-sock here, and neither normally needs setting: both derive it
from the MCP socket's directory, so relocating one relocates both. A desktop
that is down is not an error — serve waits and reconnects, which is what a
supervised process is for.
# Beside a desktop, on this machine or through a mounted volume.
sentineldesk-agent -serve
# As its own container, beside the desktop's. `sentineldesk-run` is the only
# link between them — the same volume the desktop mounts, holding both sockets.
# No ports, no network: none of this leaves the host.
#
# The second volume is not optional. It holds config.json (the model, the keys)
# and agent.db (the history); without it the agent forgets its model every time
# the container is replaced.
#
# The order does not matter: started before the desktop, it waits.
docker run -d --name sentineldesk-agent \
-v sentineldesk-run:/run/sentineldesk \
-v sentineldesk-agent:/home/agent \
-e MCP_SOCK=/run/sentineldesk/mcp.sock \
cnsoluciones/sentineldesk-agent:latest
# Build that image from this repository.
make imageConfigure the model the ordinary way — run the binary with no arguments and use
/connect; in the container, docker exec -it sentineldesk-agent /usr/local/bin/sentineldesk-agent. The change is published to the desktop at
once, so the panel comes alive without anybody reloading a browser.
One conversation at a time. There is one desktop, one mouse and one keyboard, so a message that arrives while a run is in flight is steered into it rather than starting a second — which is also what a correction is.
Auto mode, and one door. serve never asks per tool call. The gate that
matters is the controls, and it already exists on the MCP socket: if somebody is
driving, the runtime asks for them through request_control and the room
answers. ask_human is unchanged — the agent asking what you think before it
does something, when it decides to ask.
/model |
every model this build knows, grouped into what can be reached now and what needs a key. Choosing one switches the model from the next turn; the turn in flight finishes on the model it started with. |
/memory <fact> |
have something remembered, permanently |
/stop, /exit, /help |
end the run, leave, or list all of it |
/connect |
the setup wizard: pick a provider, enter its API key (checked with the provider before it is saved), then pick a model. esc steps back one panel. |
/compact |
fold the older conversation into a summary when the context fills up. Asks first, and says what the next turn will pay in cache — compaction rewrites the prompt's prefix, so the cache is void. The transcript keeps a marker saying what was folded. |
/panel |
the session panel down the right-hand side: context used, tokens, cost, and a breakdown per model. Also ctrl+b, /context or /cost. |
ctrl+p |
the same list as a palette, for a command you cannot name |
shift+tab |
ask before every tool that changes something, or stop asking |
Scrolling: ↑/↓ a row, PgUp/PgDn half a screen, Home/End to either
end — End resumes following new output. ctrl+a/ctrl+e jump within the input
line, which is what those keys do in a shell. The wheel scrolls too, and
click-and-drag still selects text: the session asks the terminal to send arrow
keys for wheel notches (DECSET 1007, the same mechanism less and vim use)
rather than capturing the mouse, so no modifier is needed for anything. A
terminal that ignores the request loses only the wheel; every key above still
works.
Context shows the last measured prompt size against the model's window. What
has accumulated since — a tool result, a reply — appears on its own line as
+~N this turn, and the percentage grows a ~. The two are never added into
one figure: nothing here tokenises the way a provider does, so the measured
number and the estimate are kept apart rather than blended into one that looks
precise and is not.
The panel needs 100 columns and disappears below that, remembering whether you
had asked for it. Its % used comes from the context window recorded for that
model in provider/models.go; a model with no window recorded shows the
absolute token count and no percentage, because a denominator this build
does not have is not one to estimate. Every figure on it is the same
measurement the run history stores — one accumulator, fed by the loop as each
turn ends, read by both the panel and the footer.
The model list is static and says so on its own footer: it is what this build
knew when it was compiled. A model released since is one line in
internal/provider/models.go — nothing else has to change — and
--model takes any string at all, so nothing here limits what can be run.
history <id> and export <id> answer different questions. The first is a
summary for a terminal — results clipped, arguments clipped, colour throughout —
and answers what did it do. The second is the whole record, in a file
somebody can search, diff and paste into a review, and answers what happened,
exactly, and what did it cost:
./bin/sentineldesk-agent export # the sessions, newest first
./bin/sentineldesk-agent export 81 > session-81.md
./bin/sentineldesk-agent export 0 # the most recent, like --resume 0
One thing it cannot show, said here rather than found out later: there is no stored chain of thought. The runtime asks the provider for no separate thinking channel, so the model's prose for a turn is the whole of the reasoning that was ever recorded. The export prints that prose, the tool calls beside it, and a note saying as much — it does not manufacture a reasoning section out of the tool log.
/language the picker: what this build can say
/language es straight to it
English, Spanish and Portuguese, and the choice is remembered in
~/.sentineldesk/config.json. -language es sets it for one invocation without
writing anything.
It changes the interface and the agent's replies. The prompt already said "answer in the language they wrote to you in", which is the right default and is not what a preference means: somebody who chose Spanish should not have to write in Spanish to get Spanish back. English adds nothing to the prompt at all — it is the default, so the line is not paid for on every cached turn to restate what was already true.
What does not change is everything a machine reads: tool names, arguments, file paths, commands typed into a terminal, anything written to disk. A shell does not speak Spanish, and a command translated into one fails in a way that reads like the desktop is broken.
The catalogues are lang/*.json, compiled into the binary. There is no file
to install and none is read from your home directory — a machine holding nothing
but this binary has all three languages, and what ships is what runs. An override
on disk was tried and taken out: it makes what the screen says depend on a file
nobody can see in the repository, so two machines on the same version disagree
about what a button is called and neither of them is wrong.
Adding a language is adding a file. Nothing is registered anywhere — the loader
reads the directory — and lang's own test fails the build if a catalogue is
missing a key English has, invents one it does not, or drops a %s that the
English sentence takes.
JSON rather than Go, so the same file feeds a web front end. Keys are shared; who draws them is somebody else's problem.
One file. Everything you chose is in ~/.sentineldesk/config.json, and nothing
else in that directory holds a setting:
{
"language": "es",
"provider": "claude-code",
"model": "sonnet",
"mode": "ask",
"providers": {
"anthropic": { "type": "apikey", "key": "sk-ant-…" },
"claude-code": { "type": "cli" }
}
}It is written on the first run if it is not there, with the two defaults worth knowing you can change. Not the provider or the model: there is no default worth recording, and seeding one would put a choice nobody made into the file — which the next startup would read back AS a choice and report as a saved model that could not be opened. An error, on a fresh install, about a preference nobody set.
/language, /model, /connect and shift+tab all write to it, so a choice
made once is a choice made. A typed flag always wins for that invocation and
writes nothing — -model claude-haiku-4-5 is this run, not a new preference.
The file is 0600, and is refused rather than warned about if anybody else can read it. That check used to live on the key files and moved here with the keys, because it is the protection that was doing the work: a warning is read once, while somebody is getting something working, and then never again.
type |
what it is | what is stored |
|---|---|---|
apikey |
anthropic, openai, openrouter, ollama-cloud | the key |
cli |
claude-code — the installed binary, already logged in | nothing; its login is the credential |
local |
ollama — a server on this machine | nothing; there is nobody to authenticate to |
It is written even where there is nothing else to store, because "no key under
this provider" is an absence and an absence does not say which of the last two
it is. The same distinction is a Kind on the provider descriptor in code, so
nothing infers the type from a missing field — and it is deliberately not a
prefix on the provider's name: a fact encoded in a string is a fact something
eventually parses back out of one, which is how row.unwired == "not driveable"
came to decide a wizard's branch and broke the first time the interface was
translated.
Keys used to live one per provider — ~/.sentineldesk/anthropic.key. On the
first run they are moved into config.json and removed, one line printed per
file saying exactly what went where. Read, written, read back, and only then
deleted, in that order, so no step can lose a credential if the next one fails.
They are not read as a fallback. A fallback would have left the old file sitting there holding a live credential with nothing ever removing it, which is the opposite of one place to look.
<NAME>_API_KEY_FILE is the exception and still wins over everything: an
operator who mounted a secret at a path has said where that credential lives, and
it is not ours to move.
~/.sentineldesk/
config.json everything you chose, keys included · 0600
pricing.json the rate table, written on first run for you to correct
agent.db every run, turn and call
memory/ skills/ themes/
What one file costs, said here rather than discovered. Claude Code and
opencode both keep credentials out of their settings file. Putting them in buys
one place to look and costs two things: cat config.json to show somebody your
language now prints your credentials, and rotating one provider's key rewrites
the file holding all of them. What was kept is the mode check above, and the
fact that a key still leaves the settings package as a Secret that redacts
itself in every printing path — Settings.Redacted() exists so that printing the
whole file is safe by default rather than safe if you remember.
pricing.json is written the first time the agent runs and never touched again.
The built-in rates are estimates; the way to correct them is to edit that file,
and a file that only existed once somebody knew to create it was a file nobody
edited.
The system prompt carries every tool on this connection, by name, grouped into families — seeing, windows, the browser, terminals, long work, other machines — built from the live catalogue at the start of the run.
It exists because of --tools. A run is offered a core set plus a dozen tools
the goal ranked highest and reaches the rest through tool_search, which is what
makes a turn cost four tenths of a cent instead of eight. What it cost was the
model's sense of what the machine can do: asked to record the screen, a model
that has never seen the word start_recording does not search for it — it drives
the UI badly, or says the desktop cannot. tool_search only helps somebody who
already suspects the tool is there, and the selection is precisely what removes
the suspicion.
Names only, no schemas: the name is what makes a search targeted, and the schema
is what a search is for. It is read off the connection rather than written into
prompts/system.md, so a desktop that gains a tool gains a line, a build without
a capability never mentions it, and a list that quietly disagrees with the server
is not possible.
About two tokens a name, once, on a prefix the provider caches.
Everything is a -flag, order does not matter, and --flag is the same thing.
None of them is required — the whole point of the paragraph at the top is that
the ordinary invocation has none.
--provider anthropic | ollama | ollama-cloud | openai | openrouter
--model which model (default: the provider's)
--role efficient | witnessed
--tools goal-matched tools on top of the core set (default 12; 0 = all)
--max-turns stop a run after this many (default 25)
--sock the socket, when you would rather say than have it found
--container reach the desktop through docker exec instead of the socket
# Cheapest Anthropic model, for anything mechanical
--model claude-haiku-4-5-20251001
# A model on this machine: no key, no bill, and slow
--provider ollama --model qwen3:4b --tools 6
# Comparing vendors on one key
--provider openrouter --model openai/gpt-5.2providers.md has a section per provider — what each needs, what
each does about caching, and the thing about it that catches people out.
efficient is the default: nobody is watching and nobody asked for evidence, so
the invisible path is correct. Making a desktop flicker for a package install is
theatre.
witnessed is for when somebody asked to see it happen — a demonstration, a
recording, a screenshot. The invisible path is closed: where a visible
equivalent exists the runtime substitutes it, so run_command becomes
terminal_run and the work happens in a terminal on screen.
That substitution is enforced by the runtime, not requested of the model. Evidence cannot depend on a model remembering to be observable.
./bin/sentineldesk-agent -container sentineldesk \
--role witnessed run "open a terminal and show the free disk space"Watch it at localhost:8080 while it runs.
The catalogue was 98% of what a turn cost. A run now starts with a core set —
see, orient, act, ask, take the controls — plus whatever the goal ranks highest,
and reaches everything else through tool_search, which is always offered.
--tools 0 offers all 120. That is the escape hatch for the day a run fails and
the first question is whether the selection caused it.
Ctrl-C cancels the run rather than killing the process, so the cancellation reaches the server and the tool in flight actually stops. Exiting instead would leave a command running with nobody to answer.
A person taking the controls also ends a run — the runtime is told, rather than finding out when its next click is refused — and the exit code says so:
0 |
finished |
1 |
failed |
3 |
the provider is not configured (the message says what to do) |
4 |
interrupted: somebody took the controls |
./bin/sentineldesk-agent costsMODEL RUNS TURNS CALLS IN OUT EST. USD
anthropic/claude-sonnet-5 6 12 6 45,506 1,414 0.1247
What the catalogue costs
17 tools per turn · ~3,476 tokens · 76% of every turn's input
Tokens are recorded exactly as the provider reported them; money is derived from
~/.sentineldesk/pricing.json, which you correct against your own invoice.
Correcting it re-prices the whole history rather than only what comes next.
The built-in rates are estimates. A model with no rate prints ? rather
than zero — reporting a day's work as free is the worst available way to be
wrong about a bill.
./bin/sentineldesk-agent history 5Turn by turn: the system prompt as it was sent, how many tools were offered, every call with its arguments and result, and any tool the runtime substituted alongside what the model asked for. The full request and response JSON is in the database if a summary is not enough.
Everything lives in ~/.sentineldesk/agent.db, mode 0600, outside any checkout.
It holds conversations; it is nobody's business but yours.
The same question, four runs, as each piece landed:
| tools offered | est. USD | |
|---|---|---|
| as it first worked | 120 | 0.0776 |
| caching the prefix | 120 | 0.0098 |
| tool selection, cold cache | 17 | 0.0117 |
| both, warm cache | 17 | 0.0037 |
Twenty-one times cheaper. Two things did it, and they compose: caching stops paying full price to re-send the catalogue, selection makes the catalogue smaller.
The cache matches a byte-identical prefix, so the tool set is chosen once at the start of a run and left alone. A new goal selects different tools and therefore pays one cache write — which is why two runs of the same question are cheaper than two different ones.
no desktop socket found — every path it looked in is printed with what was
wrong at each. not there on all of them means the desktop is not running:
docker ps --filter name=sentineldesk. permission denied on one means it IS
running and that socket answers to root — run this with sudo, or give your
account access to it. Naming the path with -sock skips the search entirely.
anthropic is unavailable: no API key — you asked for a one-shot -run,
which has nobody to ask. Exit code 3, distinct from a failure, because not
configured and broken want different things from you. Open a session instead and
use /connect: a session starts without a model on purpose, since the screen
that fixes this is the one it would otherwise be refusing to show you.
A run says interrupted — somebody took the controls. Nothing went wrong;
the desktop is shared and that is what sharing means. The next turn was told the
tools may have partly run, because a cancelled command may have installed half a
package.
A tool was refused — read the kind in history. room means ask a person
and retry; policy means the server will never allow it and no one in the room
can widen it. The loop tells the model which, because collapsing them turns
"wait your turn" into "give up".
The loop's tests run against a scripted model and a fake server, deterministically and offline:
go test ./...A scripted provider is not a mock — it implements the same interface, so the loop under test is the loop that ships. It exists because the behaviours that matter (a refusal it must retry, a refusal it must not, an interruption landing between two calls of one batch) are things a real model produces only by luck, and because a good model hides loop defects by reaching the right answer despite them.
For a real desktop and no bill, run a model locally — see
providers.md.
Apache License 2.0. The full text is in LICENSE and every source
file carries SPDX-License-Identifier: Apache-2.0.
The name and the logo are not covered by it. SentinelDesk and its logo are trademarks of Federico Pereira. The licence gives you the code, not the name:
- You may say your work uses, runs on, or is built with SentinelDesk, and you should keep the attributions the licence asks for.
- You may not use the name or the logo as the name of your own product, in a way that suggests this project endorses or maintains yours, or on a modified build in a way that would leave somebody thinking they had the original.
- Rename the fork and the question disappears. The code is yours under Apache 2.0 either way.
NOTICE.md has three parts, and they are not the same kind of
thing: work included in this product, third-party work bundled with the binary,
and the projects this design was read against and took no code from.