# Ferryman for agents **You are the intended reader.** This page is written for an AI agent that has been told to put a project on Ferryman, with no human available to answer questions. Every step below is non-interactive, idempotent, and safe to retry. If you are a human: this works for you too, but [the README](../README.md) is friendlier. --- ## What you are setting up, in one paragraph Ferryman lets agents on different machines hand each other work without a server. An order is a file, a result is a file, a review is a file; Syncthing copies the folder between machines. You will install a binary, run one command in the project directory, and end up with a signed identity on a channel. Nothing you do touches the project's own code or git history. ## Step 1 — install One line, no toolchain and no compile. On macOS or Linux: ```sh curl -fsSL https://raw.githubusercontent.com/estejosh/ferryman/main/scripts/install.sh | sh ``` On Windows, in PowerShell — the line above cannot run there, because a default Windows machine has no `sh`: ```powershell irm https://raw.githubusercontent.com/estejosh/ferryman/main/scripts/install.ps1 | iex ``` Both verify the release checksum before installing, and install for the current user without asking for administrator rights. Both verify the download's SHA-256 before installing anything, and abort on a mismatch rather than warning. If you are tempted to work around a checksum failure: don't, and report it. Building from source also works, and needs a Rust toolchain plus, on Linux, D-Bus headers for the OS credential store (`libdbus-1-dev pkg-config` on Debian/Ubuntu, `dbus-devel pkgconf` on Fedora): ```sh cargo install --git https://github.com/estejosh/ferryman ferryman-cli ``` Or the container, which carries everything including Syncthing: ```sh podman run -d --name ferryman -v ~/ferryman-channels:/channels:U \ -p 22000:22000/tcp -p 22000:22000/udp ghcr.io/estejosh/ferryman:latest ``` Check it: ```sh ferry --version ``` ## Step 2 — enable the project From inside the project directory: ```sh ferry enable --email you@example.com --json ``` The email is required. Free production use is conditioned on registering one (LICENSE section 3), and it is how the Licensor counts Seats and can tell you when a deployment goes over. **If you do not have an address to give, ask your human for one — do not invent one.** `FERRYMAN_EMAIL` works instead of the flag. Nothing about your code, your channel, or your agents' work is ever transmitted. See [PRIVACY.md](../PRIVACY.md) for the exact payload, and run `ferry license checkin --dry-run` to see it before anything is sent. That is the whole setup. It prints one JSON object: ```json { "enabled": true, "project": "myproject", "agent": "wisp", "channel": "/path/to/myproject/.ferryman/ferryman", "syncthing_folder_id": "myproject-ferryman", "review": "confirm", "public_key": "710836e3...", "already_configured": false, "files": [{ "what": "channel config", "path": "...", "created": true }] } ``` Read `already_configured` to tell "I just set this up" from "it was already done". If you cannot remember whether you ran this, **run it again** — it will not overwrite an edited config and will not rotate your signing key. Useful flags, all optional: | Flag | Default | Why you would change it | |---|---|---| | `--project` | the directory name | The directory is named something unhelpful | | `--agent` | this machine's name | Several agents on one machine | | `--role` | `worker` | This one hands out and reviews work: use `orchestrator` | | `--command` | `claude` | You run a different agent CLI; known engines (`opencode`, `codex`, …) get their correct non-interactive args written automatically — see [ENGINE_SETUP.md](ENGINE_SETUP.md) | | `--email` | *(required)* | No default is possible; ask your human | | `--review` | `confirm` | See the risk section below | | `--dashboard` | off | Mark that the human wants the web dashboard (`ferry dashboard`) to approve work from a browser | | `--dashboard-operator` | email local part | The operator's username, for the interactive prompt only | Exit code is 0 on success, non-zero with a message on stderr otherwise. It never prompts, never opens an editor, and never waits on a terminal. Right after enabling, run: ```sh ferry doctor ``` It checks what the first task would otherwise discover the hard way: the config parses, the engine resolves on PATH, the signing key and roster entry exist, Syncthing answers. Every failing check states its remedy. A missing engine is the single most common failed first task — enable warns about it in its output, and doctor confirms the fix worked. The dashboard operator's password is **never handled by an agent**. `enable` does not accept one, and you must not type one into a flag, an environment variable, or a config file. When the human wants a dashboard, run `enable --dashboard`, then start `ferry dashboard` and hand the human the URL: they create their operator (name + password) in the browser, out of your sight. A human running `enable` themselves at a terminal is instead prompted for the password, typed without echo. ## Step 3 — join the other machines **`enable` already did this.** It registered the channel folder with the local Syncthing and shared it with every device that Syncthing already trusts. Check `syncthing` in the JSON: - `available: false` — Syncthing is not installed or not running. The channel still works locally. Report the `note` field to your human; installing Syncthing is not your call to make silently. - `shared_with: []` — nothing else is paired with this Syncthing yet. Normal on a first machine. - `device_id` — this machine's Syncthing id. Your human needs it to pair another machine. **What `enable` will not do is pair a new device.** Exchanging device IDs is a trust decision — it approves a machine that will then receive data — and it needs a human on both ends. Ferryman uses pairings that already exist and never creates one. ## Step 4 — run the loops ```sh ferry agent run # picks work up, runs your agent CLI, submits a signed result ferry agent review # judges results that are waiting ``` Both loop until stopped. Add `--once` to do a single pass and exit, which is what you want from cron, a systemd timer, or a test. **An agent will not review its own work.** If one machine runs both loops under the same agent name, `review` will tell you it is skipping and why. Give the reviewer its own name (`ferry enable --agent orchestrator --role orchestrator`). --- ## How much authority the reviewer has — your call, not Ferryman's Set with `--review`, changeable any time in `.ferryman/agent.toml`. | Mode | What happens | Use when | |---|---|---| | `auto` | The reviewing agent's verdict stands. Work flows with nobody watching. | The work is recoverable and you want throughput | | `confirm` | The agent judges and writes its reasoning; a human settles it. **Default.** | Mistakes cost something | | `off` | No agent judgement. Results wait for a person. | You do not want a model's opinion on record | The default is `confirm` because that is the cautious end, not because it is recommended. Ferryman has no view on how much you trust a model to approve work unsupervised — that depends on the work and the team, and only you know both. In `confirm` mode: ```sh ferry agent pending --json ``` returns each waiting recommendation with its reasoning and its signature check. Settle one with: ```sh ferry channel review --accept t-4f2a ferry channel review --notes "the totals do not add up" t-4f2a ``` A recommendation changes nothing on its own. Until a human writes the review, the task sits exactly where it was. ## What every prompt starts with Point `preamble_file` at a file of standing context — the repo map, the conventions, whatever every task on this project needs to know — and its contents go at the front of every prompt this machine sends, before anything task-specific. ```toml # .ferryman/agent.toml preamble_file = "preamble.md" ``` The position is not incidental. Providers charge far less for a prefix they have already seen, and the discount is measured over the longest run of *leading* bytes identical to last time. On some the cached rate is under a fiftieth of the uncached one, so context that would be useful anywhere in the prompt is worth real money specifically at the start of it. Both prompt kinds go through it, so a work prompt and a review prompt — which otherwise share nothing — share this prefix. Two consequences worth knowing: - **A named file that cannot be read stops the agent starting.** Not an empty string. A machine silently working without the context it was configured to have produces slightly worse work at full price, and nothing about it looks wrong. - **Edit it rarely.** Every change invalidates the cache for the next run. That is fine; changing it every hour is not. It is also just better prompting, and it costs nothing when unset. ## When this machine works ```toml claim_window = "22:00-06:00" # this machine's local time claim_window = "16:30-00:30 UTC" # say UTC if you mean UTC ``` Unset means any hour, which is the default. People set it for cheaper overnight electricity, a metered connection with a free window, a desktop in the same room as someone asleep, or an inference provider that discounts off-peak hours — Ferryman does not need to know which. Times are **local unless you append `UTC`**. Say which you mean: a window that is off by your offset still looks like a working window, and you find out from the fact that nothing happened last night. A window crossing midnight is the usual case and works. Like every other check, it runs only before a claim — work already running when the window closes is never interrupted, and a task nobody claims simply stays open for a machine whose hours it is. ## Where the deployment stands under the licence ```sh ferry license status --json ``` Free tier is 2 Seats, 2 Computers and 2 phones/tablets, checked separately rather than pooled — three computers is over even with no phone. **Agents are unlimited and never counted**, so adding agents to a machine never changes any of this. Going over prints a notice and changes nothing else. Ferryman does not stop, degrade, or withhold anything. Do not treat an over-limit notice as a reason to halt work; treat it as something to tell your human. ## Handing out work ```sh ferry channel order --agent orchestrator --id t-4f2a \ --task "write the Q3 report" --requires-review ferry channel order --agent orchestrator --id t-4f2b --to fang --task "..." ``` Pass `--agent` with the name you enabled under. Omit it and the order is signed by the machine name, which the roster will not recognise, and every reader will report `UnknownSigner`. Addressed orders (`--to`) have nothing to race over. Open ones are settled by oldest claim, computed identically on every machine, so nobody has to be the authority. ### Two orders that must agree: interface contracts and file locks When one order builds something another order uses (an API and the screen that calls it), agree the shape first, in a signed file, instead of hoping both sides guessed the same. The master locks it; neither side starts until then; the provider's result is checked against it. A worked example - the backend provides `user-api@1`, the frontend consumes it: ```sh # 1. Anyone on the channel proposes the contract. user-api.json holds # {"name": "user-api", "version": "1", "description": "...", "response": } ferry contract propose user-api.json --agent orchestrator # a shape: {"type": "object", "required": ["id"], "properties": {"id": {"type": "integer"}, # "email": {"type": "string"}, "role": {"enum": ["admin", "member"]}}} # 2. Issue both orders now. They wait, and say why ("waiting for contract user-api@1 # to be locked") in `ferry channel tasks` and on the Contracts page. ferry channel order --agent orchestrator --id t-api --to fang \ --task "implement GET /users/{id}" \ --interface user-api@1:provides --touches 'src/api/**' 'tests/api/**' ferry channel order --agent orchestrator --id t-ui --to wisp \ --task "show the user on the profile page" \ --interface user-api@1:consumes --touches 'web/src/profile/**' # 3. The master locks it: the Contracts page in the dashboard, the Lock button in # Telegram (one question, however many times it is proposed), or ferry contract lock user-api@1 # shows the digest and asks you to confirm ferry contract lock user-api@1 --digest 89abcdef # or name the digest `ferry contract show` printed ``` A lock is bound to the contract the master looked at: if the proposal was replaced since, the lock is refused ("the contract changed since you looked") and nothing is locked. Once locked, the consumer's engine prompt carries the locked shapes, and the provider's result is held to the response shape - `result.user.id: expected integer, got string` is a contract violation, which blocks acceptance like any other. A locked contract is immutable; a change is `user-api@2`. A contract whose signature, lock or contents do not verify (forged, unsigned, edited after locking) is ignored, so the orders keep waiting. `--touches` lists the repo-relative globs an order expects to edit. Issuing an order whose globs overlap an open or claimed one prints a warning, the dashboard marks both cards, and a worker will not claim an order while an overlapping one is being worked on (it records why and takes other work) unless the order says `--allow-overlap`. The comparison is conservative and on whole path segments: `src/api/**` overlaps `src/api/x.rs` but not `src/apiv2/x.rs`. A worker keeps off a sent-back order's files only while someone is on the rework (a live claim, or a verdict newer than the stale window). A glob or path over 1024 bytes counts as overlapping everything and matching nothing. After the commit, the result records `touched_files`, and a reviewer note says so when any fall outside the declared globs - a note, never a refutation. A single order can also carry its own result shape with `--result-schema file.json`. ## Checking your work ```sh ferry channel status # where the channel is and whether it is healthy ferry channel agents # who is on it ferry channel tasks # every task, its state, and every signature check ``` `ferry channel tasks` is the one to check after setup. Every line should read `Valid`. `UnknownSigner` means something was signed by a name the roster does not carry — usually a missing `--agent`. ## What this does not do - **It does not sandbox the agent CLI it runs.** That process gets the full privileges of the account running the loop, in its working directory. Give each worker its own least-privilege account and its own disposable directory, and prefer your agent's own sandbox flags. - **It does not touch the project's code or git history.** Everything lives under `.ferryman/`. - **It does not carry your private key.** Keys live in `.ferryman/keys`, which is excluded from the synced folder before any key exists. - **It does not decide anything for you** about review authority. See above. ## If something fails | Symptom | Cause | Fix | |---|---|---| | `no Ferryman channel found` | Not enabled here, or you are above the project directory | `ferry enable` in the project root | | `run 'ferry enable' in this project first` | Channel exists, agent config does not | `ferry enable` again; it is idempotent | | `order UnknownSigner` | Signed by a name not in the roster | Pass `--agent ` | | reviewer does nothing | It is the same agent that did the work | Run the reviewer under a different name | | `'claude' ... is it installed and on PATH?` | The configured agent CLI is missing | Install it, or change `command` in `.ferryman/agent.toml`; `ferry doctor` confirms | | engine killed as frozen, or "Execution error" | It is waiting for a permission prompt nobody can answer | Use the engine's non-interactive/auto-approve contract — [ENGINE_SETUP.md](ENGINE_SETUP.md) | | authentication errors on every task | The API key was scrubbed from the worker's environment | List it in `.ferryman/credentials.json` — [ENGINE_SETUP.md](ENGINE_SETUP.md) | | work sits in `AwaitingReview` forever | `review = "confirm"` and nobody settled it | `ferry agent pending`, then `ferry channel review` |