French amarre β a mooring line. The bit of rope between a boat and the dock.
Tailnet-only WebSocket harness for driving one or more CLI coding agents (pi, claude-code, β¦) from a remote device. Self-hosted analogue of Anthropic's Claude Code Remote Control, no third-party relay. Multi-session: one server hosts up to N agent processes, each addressable by id.
Two agent adapters ship in-tree:
agents/pi/βpi-coding-agentwith a remote permission-approval extension.agents/claude-code/β Anthropic'sclaudeCLI in stream-json mode (skip-permissions in v1).
Install
amarreon a host that has Tailscale joined. Done whencurl -s http://127.0.0.1:<port>/healthzreturns 200 and the same URL is reachable from another Tailnet-joined device.
- Clone:
git clone https://github.com/nSimonFR/amarre && cd amarre- Read first:
SPEC.md(the load-bearing component-by-component spec for agents β purpose, architecture, REST + WS surface, data model, push flow, env vars, invariants), thenREADME.md,package.json, anddocs/PROTOCOL.mdfor normative wire-format details. Toolchain is Bun (β₯ 1.x); do not substitutenpm/pnpm/yarnβbun.lockis the lockfile.- Install:
bun install. Verify:bun run start --help(or--version) returns without error.- Configure one or more agent instances. Each instance maps to a CLI binary already on
PATH(e.g.pi,claude-code). The minimum config is one instance with{ agent: "<bin>", port: <num> }. Seesrc/for the loader.- Run:
bun run start. Tail logs and confirmlistening on <port>appears for each instance.- From a remote Tailnet device, connect to
ws://<this-host>:<port>/and exchange frames perdocs/PROTOCOL.md.- NixOS hosts have a
services.amarremodule β seemodule.nix.The server binds loopback only by design. The trust boundary is the Tailscale ACL, not auth tokens. Do not bind to
0.0.0.0or expose any port publicly.
amarre/
βββ server/ # generic WS β stdio proxy (~80 LOC, Bun)
βββ agents/ # agent adapter plugins
β βββ pi/ # adapter for pi-coding-agent (+ permission-gate ext)
β βββ claude-code/ # adapter for Anthropic's claude CLI (stream-json)
βββ apps/ # native / web client apps
β βββ expo/ # Expo cross-platform client (active)
β βββ ios/ # parked SwiftUI placeholder
βββ tests/fixtures/ # echo-agent + echo-adapter for server tests
βββ docs/PROTOCOL.md # full wire-format specification
βββ flake.nix # packages.<system>.server + nixosModules.amarre + checks
βββ module.nix
The server is agent-agnostic: it loads an adapter at startup based on AMARRE_AGENT (default pi) and proxies JSONL bidirectionally between WebSocket clients and per-session agent processes. Sessions are spawned/listed/killed via a small REST control plane on the same port. Agents are plugins under agents/. Apps consuming the protocol are separate projects under apps/.
See docs/PROTOCOL.md (v2.0.0) for the full front/back specification β REST control plane, WebSocket data plane, framing, multi-client semantics, permission flow, error handling, conformance checklist, and a worked example.
Layer-summary: HTTP/WebSocket β JSONL β amarre envelope (transparent proxy + one amarre.session_event) β agent's own RPC schema (e.g. pi's docs/rpc.md).
With real pi:
bun install
bun test # server + multi-session + adapter tests
PI_BIN=$(which pi) bun run server/server.tsWith real claude-code:
AMARRE_AGENT=claude-code CLAUDE_BIN=$(which claude) \
bun run server/server.tsThen from another shell β spawn a session, connect to it:
ID=$(curl -s -X POST http://127.0.0.1:8341/sessions -d '{}' | jq -r .id)
websocat ws://127.0.0.1:8341/sessions/$ID
{"id":"1","type":"get_state"}Other useful endpoints:
curl -s http://127.0.0.1:8341/sessions # list
curl -s http://127.0.0.1:8341/sessions/$ID # status
curl -s -X POST http://127.0.0.1:8341/sessions/$ID/restart
curl -s -X DELETE http://127.0.0.1:8341/sessions/$IDConsumed as a flake input (github:nSimonFR/amarre). Module:
services.amarre = {
enable = true;
agent = "pi"; # default; matches agents/pi/
port = 8341;
user = "nsimon";
maxSessions = 8; # default; cap on concurrent agent processes
};For multiple side-by-side adapter instances (e.g. separate CLAUDE_HOME per profile), use services.amarre.instances instead and route via the instanceId body field on POST /sessions (see docs/PROTOCOL.md Β§4.1):
services.amarre.instances = {
personal = { agent = "claude-code"; env = { CLAUDE_HOME = "/home/me/.claude_personal"; }; };
work = { agent = "claude-code"; env = { CLAUDE_HOME = "/home/me/.claude_work"; }; };
pi = { agent = "pi"; };
};The systemd unit runs as the configured user so it inherits home-dir agent config (~/.pi/agent/{settings.json,models.json,extensions/}, ~/.claude/). Pair with tailscale serve to expose the loopback port over the tailnet at HTTPS. Optional Expo push notifications are gated by services.amarre.push.enable (PROTOCOL Β§13).
See agents/README.md. Each adapter is a small TypeScript module that knows how to spawn one specific CLI agent in stdio-streaming mode.
See apps/README.md. Speak the documented protocol β don't introduce alternative wire formats.
v0.3 β multi-session, multi-client, tailnet-only (PROTOCOL.md v2.0.0). See docs/PROTOCOL.md Β§9 for planned extensions (state.json rehydrate, hello handshake, capability advertisement, auto-restart, push, binary media, multi-adapter-per-server).