Skip to content

Repository files navigation

sentineldesk-agent

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.


The two faces

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.


What it does differently

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.

The rest of it, briefly

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"

Getting going

1. Build it.

make build     # this machine
make all       # linux and darwin, amd64 and arm64, which is where it ships

2. Run it.

./bin/sentineldesk-agent

That 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 -doctor

Fifteen checks against a real desktop, no model involved. If this fails the problem is not the key.

Where the socket is

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.sock

Where it runs

Nothing 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).

The key, when you would rather write it yourself

/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.key

cat > rather than echo, so it does not land in shell history. A key file readable by others is refused, not warned about.


The commands

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.

serve — the chat panel in the browser

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 image

Configure 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.

Inside a session

/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

/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.


Where your settings live

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 — the three ways a model is reached

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.

Upgrading from the loose key files

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.


What the model is told about the desktop

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.


The flags

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

--provider and --model

# 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.2

providers.md has a section per provider — what each needs, what each does about caching, and the thing about it that catches people out.

--role

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.

--tools

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.


Stopping 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

Reading what it cost

./bin/sentineldesk-agent costs
MODEL                     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.

What one run actually did

./bin/sentineldesk-agent history 5

Turn 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.


What it costs, measured

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.


When something goes wrong

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".


Testing it without spending anything

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.


Licence and trademark

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.

About

AI agent for SentinelDesk desktops

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages