---
name: buzz-agent-provision
description: Provision a headless Buzz agent on a remote Linux host with the buzz-acp harness. Use when asked to add, clone, retune, or troubleshoot a remote Buzz agent: "add an opus agent on the build box", "spin up a sonnet worker", "make a high-effort variant", "the agent starts but never replies". Covers identity, relay membership by invite claim, profile and channel membership, the systemd template, the workspace, and the four knobs that define an agent: host, harness, model, effort.
---

# Provisioning a headless Buzz agent

A remote Buzz agent is one environment file, one workspace directory, and one
systemd instance. Nothing is compiled, nothing is containerised, and the whole
thing is reversible by deleting a file.

Written from a working deployment of eight agents across two hosts, running
three different vendors' agent binaries side by side. Revised 2026-08-10 after
migrating all eight to a second relay, which exercised every step below.

## Ask these four things first

Do not guess any of them. Everything else has a sane default.

| # | Question | Example | Where it lands |
|---|---|---|---|
| 1 | **Remote server** | the always-on Linux host | which host you SSH to |
| 2 | **Provider / harness** | Claude, Hermes, Pi | `BUZZ_ACP_AGENT_COMMAND` |
| 3 | **Model** | `opus[1m]`, `sonnet` | `BUZZ_ACP_MODEL` |
| 4 | **LOE (reasoning effort)** | `low` / `medium` / `high` | `<workspace>/.claude/settings.json` |

Name the agent after host, model and effort unless told otherwise, for example
`box-opus` and `box-opus-high`. The host prefix earns its keep the moment you
have agents on a second machine.

Then ask the owner for **an invite URL for the community**, because step 5 is
what actually blocks you and it needs one.

**Effort is the one that gets set wrong.** It is not an environment variable.
See step 4.

## Placeholders used below

| Token | Meaning |
|---|---|
| `<HOST>` | the remote Linux host |
| `<USER>` | the local account the agents run as |
| `<HOME>` | that account's home directory |
| `<NAME>` | the agent instance name |
| `<RELAY>` | the Buzz relay websocket URL |
| `<RELAY_HTTP>` | the same relay over https, for the invite API |
| `<OWNER>` | the owner's 64-char hex pubkey |
| `<BIN>` | directory holding the harness binaries |

## Step 0. Read the existing deployment before writing anything

If the host already runs agents, it is the authority on every path and default
below. Do not copy values out of this document when you can read the real ones.

```bash
ssh <USER>@<HOST> "systemctl list-units 'buzz-agent@*' --all --no-pager"
ssh <USER>@<HOST> "systemctl cat buzz-agent@ --no-pager"
ssh <USER>@<HOST> "ls <HOME>/.config/buzz-agent/"
```

Then read one existing env file with the secrets masked, and copy its shape:

```bash
ssh <USER>@<HOST> "sed -E 's/(KEY|TOKEN|SECRET|TAG)=.*/\1=<redacted>/I' \
  <HOME>/.config/buzz-agent/<EXISTING>.env"
```

That gives you the relay URL, the owner pubkey, the harness paths and the house
defaults without guessing. Everything from here assumes a first-time setup.

## Anatomy

```
/etc/systemd/system/buzz-agent@.service   one template, every instance
<HOME>/.config/buzz-agent/<NAME>.env      identity and knobs, one per agent
<HOME>/.buzz-<NAME>/                      workspace (the unit's cwd)
  .claude/settings.json                   <- effort lives HERE
  CLAUDE.md                               agent-specific standing orders
```

The template, using systemd instance syntax so every agent shares it:

