claustrum examples¶
These snippets run against a local daemon. They assume that claustrum is on
your PATH. A connection stays open, because stream notifications come after a
response. Thus the streaming examples use a small Node helper that reads for a
fixed time window. The simple request/response examples use socat.
Start a private daemon¶
D=$(mktemp -d /tmp/claustrum.XXXXXX)
TOK=$(uuidgen) # or: head -c24 /dev/urandom | base64
printf '%s' "$TOK" > "$D/token"
claustrum -serve -socket "$D/rpc.sock" -token-file "$D/token" # self-daemonizes
# the token file is now unlinked; keep $TOK in your client
One-shot request/response (socat)¶
req() { printf '%s\n' "$1" | socat -t2 - UNIX-CONNECT:"$D/rpc.sock"; }
req "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server.ping\",\"auth\":\"$TOK\"}"
# {"jsonrpc":"2.0","id":1,"result":{"pong":true}}
req "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"server.capabilities\",\"auth\":\"$TOK\"}"
req "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"files.stat\",\"params\":{\"path\":\"/etc/hostname\"},\"auth\":\"$TOK\"}"
req "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"git.info\",\"params\":{\"path\":\"$PWD\"},\"auth\":\"$TOK\"}"
A reusable client (Node)¶
// client.js — SOCK and TOK from env; pass a JSON array of requests as argv[2]
const net = require('net');
const c = net.connect(process.env.SOCK, () => {
for (const r of JSON.parse(process.argv[2])) {
if (r.auth === undefined) r.auth = process.env.TOK;
c.write(JSON.stringify(r) + '\n');
}
});
let buf = '';
c.on('data', d => {
buf += d;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (!line.trim()) continue;
const m = JSON.parse(line);
if (m.type === 'stream' && m.data) m.decoded = Buffer.from(m.data, 'base64').toString();
console.log(JSON.stringify(m));
}
});
setTimeout(() => { c.destroy(); process.exit(0); }, Number(process.env.WINDOW || 1500));
Spawn a process and read its output stream¶
run '[{"jsonrpc":"2.0","id":1,"method":"process.spawn",
"params":{"id":"p1","command":"sh","args":["-c","echo hello; echo oops 1>&2; exit 3"]}}]'
# {"jsonrpc":"2.0","id":1,"result":{"success":true}}
# {"type":"stream","processId":"p1","stream":"stdout","seq":1,"data":"aGVsbG8K","decoded":"hello\n"}
# {"type":"stream","processId":"p1","stream":"stderr","seq":2,"data":"b29wcwo=","decoded":"oops\n"}
# {"type":"stream","processId":"p1","stream":"exit","seq":3,"exitCode":3}
Feed stdin to a running process¶
DATA=$(printf 'ping\n' | base64)
run "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"process.spawn\",\"params\":{\"id\":\"cat1\",\"command\":\"cat\"}},
{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"process.stdin\",\"params\":{\"id\":\"cat1\",\"data\":\"$DATA\"}},
{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"process.kill\",\"params\":{\"id\":\"cat1\",\"signal\":\"SIGTERM\"}}]"
# the stdin reply is {"id":2,"result":{"success":true,"applied":5}} (5 = bytes of "ping\n");
# stdout frame decodes to "ping\n"; exit frame has exitCode -1, signal "SIGTERM", killedBy "client"
process.stdin also accepts an offset, the byte position at which the data
starts. The offset makes replay across reconnects idempotent. If you send only
bytes that the daemon applied before, the daemon does nothing and flags the reply
"duplicate":true. If the offset is past the applied count, the daemon returns
a -32003 stdin offset gap error. See process.stdin.
Reattach / catch up via the replay buffer¶
# spawn an emitter, then later reattach from seq 0 to replay everything buffered:
run '[{"jsonrpc":"2.0","id":1,"method":"process.spawn",
"params":{"id":"em","command":"sh","args":["-c","for i in 1 2 3; do echo $i; done"]}},
{"jsonrpc":"2.0","id":2,"method":"process.reattach","params":{"id":"em","fromSeq":0}}]'
# the reattach replays the buffered stdout+exit frames, then returns
# {"id":2,"result":{"found":true,"running":false,"firstSeq":1,"lastSeq":4,"stdinApplied":0}}
The firstSeq and lastSeq values are examples only. The daemon frames stdout
in chunks, not one frame per line. Thus the three echo outputs can arrive in
one frame and change lastSeq. When the connection that spawned a process
disconnects, the process continues to run. A new connection can reattach to
that process and continue to read the stream. This is the reconnect path.
Opt into pid + startTime — wantPid (claustrum extension)¶
An addition, not reference behavior
The reference daemon has no wantPid. This is an opt-in claustrum
extension, CT-1. See DIVERGENCES.md. If you omit it,
which is the default, every frame stays byte-identical to the reference.
wantPid does not change the original spawn and reattach behavior.
If you ask for them, it adds fields, and nothing else.
process.spawn and process.reattach accept "wantPid":true. When you set it,
the result carries the child's OS pid and a startTime token. The daemon
returns the same two values on spawn and on reattach for the same id. Use them
to detect PID reuse and orphans:
run '[{"jsonrpc":"2.0","id":1,"method":"process.spawn",
"params":{"id":"w1","command":"sh","args":["-c","echo hi"],"wantPid":true}},
{"jsonrpc":"2.0","id":2,"method":"process.reattach",
"params":{"id":"w1","fromSeq":0,"wantPid":true}}]'
# {"id":1,"result":{"success":true,"pid":12345,"startTime":1718040000.12}}
# … stdout + exit stream frames …
# {"id":2,"result":{"found":true,"running":false,"firstSeq":1,"lastSeq":2,"stdinApplied":0,"pid":12345,"startTime":1718040000.12}}
Without wantPid, those same two replies are exactly {"success":true} and
{"found":…,"running":…,"firstSeq":…,"lastSeq":…,"stdinApplied":…}. The
pid and startTime fields are absent (omitempty).
startTime is an opaque token. Store it. Then compare a daemon value
against a later daemon value for the same id to detect PID reuse. Do
not compare it for equality against a process start time that you read from
the OS (for example, psutil create_time). startTime is the daemon's wall
clock at the moment of the spawn, not the kernel's process-creation time.
Extract a plugin tarball¶
run "[{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"files.extract_tar\",
\"params\":{\"archivePath\":\"/path/to/plugin.tar.gz\",\"destDir\":\"/abs/out/dir\"}}]"
# {"id":1,"result":{"success":true,"fileCount":N}} (expects a gzip tarball; destDir must be absolute, non-root)
The daemon also refuses a destDir that is or contains your home directory.
This is the wipesHomeDir guard, not only the absolute/non-root check that the
comment shows. extract_tar deletes destDir before it extracts, and "~"
expands to $HOME. Thus the home guard stops a bare "~" from deleting your
home directory. See DIVERGENCES.md (D2).
Shut it down¶
# No token: server.shutdown is the one unauthenticated method.
claustrum -stop -socket "$D/rpc.sock"
rm -rf "$D"
Install/ensure the agent CLI (offline-verifiable)¶
claustrum -install -cli-dir "$D/cli" -cli-version 1.2.3 \
-cli-url https://example.invalid/cli.zst -cli-checksum <sha256-of-the-zst>
# during the download it prints __INSTALL_PROGRESS__{"phase":"download","bytes":N,"total":M} lines,
# at most one each second and only when the byte count changed, then:
# __INSTALL_RESULT__{"serverVersion":"…","os":"linux","arch":"amd64","libc":"glibc",
# "cliPath":"…/cli/1.2.3","cliWasPresent":false,"cliError":"…",
# "fetch":{"bytes":N,"ms":N,"longestPauseMs":N}}
# (the fetch object appears whenever a -cli-url download was attempted; the -cli-url body
# has an always-on 60s read-idle abort → cliError "download stalled: no data for 60s after …")
cliError is omitempty. A successful install omits it. It appears above only
because https://example.invalid is unreachable. The download thus fails.
libc is glibc on a glibc linux host and musl on a musl linux host. Off
linux, libc is empty ("").
Operational knobs (claustrum-only, all off the wire)¶
Token on a file descriptor¶
Start the daemon with the token on a file descriptor instead of a temp file
(fd 0 also works, to pipe the token on stdin). The daemon itself still
persists daemon.token beside the socket (PROTOCOL.md):
Prometheus metrics¶
Opt into Prometheus counters for connections, spawns, exits, reattaches, and stream and stdin bytes. No listener exists unless you set the flag. The endpoint serves counts only and has no auth. Thus bind it to loopback:
claustrum -serve -socket "$D/rpc.sock" -token-file "$D/token" -metrics-addr 127.0.0.1:9090
curl -s http://127.0.0.1:9090/metrics | grep claustrum_
# claustrum_connections_total 2
# claustrum_process_spawns_total 1
# …
Log level¶
Make the daemon's stderr diagnostics quieter. The default emits everything,
which matches the reference. The [Server], [process.Manager] and the other
prefixes stay grep-able at every level:
-keep-children¶
Use -keep-children (CT-2) to let child processes survive a daemon
restart or upgrade. A graceful shutdown leaves the spawned children running.
- Off by default. A shutdown kills the children: the whole tree on Linux and macOS, the direct child on Windows.
- No re-adoption. The new daemon does not re-adopt the survivors. Reconcile
them out-of-band with the CT-1
pidandstartTime. - Survivors lose stdio. Stdin gets EOF, and writes to stdout and stderr hit a closed pipe (SIGPIPE/EPIPE, see PROTOCOL.md). Thus only children that tolerate this condition outlive the daemon.
- Windows honors it too. A child is in no Job Object, so the children live on after the daemon exits.
- Two limits on Linux and macOS. The host cleaner ends an orphaned Claude Code
group under the install root by its own rule, a kept one too. The next daemon
start ends a kept process whose
ida later spawn took. See DIVERGENCES.md CT-2.
claustrum -serve -socket "$D/rpc.sock" -token-file "$D/token" -keep-children
# on graceful shutdown the daemon logs, instead of killing them:
# [Server] -keep-children: leaving 2 running child process(es) alive across shutdown
See DIVERGENCES.md for the CT-2 catalog entry.