This quickstart runs Pi Daemon as a Home Manager user service,
creates one durable session that deliberately selects
tools.mode: "none", waits for its mutation ticket, attaches
stock Pi RPC, and shows the ACP connection contract. That restrictive
example is a safe starting point, not the full product limit: the same
authenticated API can create configured trusted sessions with Pi
built-ins such as cwd-bound bash and explicit reviewed extension
resources. The service bearer is read from an owner-only file; it is
never placed in Nix source or a process argument.
Prerequisites: a flake-based Home Manager configuration, Bash,
curl, and jq. Log in with normal Pi once
before starting an isolated daemon instance so
~/.pi/agent/auth.json exists as an owner-private regular
file.
Only workload authority roots are operator-owned inputs. Pi Daemon deliberately does not create them because doing so would turn a configuration typo into a filesystem authority grant:
install -d -m 700 "$HOME/work" "$HOME/scratch-work"On first launch the daemon itself creates and validates its private
state, socket, and agent directories. A distinct empty
agentDir seeds auth.json once from Pi's normal
agent directory when that source exists. The API generates a random
owner-only bearer at stateDir/api-token when no file,
descriptor, or environment bearer is configured. Restart reuses both
files; existing auth and bearer files are never overwritten or
rotated.
Add Pi Daemon to the inputs of your Home Manager flake and import its module:
inputs.pi-daemon.url = "github:harryaskham/pi-daemon";
inputs.pi-daemon.inputs.nixpkgs.follows = "nixpkgs";
# Add this beside the other modules passed to homeManagerConfiguration:
modules = [
inputs.pi-daemon.homeManagerModules.default
./home.nix
];Then configure one or more named instances in home.nix.
Remove the sandbox entry if you need only one. The explicit
values below make every service identity, state/persisted-configuration
directory, socket, port, agent directory, log, workload root, and bearer
source collision-free:
{config, ...}: let
home = config.home.homeDirectory;
in {
services.pi-daemon.instances = {
operator = {
stateDir = "${home}/.local/state/pi-daemon/operator";
socketPath = "${home}/.local/state/pi-daemon/operator/run/pi-daemon.sock";
agentDir = "${home}/.pi/operator";
allowedRoots = ["${home}/work"];
stdoutLog = "${home}/.local/state/pi-daemon/operator/stdout.log";
stderrLog = "${home}/.local/state/pi-daemon/operator/stderr.log";
api = {
enable = true;
bind = "127.0.0.1";
port = 7463;
};
};
sandbox = {
stateDir = "${home}/.local/state/pi-daemon/sandbox";
socketPath = "${home}/.local/state/pi-daemon/sandbox/run/pi-daemon.sock";
agentDir = "${home}/.pi/sandbox";
allowedRoots = ["${home}/scratch-work"];
stdoutLog = "${home}/.local/state/pi-daemon/sandbox/stdout.log";
stderrLog = "${home}/.local/state/pi-daemon/sandbox/stderr.log";
api = {
enable = true;
bind = "127.0.0.1";
port = 7464;
};
};
};
}Each name produces an independent native service: for example,
pi-daemon-operator.service under Linux systemd,
com.pi-daemon.operator under Darwin launchd, or
pi-daemon-operator under nix-on-droid supervisord. The
module rejects duplicate state directories, sockets, enabled API ports,
or effective bearer paths. Keep allowedRoots disjoint from
state and agent locations; the daemon rejects authority roots that
overlap its state or credential storage. Home Manager still creates log
parents needed by native supervisors, while the daemon owns first-launch
state/socket/agent setup. Neither Nix nor Home Manager evaluates bearer
or Pi auth bytes. Set authSeedFile or
api.tokenFile only to override the safe first-launch
defaults.
Apply the configuration; Home Manager enables and starts the service. The platform-native restart commands are shown when an explicit restart is needed:
home-manager switch --flake ".#$USER@$(hostname -s)"
case "$(uname -s)" in
Darwin)
launchctl kickstart -k "gui/$UID/com.pi-daemon.operator"
;;
Linux)
if command -v systemctl >/dev/null; then
systemctl --user restart pi-daemon-operator.service
else
supervisorctl restart pi-daemon-operator
fi
;;
esac
pi-daemon probe \
--socket "$HOME/.local/state/pi-daemon/operator/run/pi-daemon.sock"A successful probe exits 0. Exit 75 means the transport is listening
but model, authentication, or recovery readiness is degraded; inspect
journalctl --user -u pi-daemon-operator on Linux or the
configured stdout/stderr logs on Darwin before admitting sessions.
Because this instance enables its API, Home Manager also starts the
independent pi-daemon-watchdog-operator service (Darwin
label com.pi-daemon.watchdog.operator). It probes for
semantic HTTP responses rather than trusting the PID/listener. Slow
responses are recorded as degraded without recovery; two hard failures
permit one exact-instance graceful restart and then latch degraded until
a successful response, preventing load-driven restart storms. Its
owner-private decision is STATE_DIR/watchdog-v1.json.
The helper below passes the authorization header to curl
through a private file descriptor. The token is not expanded into
curl's process arguments. It requires Bash,
curl, and jq.
set -euo pipefail
API="http://127.0.0.1:7463"
TOKEN_FILE="$HOME/.local/state/pi-daemon/operator/api-token"
SESSION_ID="quickstart"
SESSION_CWD="$HOME/work/pi-daemon-quickstart"
mkdir -p "$SESSION_CWD"
curl_api() {
curl --fail-with-body --silent --show-error \
--config <(printf 'header = "Authorization: Bearer %s"\n' "$(<"$TOKEN_FILE")") \
"$@"
}
wait_ticket() {
local ticket_id="$1" response state deadline=$((SECONDS + 60))
while (( SECONDS < deadline )); do
response="$(curl_api "$API/v1/ticket/$ticket_id")"
state="$(jq -r '.data.state' <<<"$response")"
case "$state" in
succeeded)
jq . <<<"$response"
return 0
;;
failed)
jq . <<<"$response" >&2
return 1
;;
indeterminate)
jq . <<<"$response" >&2
return 75
;;
queued|running)
sleep 0.2
;;
*)
printf 'unexpected ticket state: %s\n' "$state" >&2
return 1
;;
esac
done
printf 'ticket %s did not settle within 60 seconds\n' "$ticket_id" >&2
return 75
}Check capabilities, then create a durable session. Keep the idempotency key stable when retrying the same request; use a new key only for a new semantic mutation.
curl_api "$API/v1/capabilities" | jq .
create_response="$(
curl_api \
--request POST \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: quickstart-create-v1' \
--data-binary @- \
"$API/v1/session" <<JSON
{
"requestId": "quickstart-create-request",
"sessionId": "$SESSION_ID",
"spec": {
"cwd": "$SESSION_CWD",
"name": "operator-quickstart",
"target": { "mode": "new" },
"tools": { "mode": "none" },
"isolation": { "mode": "unisolated" }
}
}
JSON
)"
jq . <<<"$create_response"
create_ticket="$(jq -er '.data.ticketId' <<<"$create_response")"
wait_ticket "$create_ticket"
curl_api "$API/v1/session?limit=50" \
| jq '.data.sessions[] | {sessionId, name, generation, revision, residency, state}'For a mutually trusted workload, replace the example's
none mode with default,
no-builtin, or a named allowlist, and supply
explicit approved resources through the typed SessionSpec.
Read Session configuration and
isolation first: built-in bash is a child invocation in the
configured cwd, not a persistent shell/PTY, and trusted extensions share
the unisolated daemon process.
For repeatable one-shot commands built on the same API, see the session management CLI. The raw
curl flow above remains useful for protocol integration and
troubleshooting.
POST, PUT, and DELETE return
202 tickets. queued and running
are nonterminal. Never blindly repeat an indeterminate
operation; inspect retained Pi entries and use the explicit
reconciliation API described in the session
API.
pi-daemon-rpc exposes stock Pi RPC JSONL on stdin/stdout
while keeping daemon attach/reconnect status on stderr. The bridge reads
the bearer file itself. This read-only smoke requests the current Pi
state and exits after the response:
printf '%s\n' '{"id":"state-1","type":"get_state"}' \
| pi-daemon-rpc \
--url "$API" \
--session "$SESSION_ID" \
--role observer \
--token-file "$TOKEN_FILE"Use the default controller role for prompts or other mutations. Only one controller lease exists per session; observers remain read-only.
Configure an ACP client that supports WebSocket transport with:
| Setting | Value |
|---|---|
| URL | ws://127.0.0.1:7463/v1/session/quickstart/apc |
| WebSocket subprotocol | agent-client-protocol.v1 |
| HTTP authorization | Bearer value read from
~/.local/state/pi-daemon/operator/api-token |
After the WebSocket opens, send upstream ACP JSON-RPC
initialize, then bind the route-scoped session with
session/load using session ID quickstart and
the same absolute cwd used at creation. A client that accepts only a
local stdio ACP adapter needs a WebSocket transport integration; Pi
Daemon intentionally does not launch pi-acp or
pi --mode rpc. See the ACP
adapter for the supported messages, bounds, and permission
routing.
For a non-loopback API/RPC endpoint, terminate TLS and use
https:///wss://; never send the service bearer
over remote plaintext HTTP. Dash separately supports a reviewed
trusted-network exception with an exact non-loopback
web.publicOrigin, wildcard or explicit
web.bind, and web.allowInsecureHttp: true. It
emits a startup warning and still requires the browser credential plus
exact Host/Origin/CSRF evidence. Prefer TLS and see Dashboard transport
security.
Deletion requires the current strong ETag.
retainArtifacts=false removes the catalog record and
retained Pi artifacts after the ticket succeeds.
headers="$(mktemp)"
trap 'rm -f "$headers"' EXIT
curl_api \
--dump-header "$headers" \
--output /dev/null \
"$API/v1/session/$SESSION_ID"
etag="$(awk -F': ' 'tolower($1) == "etag" { sub(/\r$/, "", $2); print $2 }' "$headers")"
[[ -n "$etag" ]]
delete_response="$(curl_api \
--request DELETE \
--header "If-Match: $etag" \
--header 'Idempotency-Key: quickstart-delete-v1' \
"$API/v1/session/$SESSION_ID?retainArtifacts=false")"
jq . <<<"$delete_response"
delete_ticket="$(jq -er '.data.ticketId' <<<"$delete_response")"
wait_ticket "$delete_ticket"isolation.mode: "unisolated" is an honest declaration,
not a sandbox. Logical sessions have separate Pi runtime state,
settings, queues, and session files, but trusted extensions and SDK code
share one Node process, memory space, module globals, and ambient
environment. Put mutually untrusted workloads in separate daemon
processes, containers, or VMs. Separate Home Manager instances use
independent processes and memory, but instances under one Unix account
still share that account's filesystem and credential authority; use a
stronger OS, container, or VM boundary for mutually untrusted
workloads.