```ini
[Unit]
Description=Buzz headless agent %i (buzz-acp harness)
After=network-online.target
Wants=network-online.target

[Service]
User=<USER>
Group=<USER>
EnvironmentFile=<HOME>/.config/buzz-agent/%i.env
Environment=PATH=<HOME>/.local/bin:<BIN>:/usr/local/bin:/usr/bin:/bin
WorkingDirectory=<HOME>/.buzz-%i
ExecStart=<HOME>/.local/bin/buzz-acp
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Create it once per host. After that, adding an agent touches no systemd
configuration at all.

## Harnesses

`BUZZ_ACP_AGENT_COMMAND` picks the brain. The harness does not care which agent
binary it drives, which is the whole point: different vendors can run side by
side in the same channels.

| Provider | `BUZZ_ACP_AGENT_COMMAND` | Notes |
|---|---|---|
| Claude | `<BIN>/claude-agent-acp` | leave `BUZZ_ACP_AGENT_ARGS` empty |
| Hermes | `<BIN>/acp-buzz` | the delivery shim, **not** the hermes binary. Set `ACP_BUZZ_REAL_CMD=<HOME>/.local/bin/hermes` and `ACP_BUZZ_REAL_ARGS=acp,--accept-hooks` |
| Pi | `<BIN>/pi-acp-buzz` | same shim, pi entry point by default. No `BUZZ_ACP_MODEL` |

**Point `AGENT_COMMAND` at the shim, not at hermes.** If `AGENT_COMMAND` is the
hermes binary itself then `ACP_BUZZ_REAL_CMD` is never read and the fallback
delivery path does not exist. This is the single most common copy/paste error in
a Hermes setup.

`BUZZ_ACP_AGENT_ARGS` is **comma-delimited**, not space-delimited.

Confirm the binary exists before writing the env file. A missing command gives
a restart loop and an unhelpful error.

## Step 1. Generate an identity, and derive its pubkey

Every agent needs its own keypair. Never share one between agents.

```bash
ssh <USER>@<HOST> "umask 077 && openssl rand -hex 32 > <HOME>/buzz-setup/agent-<NAME>.key"
```

The `buzz` CLI has no keygen and cannot print a pubkey. Derive it yourself, from
a directory that owns a `nostr-tools` install, because you need the pubkey
*before* the agent can be a member of anything:

```js
// pk.mjs   ->   SK=<hex> node pk.mjs
import { getPublicKey } from "nostr-tools/pure";
const sk = process.env.SK.trim().replace(/^'|'$/g, "");
console.log(getPublicKey(Uint8Array.from(Buffer.from(sk, "hex"))));
```

Scripts that import from `nostr-tools` must live inside the directory that owns
`node_modules`. A script in `/tmp` fails with `ERR_MODULE_NOT_FOUND`.

## Step 2. Write the env file

```bash
ssh <USER>@<HOST> "install -o <USER> -g <USER> -m 600 /dev/null \
  <HOME>/.config/buzz-agent/<NAME>.env"
```

```ini
BUZZ_RELAY_URL=<RELAY>
BUZZ_PRIVATE_KEY=<hex from step 1>
BUZZ_ACP_AGENT_OWNER=<OWNER>
BUZZ_ACP_AGENT_COMMAND=<BIN>/claude-agent-acp
BUZZ_ACP_AGENT_ARGS=
BUZZ_ACP_MODEL=opus[1m]
BUZZ_ACP_SESSION_TITLE=<NAME>
BUZZ_ACP_RESPOND_TO=owner-only
BUZZ_ACP_DEDUP=queue
BUZZ_ACP_MULTIPLE_EVENT_HANDLING=steer
BUZZ_ACP_AGENTS=2
BUZZ_ACP_RELAY_OBSERVER=false
BUZZ_ACP_SYSTEM_PROMPT=<one line, see step 4>
RUST_LOG=buzz_acp=info
```

Mode `600` and owned by `<USER>`, because it holds a private key and systemd
reads it as that user.

**Validate the model id against `buzz-acp models`.** An id that is not in the
picker is accepted silently and the agent falls back to a default, so an agent
named for one model runs as another. A stale `claude-opus-4-8[1m]` survived in
production configs for six days this way.

`BUZZ_ACP_AGENTS` is the worker count, not a model setting. `2` lets the agent
pick up a second channel while the first is mid-turn.

`MULTIPLE_EVENT_HANDLING=steer` cancels an in-flight turn and re-prompts with
the new message woven in. `queue` makes it wait for the current turn.

> **`steer` is not available for Pi or Hermes.** Both adapters report
> `steering_supported=false` at startup, and steering a pi session cancels the
> turn and crashes the adapter on the cancel drain. Set
> `BUZZ_ACP_MULTIPLE_EVENT_HANDLING=queue` for every non-Claude harness. Both
> modes require `DEDUP=queue`.

## Step 3. Create the workspace

```bash
ssh <USER>@<HOST> "sudo -u <USER> mkdir -p \
  <HOME>/.buzz-<NAME>/.claude \
  <HOME>/.buzz-<NAME>/{GUIDES,RESEARCH,PLANS,WORK_LOGS,OUTBOX,REPOS,.scratch}"
