Skip to content

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));
run() { SOCK="$D/rpc.sock" TOK="$TOK" node client.js "$1"; }

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):

claustrum -serve -socket "$D/rpc.sock" -token-fd 3 3< <(printf '%s' "$TOK")

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:

CLAUSTRUM_LOG_LEVEL=warn claustrum -serve -socket "$D/rpc.sock" -token-file "$D/token"

-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 pid and startTime.
  • 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 id a 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.