- Rust 62%
- Python 32%
- Nix 6%
Spooky — spec #
Spooky, the MCP server for action at a distance.
As a warning, this project is very vibecoded so take it as a proof of concept more than something secure enough to put sensitive traffic through.
Status: draft, decisions settled. M0–M3 built.
What it is #
Spooky lets a remote agent harness run tools on a local device. A harness with MCP support (the "remote" side) gains the ability to act on machines it cannot otherwise reach.
Two pieces:
spooky-relay— runs next to the harness. Speaks MCP to the harness and holds websocket connections from clients. Ships as a second binary/service in this repo.spooky— runs on the local device. Dials out to the relay, loads tool plugins from a directory, and asks the human before running anything it wasn't told to auto-allow.
The local device dials out, so the relay never needs to reach it. Tailnet is the assumed transport; a plain public relay works too if the client can reach it.
harness ──MCP(stdio|http)──> spooky-relay <──wss/ws──> spooky (device) ──> plugin scripts
│
└─ tokens.json, device registry
Non-goals for v1 #
Named so they don't creep in:
- TLS / transport encryption (scheme written down below, not built).
- A web UI, a TUI transcript, conversation storage, memory. The harness owns all of that.
- Sandboxing plugins. They run as the local user with the local user's access.
- Streaming partial tool output. A call returns once, whole.
- Multiple humans per device, roles, or any user model. A token is a device.
- Windows.
Repo layout #
spooky/ # client binary, local device side
src/
main.rs # CLI: pair, run, plugins, status, unpair
plugins/ # manifest loading, invocation, spill
link.rs # websocket client, envelope, reconnect, reload
state.rs # token.json, pidfile, spill dir
policy/ # approval prompts, autoallow, yolo (M3)
relay/ # relay binary, harness side
src/
main.rs # CLI: serve, pair, tokens, devices
mcp/ # MCP server surface (stdio; http deferred)
registry.rs # device registry, call routing, queue
link.rs # ws accept, hello/pair, frame loop
admin.rs # unix-socket admin channel for the CLI
state.rs # tokens.json, pairing.json
digest.rs # schema digest
proto/ # wire frames and argument validation, shared by both binaries
plugins/ # reference plugins: run, read-file, write-file, job-*
tests/ # end-to-end suite per milestone
Rust workspace, two binaries. tokio, no database — JSON files under a state dir. The whole thing should be readable in an afternoon.
Relay: MCP surface #
The relay is an MCP server with a fixed three-tool surface. It never changes at runtime.
spooky_list— no arguments: every connected device with its tool names and one-line descriptions.{device: "laptop"}: that device's tools with descriptions andrequireshints, plus its platform. No schemas; those are the next tool.spooky_schema—{device, tool}: a compact digest of one tool's arguments.{device, tool, full: true}: the raw JSON Schema, for anything the digest flattens away.spooky_call—{device, tool, args, reason}: runtoolondevicewithargs.reasonis the agent's stated purpose and is shown to the human in the approval prompt.
Why a fixed surface #
The obvious alternative — flattening every device's plugins into tools/list as
laptop__run, phone__read_file — makes the tool set a function of who is connected. MCP has
notifications/tools/list_changed for exactly that, but client support is uneven: some re-list,
some cache the set for the session, and CLI harnesses typically spawn the server per session and
list once. That yields a surface that is sometimes live, which is worse than one that is
honestly static.
Three stable meta-tools instead: the harness's cached list is always correct, a device can
connect mid-session and be usable immediately, and there is no per-device tool-name budget to
fight. The cost is that the agent reads a schema before calling instead of getting it pre-loaded
— one extra call, and the schemas stay authoritative because the relay serves them from the
client's live hello.
spooky_list stays small: names and one-line descriptions only. Schemas come per tool, on demand.
A device with ten plugins should cost a few hundred tokens to enumerate, not a few thousand.
Schema digest #
spooky_schema returns a compact form, not a JSON Schema dump:
{
"device": "laptop",
"tool": "run",
"desc": "Run a shell command on this device.",
"requires": ["shell", "net"],
"args": [
{"name": "cmd", "type": "string", "required": true, "desc": "the command line"},
{"name": "timeout_s", "type": "integer", "required": false, "default": 60}
],
"complex": false
}
Derived by walking properties/required one level deep. Argument descriptions come from the
plugin's own schema description fields; a plugin that ships none gets desc: null and the agent
should fall back to full: true. complex: true means the schema has nested objects, arrays or
unions that the digest flattened, so the digest is lossy and the full schema is the truth. Saying
so explicitly is cheaper than an agent guessing wrong on a nested argument.
Calls #
spooky_call validates before routing: unknown device → device <name> is not connected;
unknown tool → device <name> has no tool <tool>, listing what it does have; args that fail the
plugin's schema → the validation error, without waking the device. A routed call is then subject
to the device's approval policy.
Every failure — validation, denial, timeout, plugin error — comes back as an MCP tool result with
isError: true and a plain-text message. MCP protocol errors are reserved for malformed MCP
itself; harnesses treat the two differently and some abort a session on a protocol error.
One call in flight per device. Further calls queue FIFO, eight deep; past that they fail at once
with device <name> is busy. Serialising calls is what keeps the approval sink sane: prompts never
stack, and the human never answers a call that has already been superseded.
The relay's tool descriptions should state the order plainly — spooky_list, then spooky_schema
for the tool you picked, then spooky_call — so a model doesn't invent device or tool names or
guess at arguments.
Transport for the harness side: spooky-relay serve --stdio --listen 127.0.0.1:7777 (harness spawns
it as a subprocess) and spooky-relay serve --listen 127.0.0.1:7777 (streamable HTTP). Both bind the same
registry. Default to stdio; http mode is for harnesses that want a persistent relay shared across
sessions.
--listen is required in both modes. A device can only dial out, so the relay needs a listening
socket in stdio mode too, and the harness has to know that address to give the device at pair time.
MCP travels on stdio, the device link on the websocket; the two never share a channel.
Relay: device protocol #
Websocket, JSON frames, one frame per message. proto: 1. Server-assigned ids on server frames,
client-assigned on client frames; a reply echoes ref.
Client → relay:
| type | fields |
|---|---|
pair |
code, device — first frame on a throwaway socket: exchange the pairing code for a token. |
hello |
token, proto, device, platform, tools[] — first frame. tools is {name, desc, schema}. A device held by a live socket is taken over on a matching token and rejected with device_taken otherwise (see liveness, below). Re-sending hello on an accepted socket replaces the tool set (plugin reload). |
tool_result |
ref, ok, output, summary?, spilled? |
ping |
Relay → client:
| type | fields |
|---|---|
paired |
device, token — answer to pair; the socket closes after it. |
ready |
device, proto, policy_hint |
call |
ref, tool, args, reason, agent? — run this. reason is the agent's stated purpose, shown to the human. agent is the harness's MCP clientInfo name, when it sent one: a hint for the prompt, not a security boundary. |
deny |
ref, why — the relay refused before the device ran anything (unknown tool, device offline mid-flight, cancelled, timeout). |
err |
ref, code, msg, code in proto, auth, device_taken, malformed, too_large |
pong |
A dropped connection is not fatal: the client reconnects with backoff and re-sends hello.
Pairing rides the device link because the relay has no other listener for a client to reach:
spooky pair opens a socket, sends pair, takes paired, and closes. A code is single-use and a
fresh one is minted after every successful pair, so the printed code is always the current one.
Frames over 1 MiB are rejected with too_large; a call whose args exceed that fails without waking
the device.
Liveness: the relay pings every 20s and reaps a socket after two unanswered pings. A hello naming
a device already held by a live socket is accepted — and takes that socket over — when it carries
the same token, and rejected with device_taken when the token differs. Same token means the same
client reconnecting; preemption is what stops a half-open socket from locking a device out until
TCP keepalive finally notices. A different token means a second client claiming the same device
name, which only the human can resolve, by renaming or revoking.
Call timeout: the relay gives up 120s after sending call, returns an isError tool result, and
sends deny with why: "timeout" on the socket so a client blocked on a human prompt withdraws it
instead of answering a call nobody is waiting for. The client may keep running; its late
tool_result is discarded. MCP notifications/cancelled is forwarded as a deny with
why: "cancelled" so a client waiting on a human unblocks.
The approval budget sits inside the call budget, not beside it: approval_timeout_ms defaults to
90s, and a plugin's timeout_ms is clamped to what is left of the 120s when approval returns.
Otherwise a human answering at t=119 leaves the plugin one second to run.
Relay: admin channel #
spooky-relay devices and tokens revoke have to reach the process holding the live sockets, so
serve also listens on a unix socket at <state>/admin.sock (mode 0600, removed and rebound at
start). One JSON line in, one JSON line out: {cmd: "devices"}, {cmd: "tokens"},
{cmd: "revoke", device}. Local-only by construction, so it needs no token of its own. Revocation
drops the device's tokens, closes its socket and fails its in-flight calls.
Pairing and auth #
- Relay generates a pairing code at first boot, printed once to stderr. In stdio mode stdout is
MCP framing, and one stray line corrupts the transport.
spooky-relay pairreprints the code, so a harness-spawned relay never needs to print it at all. spooky pair <relay-url> <code> --device <name>→ relay mints a token, stores{device, token_hash, created}, client stores{relay_url, device, token}at~/.local/share/spooky/token.json, mode 0600.spooky-relay tokens list|revoke <device>. Revocation closes the live socket.- Tokens are opaque random 32 bytes, no expiry, no user model. Device names match
[a-z0-9-]{1,32}and are unique per relay.
Threat model v1: tailnet is the security boundary. A token is a bearer credential that lets its holder answer tool calls; anyone holding it can impersonate a device.
Plugins #
A plugin is a directory under the plugin root, which is $SPOOKY_PLUGIN_ROOT, default
~/.config/spooky/plugins:
<plugin-root>/<plugin-name>/
manifest.json
<entry script>
manifest.json:
{
"name": "run",
"description": "Run a shell command on this device.",
"entry": "run.py",
"interpreter": "python3",
"schema": { "type": "object", "properties": { "cmd": {"type": "string"} }, "required": ["cmd"] },
"timeout_ms": 60000,
"cwd": "inherit",
"summary_hint": "cmd",
"requires": ["net", "fs"]
}
namemust match[a-z0-9-]{1,32}and must equal its directory name. Not cosmetic: the spill path is built from device and tool names, so a tool calledrun/execwould write outside the spill dir.entryis resolved inside the plugin dir and must not escape it.schemais whatspooky_listserves and what the relay validates against. The client validates again before invoking — a plugin should not have to re-check its own arguments.summary_hintnames the argument to render in the approval prompt and to echo back as the call's one-line summary. A display hint only; nothing is validated against it.cwdisinherit(the client's cwd) or a literal path.requiresis["net", "fs", "shell"]at most, advisory only: it is shown in the approval prompt and inspooky_list, and enforced by nothing. It exists so the human sees thatrunwants a shell.- Invocation: spawn
interpreter entry, write one JSON object to stdin{"args": {...}, "context": {"device": "...", "cwd": "...", "call_ref": "...", "jail": "..." | null}}, read one JSON object from stdout{"ok": true, "output": "...", "summary": "..."}. The result is the last line of stdout that parses as a JSON object; every other stdout line is ignored, and diagnostics belong on stderr. A nonzero exit, an unparseable stdout, or no parseable line at all becomes a tool error carrying the stderr tail. outputis a string. Above 64 KiB the client writes it to~/.local/share/spooky/spill/<device>-<tool>.txtand returns head (64 lines) + tail (64 lines)- the path + byte count, with
spilled: truein the frame. One path per device+tool, so a second spill overwrites the first: growth is bounded and the file always holds the most recent big output. Read an old one before the next call if you need it.
- the path + byte count, with
- Plugins are scanned at client start and on
SIGHUP/spooky plugins reload. Drop a dir in, reload. No hot-add without a reload — the human should see what appeared. The client records its pid at<state>/spooky.pid;spooky plugins reloadsendsSIGHUPto it, and the client rescans, swaps its tool set and re-sendshello. - A manifest that fails to parse is reported in
spooky statusand skipped; it does not stop the client.
Reference plugins, which double as the acceptance test for the contract: run, read-file,
write-file, and the job-* set below. Hyphens, not underscores: the name rule above forbids _,
so the plugin directories are plugins/read-file/ and plugins/write-file/. The job-* plugins
share one core, plugins/joblib.py: a plain file at the plugin root, which the scanner ignores
because it only reads directories.
Background jobs #
run blocks: the call holds the device until the command ends and the relay gives up at 120s. The
job-* plugins hand a command to the background instead. One job per call, named job-<8 hex>:
job-start {cmd}— spawns the command in a new session and returns the id at once. Output goes to a log file, never to the caller.job-poll {id}— running, exited with N, killed by SIGTERM, or not running with no exit code.job-tail {id, lines=64}— the last lines of the log, read backwards from the end, capped at 200.job-wait {id, timeout_s=30}— blocks until the job ends or the timeout runs out, then returns the status and the last lines. Clamped below the relay's call timeout.job-kill {id, grace_s=5}— SIGTERM to the job's process group, SIGKILL if it ignores that.
State sits next to the spill files, <state>/jobs/<id>/: cmd, launcher, pid, log, exit,
signal. The pid is the shell that leads the job's process group. The command runs in a subshell
inside launcher so that an exit in it cannot skip the exit-code write. A job outlives the plugin
process that started it and keeps running across client restarts and dropped links; nothing stops it
but job-kill or the command ending. Jobs run in the jail, like every other plugin.
Two things this shape forces: a command that ends with exec leaves no exit code, and job-poll
says so rather than guessing; and the log grows without limit, so a job that prints forever fills
the disk.
Approval policy #
Default: prompt. On the device:
<agent> wants to run laptop:run
curl example.com > /tmp/example
reason: fetching the page to check the header
needs: shell, net
run? [Y/n/a=always] >
The header names the calling agent from MCP clientInfo when the harness sends one, falling back to
the device name — MCP carries no other identity, which is why <agent> is a hint and not a
security boundary. The body line is summary_hint's argument rendered verbatim; a plugin with no
summary_hint shows its args as compact JSON.
aappends the tool to the autoallow list in~/.config/spooky/policy.json(per device, per tool name), mode 0600. The file holdsautoallowas<device>:<tool>entries andapproval_timeout_ms;$SPOOKY_POLICYoverrides the path.spooky --yoloauto-allows everything.spooky --allow run,read_fileallows by name.- No answer within
approval_timeout_ms(default 90s, configurable, clamped below the relay's 120s call timeout) = deny. - Non-tty stdin (daemon) = autoallow list only, plus
--allowand--yolo; anything else is denied withwhy: "no human attached". Daemon deployments should run with an explicit autoallow list or--yolo. A desktop notification (notify-send/ libnotify) carrying the same prompt is the intended next step, opt-in because headless boxes have no notification daemon — not v1. - CLI mode defaults to a cwd jail: the client resolves
context.jailto its working directory and passes it to every plugin. The referenceread_file/write_filerefuse paths outside it unless--no-jailis passed, which sendsjail: null. The jail is a path check before the plugin runs, not a sandbox, and the client does not special-case plugin names to enforce it — a plugin that ignoresjailis simply not jailed. Daemon mode sendsjail: null.
CLI surface #
spooky
pair <url> <code> --device <name>run [--yolo] [--allow a,b] [--no-jail] [--cwd <dir>] [--verbose]— foreground, connects, prompts on tty.--cwdis where plugins run and what the jail is rooted at; without it the jail is the directory the client was started in.daemon [--verbose]— no tty, autoallow only; meant for systemd.plugins list|reload—listscans the plugin root;reloadsignals a running client.status— relay reachability, loaded plugins, policy.unpair
spooky-relay
serve --stdio --listen <addr> | --listen <addr>— the MCP endpoint.--listenis required in both modes; devices dial it.pair— reprint/mint a pairing code.tokens list|revoke <device>devices— who's connected.
Service units: a systemd unit for the relay and one for spooky daemon, templated in
packaging/. spooky daemon and spooky run share one connection layer; only the approval sink
differs.
Milestones #
M0 — skeleton. Relay --stdio --listen with spooky_list, spooky_schema and spooky_call over a
hardcoded registry; client connects with a hardcoded token, no pairing, no plugins. Proves the
frame loop, the digest derivation and MCP wiring.
M1 — pairing + registry. Pairing codes, tokens, revocation, device registry, live hello
tool sets, offline and unknown-tool error paths. Two clients, one relay.
M2 — plugins. Manifest loader, validation, invocation, spill, reload via SIGHUP and CLI.
run/read_file/write_file as plugins.
M3 — approval. TTY prompt with requires hints, autoallow persistence, yolo, timeouts, cwd
jail in CLI mode.
M4 — daemon. No-tty policy, systemd units, reconnect/backoff hardening, status.
M5 — MCP forwarding. A plugin type mcp in the manifest pointing at a local MCP server
(stdio command or http url); the client proxies tools/list and tools/call through it and
re-exports its tools under the device. Same approval policy applies.
Later and cheap: TLS, output streaming, desktop-notification approvals, per-plugin env vars.
Deferred: TLS #
Written down, not built. When the relay is not on a trusted network: relay serves wss with a
certificate whose SHA-256 fingerprint is captured at pair time and pinned in the client's
token.json; pairing happens over that pinned channel with a one-time code. A private channel
without a CA and without a new trust store. Rejected for v1: mutual TLS with ssh keys converted
to certs — more moving parts, same property.