```

The workspace must exist before the unit starts. systemd fails with
`status=200/CHDIR` when `WorkingDirectory` is missing, which reads like a
harness crash and is not one.

Write a `CLAUDE.md` in the workspace for anything channel or project specific.

## Step 4. Set the effort, and the system prompt

> **Reasoning effort is not an environment variable.** There is no
> `BUZZ_ACP_EFFORT`. Setting effort in the env file does nothing, silently, and
> the agent runs at default effort while its name promises otherwise.

```bash
ssh <USER>@<HOST> "sudo -u <USER> tee <HOME>/.buzz-<NAME>/.claude/settings.json >/dev/null <<'EOF'
{
  \"effortLevel\": \"high\"
}
EOF"
```

Persisted values are `low`, `medium`, `high`, `xhigh`. `max` exists in the SDK
but is session-scoped and invalid in settings. Project settings beat user
settings, which is why the per-workspace file works without splitting
`CLAUDE_CONFIG_DIR` (splitting it would also fork `.credentials.json` and leave
several directories refreshing the same OAuth token).

Older deployments passed effort as `--effort high` through
`BUZZ_ACP_AGENT_ARGS`, and those invocations linger in journal history. The
`claude-agent-acp` entry point parses only `--cli` and `--version` and silently
drops everything else, so that form never worked. **Check the most recent
`buzz-acp starting` line before believing anything you grep out of the logs.**

The system prompt goes in `BUZZ_ACP_SYSTEM_PROMPT` as one line. Three clauses
have already been paid for in lost turns and belong in **every** prompt,
whatever the harness:

```
PUBLISHING IS NOT OPTIONAL: your reasoning and tool output are invisible in
Buzz. An answer exists only if you run
  printf %s 'your reply' | buzz messages send --channel CHANNEL_UUID
    --reply-to EVENT_ID --content -
and see "accepted":true. Composing the reply as plain assistant text ends the
turn silently and the human sees nothing.

NOTIFICATIONS: when you report a result, deliverable, or blocker to whoever
asked, START the message with an @mention of their exact display name and pass
--mention with their hex pubkey. Without it they get no notification and assume
you are silent.

NEVER run a command that blocks waiting on stdin (interactive logins, prompts,
tail -f on a fifo). The harness kills the turn at the idle timeout and requeues
it in a loop.
```

> **Claude needs the publishing clause too.** Earlier revisions of this document
> claimed `claude-agent-acp` handles delivery for you. It does not. On
> 2026-08-10 a Claude agent answered three consecutive mentions correctly,
> including a full GitLab issue list, and emitted every one as plain assistant
> text with zero `buzz messages send` calls in the whole session. The channel saw
> nothing. Once a session drifts out of the contract it stays drifted, so the
> clause is cheap insurance and the restart that drops the session is the cure.

## Step 5. Make the identity a relay member, the step that actually blocks you

A fresh key is a stranger to the relay. Until it is a member every call fails:

```
{"error":"auth_error","message":"relay error 403: relay_membership_required"}
```

The service restarts forever and looks healthy in `systemctl` while doing
nothing at all. **Check the journal, not the unit state.**

**The normal way in is an invite claim, performed by the agent's own key.** The
owner mints an invite in Buzz Desktop and gives you the URL. Nobody can add a
bare pubkey to a community from the CLI, and invite minting is owner or admin
only, so an existing agent cannot make one for a new sibling.

```js
// claim-any.mjs   ->   RELAY_HTTP=<RELAY_HTTP> SK=<hex> node claim-any.mjs <invite-url>
import { finalizeEvent } from "nostr-tools/pure";
import { createHash } from "node:crypto";

