AI agents on instances
ghostd is a small daemon for AI agents to work on a machine. It runs
commands as argument arrays and answers their exit codes and output, keeps
long-running work as jobs that outlive the connection, and reads, writes,
edits and searches files. It is reached over the SSH access you already
have, one kept connection, with nothing to quote. ghost is its client:
ghost x runs a command on an instance from a shell, and ghost mcp gives
an agent such as Claude Code its tools over MCP.
ghostd is open source, at
github.com/gigahost-apps/ghostd.
It runs as your user on the instance, with no service manager, and it never
reads or changes anything under ~/.ssh.
Targets
Section titled “Targets”Every command and tool names the machine it works on, its target, in one of three ways:
| Target | |
|---|---|
| An instance ID or name | An instance of your account: ghost reads its public IPv4 address (and, for an instance with a Shared IP, the public port that forwards to its SSH port 22) and logs in as root (--user changes that). This needs your account key, like ghost instance list. ghost remembers which instance a name stands for and the instance’s address for 24 hours, so ghost x NAME normally asks the API nothing; when the address stops answering, it looks the instance up again. A rename, or a new instance of the name, made on the website or another device is not seen until then: --refresh (refresh in MCP) or ghost ghostd forget NAME looks it up now. |
ssh://[user@]host[:port] |
Any machine you can reach with ssh; your ~/.ssh/config applies, so a host alias works. The host is letters, digits, ., _ and - (or an IPv6 address in brackets), and so is the user. |
local |
This machine, without SSH: ghostd from PATH, or the binary GHOSTD_BIN names. An instance named local is named by its ID. |
ghost uses your own ssh, keys and agent. It runs SSH with BatchMode=yes,
so a key that needs a passphrase has to be in your agent, and it accepts a new
host key the first time (StrictHostKeyChecking=accept-new). For an instance,
ghost remembers the host key in its own file (~/.local/share/ghost/known_hosts)
under the instance, not under its address and port, so a new instance at an
address or Shared IP port that an earlier instance had does not warn, and a
changed key of the same instance still stops the connection. When you delete
or reinstall an instance with ghost, or run ghost ghostd forget NAME, ghost
forgets its key. Targets of the form ssh:// use your own known_hosts. Plain
ssh to an instance may still warn when the address was another instance’s:
ssh-keygen -R "[address]:port" removes the old line.
ghost keeps what it learns about targets in
~/.cache/ghost/ghostd-targets-v2.json ($XDG_CACHE_HOME/ghost), readable
only by you, apart for each API and account key. The instance IDs in it
only ever come from your account’s API answers, so it cannot send a command
to someone else’s instance. ghost’s own ghost instance rename, delete,
reinstall, ghost backup restore and a creation from a backup drop what it
keeps about that instance. --refresh on ghost x and ghost ghostd
commands (and refresh on every MCP tool) looks the instance up through the
API first, and ghost ghostd forget TARGET drops what ghost keeps about a
target.
The machine’s identity
Section titled “The machine’s identity”ghostd 0.2.0 makes a host ID the first time it starts, 16 random bytes kept in its state directory. It stays through restarts, updates and reboots, and changes when the ghostd state is new: a reinstalled instance, another machine, another user’s ghostd. ghost learns the ID the first time it connects to a target and names it with every request after that; a ghostd with another ID answers that it is not the host the request was meant for, and runs nothing. So a name or an address that now reaches another machine never runs your command there.
When an instance answers another ID, ghost asks the API where the instance
is. If it is the same instance at the same address, it was reinstalled or
its ghostd state is new: ghost keeps the new ID, says so (a line on stderr,
or notice in an MCP result) and sends the request once more. If the API
has the instance somewhere else, ghost connects there and sends the request
once more. After a mismatch a request is sent at most once more. Until the API answers,
nothing runs, and ghost keeps expecting the ID it knew. An ssh:// target and
local have no API to ask: ghost refuses, naming both IDs, until you accept
the new one:
ghost ghostd forget ssh://me@example.comA copy of the ghostd state, in a disk image or a backup restored on another
machine, keeps the ID; remove host-id from ghostd’s state directory on the copy
(/var/lib/ghostd as root, else ~/.local/state/ghostd) to give it its
own. ghost ghostd status shows the host ID, whether it is
checked, and the machine’s host name, machine ID and boot ID.
ghostd 0.1.1 has no host ID. On a target ghost has learned no host ID for,
its commands still run, but nothing checks the machine’s identity: results
say host_check: false, and ghost says to update it with ghost ghostd install TARGET. A target that answered as ghostd 0.2.0 before and now
answers as 0.1.1 is treated as one with another ID: ghost asks the API about
an instance (at the same address, the command runs unchecked, and ghost says
so), and refuses an ssh:// target or local until you update ghostd there
or run ghost ghostd forget TARGET. Sessions, waits and the machine’s state
need ghostd 0.2.0.
An instance with a Dedicated IP
answers SSH on port 22. An instance with a
Shared IP needs a public port that forwards to its port 22. ghost instance ports INSTANCE lists and sets its ports.
Installing ghostd
Section titled “Installing ghostd”ghost ghostd install web-1ghost installs the current release of Gigahost’s ghostd channel. It checks
the release itself, the release manifest’s signature and the archive’s
SHA-256, and copies the binary to the instance over SSH, so the instance
needs nothing but sh, uname and sha256sum, and no access to the
download server. The instance checks the SHA-256 again before it puts
~/.local/bin/ghostd in place (/usr/local/bin/ghostd as root), and ghost
ends by checking that ghostd answers:
ghost ghostd status web-1The channel has x86-64 builds. On another machine, or to build what you read, install from source:
ghost ghostd install web-1 --from-sourceghost ghostd install web-1 --from-source --ref v0.2.0ghost then runs ghostd’s install script on the instance, which builds
ghostd there: it needs a C compiler (build-essential on Debian and
Ubuntu, build-base on Alpine) and installs Rust for the user when it is
missing, so the first build takes a few minutes. It builds the latest
release tag; --ref REF names a tag, branch or commit. On the instance
itself, without ghost, curl -fsSL https://api.gigahost.cloud/install/ghostd.sh | sh
installs the channel’s release the same way. That script needs curl or
wget, jq, tar, cmp, base64 and OpenSSL 3 (or python3) on the instance, and
says which are missing; ghost ghostd install needs none of them.
To update ghostd, install it again. ghost ghostd status says when the
channel has a newer version. The install stops a ghostd daemon that still
runs the old binary, and the next connection starts the new one; jobs keep
running through the stop. Installing the release that is already there
changes nothing: the daemon keeps running, and so do the connections to it.
Running commands: ghost x
Section titled “Running commands: ghost x”ghost x replaces ssh host 'command' for scripts and agents without MCP.
The arguments after -- are the program and its arguments, passed as they
are without a shell, so nothing needs quoting:
ghost x web-1 -- ls -la /etcghost x web-1 --cwd /srv/app -- git log -1 --format='%H %s'ghost x web-1 --env RUST_LOG=debug --timeout 600 -- cargo testghost x web-1 --stdin -- psql app < query.sqlghost x web-1 --script ./deploy.sh -- --fastThe program’s stdout and stderr are written to ghost’s own, byte for byte
and all of it, and ghost exits with the program’s exit code: 128 + n when a
signal n ended it, 124 at its timeout (120 s unless --timeout), 127 when it
could not be started, and 255 when ghost itself failed (the error is on
stderr). --login runs it through your login shell, for a PATH set in
~/.profile. With --json, ghost prints the result as JSON instead: the
exit code or signal, stdout and stderr (stdout_base64 when they are not
UTF-8), and the job’s ID.
Consecutive ghost x commands to one target share one SSH connection
(SSH’s own ControlMaster, kept 10 minutes, its socket in a private
directory under $XDG_RUNTIME_DIR/ghost).
Jobs and background processes
Section titled “Jobs and background processes”ghostd leaks no processes. A plain background process that a command leaves
behind (cmd &, nohup cmd &) is stopped when the command ends. A
server or anything long runs best as a job instead. What it starts in the
background belongs to the job, which lives until none of it is left or you
stop it:
ghost x web-1 --detach --name api -- ./server --port 8080ghost ghostd job list web-1ghost ghostd job logs web-1 JOB_ID --followghost ghostd job stop web-1 JOB_IDA job outlives the connection, ghostd’s restarts and its crashes; ghost ghostd job wait, get and remove complete the set. A process that
detaches into a session of its own (a daemon’s own forking, setsid) and a
service started with systemctl start are left running.
Sessions: interactive programs
Section titled “Sessions: interactive programs”A program that asks questions, a REPL or a full-screen program such as
top runs in a session: a terminal of its own on the machine, which
outlives the command. You type into it and read its screen as text:
ghost ghostd session start web-1 -- python3ghost ghostd session send web-1 SESSION_ID --text 'print(6 * 7)' --key Enterghost ghostd session read web-1 SESSION_ID --until '(?m)^42$'ghost ghostd session close web-1 SESSION_IDWithout a program, a session runs your login shell. send types its
--text and --key options in their order; a key is Enter, Tab,
Escape, Backspace, an arrow, PageUp, F1 to F12, Ctrl-C and the
like. What is typed is never sent twice: when the connection drops, ghost
says it may or may not have arrived, and reading the session shows which.
read prints the screen at once, or first waits up to --wait seconds (at
most 60) for new output (--until-changed), a regular expression
(--until) or the program’s end (--until-exited). ghost ghostd session list lists the sessions; a closed session’s last screen and output stay
readable.
Waiting for something
Section titled “Waiting for something”ghost ghostd wait waits on the machine itself, looking every 250 ms,
instead of a loop of commands. It exits 0 when the condition held and 1 when
the timeout came first (60 s unless --timeout, at most 300):
ghost ghostd wait web-1 --port 8080ghost ghostd wait web-1 --http http://127.0.0.1:8080/healthghost ghostd wait web-1 --file /srv/app/readyghost ghostd wait web-1 --file /var/log/app.log --contains 'started'ghost ghostd wait web-1 --job JOB_ID --matches 'listening on'ghost ghostd wait web-1 --pid 4242--job alone waits for the job to finish, --session for a session’s
program to exit; with --matches, for output written after the wait began.
--http takes http:// URLs only; for HTTPS, wait for the port.
The machine’s state
Section titled “The machine’s state”ghost ghostd state web-1answers in one call what is going on on the machine: the OS and kernel,
uptime and load, memory and swap, the disks and how full they are, the
listening ports (with the process, for your own), failing systemd units and
the last kernel and journal errors. --json prints it as JSON.
Copying files
Section titled “Copying files”ghost ghostd put web-1 ./release.tar.gz /srv/app/release.tar.gzghost ghostd get web-1 /var/log/app.log ./app.logBoth check the file’s SHA-256 at the end, and a file is replaced atomically: a reader never sees half of it.
Giving an agent the tools: ghost mcp
Section titled “Giving an agent the tools: ghost mcp”ghost mcp is an MCP server over stdio. Add it to Claude Code:
claude mcp add ghost -- ghost mcpor to Claude Desktop, in claude_desktop_config.json, with the full path to
ghost (Desktop starts it without your shell’s PATH):
{ "mcpServers": { "ghost": { "command": "/home/you/.local/bin/ghost", "args": ["mcp"] } }}Every tool takes a target as above (and refresh, to look an instance
up through the API first), and answers a structured result; its text is a
one-line summary followed by the same result as JSON, for clients that show
the model only the text. A result says whether the machine’s identity was
checked (host_check) and what ghost noticed on the way (notice). A
failure is a tool error that says what ghostd or SSH said.
| Tool | |
|---|---|
ghostd_exec |
Run a program (an argument array, or a script) to its end: exit code, the last 64 KiB of stdout and stderr, and the job’s ID. output_file gets all of stdout. |
ghostd_job_start |
Start a program as a job and answer its ID at once. |
ghostd_job_list, ghostd_job_status, ghostd_job_wait |
The jobs, one job’s state, waiting up to 60 s for one to finish. |
ghostd_job_logs |
A job’s stdout or stderr: from an offset or its tail, or all of it into a local file. |
ghostd_job_signal, ghostd_job_stop, ghostd_job_remove |
Signal a job, stop it and all it started, remove a finished one. |
ghostd_file_read, ghostd_file_write |
Read a file as text (at most 256 KiB a call) or whole into a local file; write one from text or from a local file of any size. |
ghostd_file_edit |
Replace exact text in a file. |
ghostd_file_list, ghostd_file_search, ghostd_file_stat |
List a directory, search files with a regular expression as ripgrep does, a path’s metadata. |
ghostd_file_remove, ghostd_file_rename, ghostd_file_mkdir |
Remove, rename and make paths. |
ghostd_sync |
Make a directory on the target hold what a local directory holds, or the other way round, copying only the files that differ. With delete it removes what the other side does not have; a directory it cannot list stops it, and a sync down never writes through a local symlink. |
ghostd_session_start, ghostd_session_send, ghostd_session_read |
Start an interactive program on a terminal of its own, type text and keys into it, and read its screen, waiting for new output, a regular expression or its end. |
ghostd_session_list, ghostd_session_resize, ghostd_session_signal, ghostd_session_close, ghostd_session_log |
The sessions; resize one, signal its program, close it, read its raw output. |
ghostd_wait |
Wait on the target for a port, an http:// URL, a file or its content, a job’s or a session’s output, a process, a job or a session to end, up to 300 s. |
ghostd_machine_state |
The machine’s state in one call: OS, load, memory, disks, listening ports, failing units, recent errors. |
ghostd_install, ghostd_status |
Install or update ghostd on a target (the channel’s release, or from_source), and see that it answers, its host ID and whether the channel has a newer version. |
Local paths the tools take (output_file, to_path, from_path,
script_file, stdin_file, local_dir) are your own files, read and
written as you; a relative one is relative to the directory ghost mcp
started in. Paths on the target are relative to the home directory of the
user ghost logs in as.
ghost mcp keeps up to four SSH connections per target: calls share one,
and large transfers and syncs take their own, so a long upload does not hold
up a quick command. When a connection drops during a command, ghost
reconnects and asks for the same run again, which ghostd answers from the
run already going instead of starting it twice; an edit, rename, removal or
upload that was cut off is not repeated, and the tool says it may or may
not have happened.