const relayHttp = process.env.RELAY_HTTP;
let code = process.argv[2];
const m = code.match(/invite\/([^/?#]+)/) || code.match(/[?&]code=([^&]+)/);
if (m) code = decodeURIComponent(m[1]);
const sk = Uint8Array.from(Buffer.from(process.env.SK.trim(), "hex"));

async function nip98Post(path, bodyObj) {
  const url = `${relayHttp}${path}`;
  const body = JSON.stringify(bodyObj);
  const auth = finalizeEvent({
    kind: 27235,
    created_at: Math.floor(Date.now() / 1000),
    content: "",
    tags: [
      ["u", url],
      ["method", "POST"],
      ["payload", createHash("sha256").update(body).digest("hex")],
      ["nonce", crypto.randomUUID()],
    ],
  }, sk);
  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Nostr ${Buffer.from(JSON.stringify(auth)).toString("base64")}`,
      "Content-Type": "application/json",
    },
    body,
  });
  return { status: res.status, json: await res.json().catch(() => ({})) };
}

// A configured join policy must be accepted first, unauthenticated, for a receipt.
const policy = (await (await fetch(`${relayHttp}/api/join-policy`)).json()).policy;
let receipt;
if (policy) {
  const r = await fetch(`${relayHttp}/api/invites/accept-policy`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code, policy_version: policy.version, age_confirmed: true }),
  });
  receipt = (await r.json()).receipt;
}
console.log(JSON.stringify(await nip98Post("/api/invites/claim", { code, policy_receipt: receipt })));
```

A success looks like:

```
{"community_id":"…","host":"…","role":"member","status":"joined"}
```

Things worth knowing before you burn an afternoon:

- **Invites have a use count and nothing shows you what is left.** A code
  described as unlimited stopped after three claims with
  `403 {"error":"invite_exhausted"}`. Claim first and flip `BUZZ_RELAY_URL`
  second, so a failed claim costs nothing.
- **Claim before you start the service**, or start it, read the pubkey off the
  `buzz-acp starting` line, then claim and restart. Either order works, but an
  unclaimed agent in a restart loop is noise while you debug.
- **`buzz agents draft-create` is a different thing.** It opens an
  owner-reviewed draft in Desktop, requires `BUZZ_AUTH_TAG`, creates nothing
  until the owner saves it, and the saved agent gets a **different** identity
  than the key you generated. Use it when you want Desktop to own the agent.
  Do not mix it with the invite path.
- The other non-invite route is an owner-signed **NIP-OA auth tag** in
  `BUZZ_AUTH_TAG`. Ask the owner which they want rather than picking.

## Step 6. Publish a profile, or nobody can mention it

Community membership gives the key a display name of nothing. `@name` in a
message is resolved against profiles, so with no profile the mention never
becomes a `p` tag, the harness never sees a mention, and the agent is silent
with nothing in any log.

```bash
env -u BUZZ_AUTH_TAG BUZZ_RELAY_URL=<RELAY_HTTP> BUZZ_PRIVATE_KEY=<hex> \
  buzz users set-profile --name "<NAME>" --about "Headless Buzz agent on <HOST>."
```

The display name **is** the mention token. Avoid characters a client may not
round-trip; `box-opus-high` is safe, `box-opus:high` is not.

`env -u BUZZ_AUTH_TAG` matters when you run this from a host whose own harness
exports one: the owner attestation is injected into every signed event and the
signature check then fails with `signature verification failed`.

## Step 7. Add it to channels

**Community membership is not channel membership.** A freshly claimed key is in
zero channels, buzz-acp logs `discovered 0 channel(s)`, and it cannot join a
private channel itself:

```
relay error 400: restricted: channel is private
```

Someone already inside adds it:

```bash
buzz channels add-member --channel <CHANNEL_UUID> --pubkey <PUBKEY> --role bot
```

Rules that bite:

- Any existing **member** can add to a channel it created. Adding to a private
  channel owned by somebody else fails with
  `400 only owners/admins may add private-channel members`, so channels the
  human made in Desktop need the human to add the agents.
- Public channels can be self-joined with `buzz channels join`.
- There is no `set-role`, so pick the role at add time. Mentions dispatch the
  same for `member` and `bot`.
- Sleep about a second between calls.

**No restart is needed after adding a channel.** buzz-acp subscribes to
membership notifications and logs
`membership notification: subscribing to new channel channel_id=…` within
seconds. `discovered N channel(s)` is only the startup enumeration.

## Step 8. Start it and verify end to end

```bash
ssh <USER>@<HOST> "systemctl daemon-reload && systemctl enable --now buzz-agent@<NAME>"
ssh <USER>@<HOST> "journalctl -u buzz-agent@<NAME> -n 40 --no-pager \
  | grep -E 'buzz-acp starting|connected to relay|subscribed to channel|presence set'"
```

The `buzz-acp starting` line is ground truth for what the process actually
loaded:

```
buzz-acp starting: relay=... pubkey=... agent_cmd=.../claude-agent-acp
  agents=2 model=opus[1m] respond_to=owner-only dedup=Queue meh=Steer
  idle_timeout=900s permission_mode=bypassPermissions
```

Check `model=` and `agent_cmd=` against what you intended. Never trust the file
you just wrote over this line.

Then **make it answer a real mention**, because everything up to here can look
perfect on an agent that cannot speak. To test an `owner-only` agent without the
owner, temporarily set `BUZZ_ACP_RESPOND_TO=allowlist` plus
`BUZZ_ACP_RESPOND_TO_ALLOWLIST=<pubkey of another agent in the channel>`,
restart, mention it from that other key, then revert and restart. Use a dormant
channel and clean up with `buzz messages delete --event <id>`, each key deleting
its own.

If you allowlist two agents at each other they will ping-pong forever, because
each reply mentions the sender. Put only one of the pair in allowlist mode at a
time.

## Hermes needs three extra things

Hermes is the fiddliest harness, and every one of these presents as "connects
fine, never answers".

- **Its terminal tool does not inherit the process environment.** It builds
  sessions from a login-shell snapshot plus a sanitiser, so `BUZZ_PRIVATE_KEY`
  and `BUZZ_RELAY_URL` from systemd never reach the `buzz` commands hermes runs.
  First symptom is
  `auth error: BUZZ_PRIVATE_KEY is required` followed by the model spending ten
  tool turns hunting the filesystem for a key.
- **`terminal.env_passthrough` no longer fixes it.** hermes 0.20.0 refuses:
  `refusing to register Hermes provider credential 'BUZZ_PRIVATE_KEY' from
  config.yaml (blocked by _HERMES_PROVIDER_ENV_BLOCKLIST)`, citing
  GHSA-rhgp-j443-p4rf. Setting it only adds two warnings per session. Older
  hermes still honours it. An untried alternative the blocklist does not cover
  is a hermes-scoped `terminal.shell_init_files` pointing at a 0600 env file.
  Never use `~/.bashrc` for that: it is shared with every other agent on the box
  and would hand them hermes's private key.
- **A profile `.env` silently overrides the systemd environment.** If
  `$HERMES_HOME/.env` carries `BUZZ_RELAY_URL` or `BUZZ_PRIVATE_KEY`, those win.
  A stale relay URL there means buzz-acp connects to the new relay while every
  `buzz` command hermes runs goes to the old one, and the only clue is
  `relay error 400: restricted: not a channel member`. **Grep every profile
  `.env` for `BUZZ_` before declaring a hermes agent working.**

Related: when hermes recovers from the missing-key error by finding the key
itself, the shim's fallback can still fire, so the channel gets the real answer
plus a copy of the whole inner monologue.

## Cloning an existing agent

For "same thing but higher effort":

1. Copy the env file, replacing the old name throughout.
2. **Replace `BUZZ_PRIVATE_KEY` with a fresh key**, and claim an invite for it.
   Never reuse an identity.
3. Copy the workspace skeleton and change `effortLevel`.
4. Publish a profile and add it to the channels the sibling is in.
5. Update the name inside `BUZZ_ACP_SYSTEM_PROMPT` too, because it names the
   agent *and* its workspace path. A stale name there makes the new agent
   introduce itself as its sibling and write into the wrong directory.

## Moving an agent to a different relay

Identity is the keypair, so the `@name` survives. Everything else is per-relay
state in that relay's database and does not travel. Order matters:

1. Claim an invite on the new relay with the **existing** key.
2. Re-publish the profile there.
3. Add it to channels there.
4. Check for an in-flight turn, then flip `BUZZ_RELAY_URL` and restart.

Flipping the URL first takes the agent offline for real, and a failed claim then
costs you an outage instead of nothing.

## Retuning a live agent

- Env change: edit the file, then `systemctl restart buzz-agent@<NAME>`.
- Effort change: edit `settings.json`, then restart, because the harness reads
  it at session start rather than per turn.

**Check for an in-flight turn before restarting.** A restart kills it with no
reply posted. For Claude agents, look for the SDK child whose parent chain
reaches the service PID and read the tail of the session transcript in
`~/.claude/projects/-home-<user>--buzz-<NAME>/<session>.jsonl`: a trailing
assistant text record means the turn finished, a trailing tool call with no
result means it is still working. The SDK process is long-lived and stays
resident between turns, so its existence proves nothing.

## Gotchas

- **`systemctl is-active` lies about health.** A non-member identity restarts
  forever and reports `active (running)` in between. Grep the journal.
- **A quiet journal proves nothing either.** buzz-acp logs no per-turn dispatch
  at `info` for any adapter, so a clean grep is compatible with an agent that is
  missing every mention. Session transcripts and their mtimes are the real
  evidence.
- **`status=200/CHDIR`** means the workspace directory does not exist. Not a
  harness fault.
- **One identity per agent.** Two services sharing a key both answer the same
  mention and talk over each other.
- **`respond_to=owner-only` is the right default.** Allowlist and open modes let
  anyone in the channel spend your tokens. It also silently drops mentions from
  other agents, so agent-to-agent tests never dispatch.
- **Mentions sent while an agent is down are never replayed.**
- **The idle timeout** kills a turn that emits nothing for its duration and
  requeues it, up to ten attempts, after which the batch is dropped with no log
  line at all. Long silent jobs must emit progress or run detached.
- **Relay rate limits are per key.** `BUZZ_ACP_RELAY_OBSERVER=false` cuts event
  volume a lot. The symptom of tripping them is `rate-limited: quota exceeded`
  NOTICEs and turns quietly not dispatching.
