# DocBox agent setup

The complete human- and agent-access contract is in the canonical
[`access-flows.md`](canonical/access-flows.md). This guide contains only the
agent-facing operational steps.

The homepage serves this guide to plain HTTP fetches and recognized agent
fetchers. Browser navigation gets the web app. Use `/?mode=agent` or `/llms.txt`
to request the guide explicitly, even if your fetcher sends browser headers.

## Prepare a human invitation (no agent enrollment required)

To invite a **person to an existing workspace**, use this public flow rather
than agent enrollment below. Preparation, preview, login, and status polling
send no invitation email and grant nothing. A current human workspace member
must approve on the exact workspace host; the recipient then independently
accepts the ordinary email invitation at the main site. Agents cannot approve,
reject, resend, or cancel. New grants are fixed member access; no role field exists.

`POST https://docbox.sh/api/person-invitation-requests` accepts exactly
`workspaceName`, `email`, and `token` in JSON. Workspace/email are normalized
and immutable. The caller generates a fresh 32-byte random token (64 lowercase
hex characters); only its digest is stored. Every exact create retry must reuse
the original JSON and token, including after a lost response. Changed scope
requires a new proposal/token. Divergent reuse is generically invalid.
Idempotency and token binding apply during the 48-hour recovery window. Once
physical cleanup removes a request, its old ID remains invalid; a later create
is a new proposal requiring fresh human review, never recovery or a resend.
Always generate fresh token bytes for a new proposal rather than reusing old ones.

HTTP 201 returns `id`, `approvalUrl`, `expiresAt`, `recoverUntil`, and
`pollIntervalSeconds: 5`. Give the human **only `approvalUrl`**. Approval lasts
24 hours; outcome recovery lasts 48 hours from creation. Neither creation nor
status reveals whether the submitted workspace exists or is available.

`POST /api/person-invitation-requests/ID/status` takes only `{"token":"…"}`
in JSON, never a token in the URL. Its only result is `status`: `pending`,
`approved`, `rejected`, or `expired`. `approved` means a human authorized the
ordinary invitation flow, not that email arrived or the recipient joined.
Status never exposes recipient credentials, sessions, delivery or acceptance
activity. Repeated approval/status never sends again. Delivery ambiguity is
shown to humans with an explicit ordinary-invitation resend action.

Bodies must be JSON objects of at most 4096 bytes, delivered within five seconds.
Unknown fields, malformed tokens and JSON fail with 400; wrong content type is
415, oversized bodies 413, body timeout 408. Honor integer `Retry-After` on 429
and 503. Poll no faster than five seconds, stop at terminal/invalid results or
`recoverUntil`, and stop after five consecutive transport/5xx failures with
**outcome unknown**. Never interpret a timeout as rejection or regenerate the
token to retry an uncertain create.

Copyable preparation example (POSIX shell, Python 3, curl, jq). Replace only
`WORKSPACE` and `PERSON_EMAIL` in the Python input. Do not enable shell tracing.
Private files live outside the repository; tokens and recipient JSON never
enter curl's argv or a URL. Keep the directory on an uncertain result so exact
retries remain possible. Delete it after a terminal result, or at recovery expiry.

```sh
umask 077
export DOCBOX_REQUEST_DIR=$(mktemp -d)
python3 - "$DOCBOX_REQUEST_DIR" <<'PY'
import json, secrets, sys
from pathlib import Path
p = Path(sys.argv[1])
token = secrets.token_hex(32)
(p / 'create.json').write_text(json.dumps({
    'workspaceName': 'WORKSPACE', 'email': 'PERSON_EMAIL', 'token': token}))
(p / 'status.json').write_text(json.dumps({'token': token}))
PY
# On transport failure or 429/503, wait Retry-After and rerun this SAME curl.
# Do not rerun the token-generation block. Limit consecutive failures to five.
curl --silent --show-error --max-time 10 \
  -H 'Content-Type: application/json' --data-binary @"$DOCBOX_REQUEST_DIR/create.json" \
  -D "$DOCBOX_REQUEST_DIR/headers" -o "$DOCBOX_REQUEST_DIR/created.json" \
  -w 'HTTP %{http_code}\n' https://docbox.sh/api/person-invitation-requests
# Continue only after HTTP 201; this prints only the human review locator.
jq -er '.approvalUrl' "$DOCBOX_REQUEST_DIR/created.json"
```

After handing off that locator, this bounded polling block waits up to ten
minutes (plus server cooldowns). If still pending or unknown, retain the same
private directory and rerun it later within 48 hours; no second human approval
is needed. Do not run concurrent polling loops for the same proposal.

```sh
(
  set -eu
  id=$(jq -er '.id | select(test("^[0-9a-f-]{36}$"))' "$DOCBOX_REQUEST_DIR/created.json")
  until_epoch=$(python3 - "$DOCBOX_REQUEST_DIR/created.json" <<'PY'
import datetime, json, sys
print(int(datetime.datetime.fromisoformat(json.load(open(sys.argv[1]))['recoverUntil'].replace('Z','+00:00')).timestamp()))
PY
  )
  terminal=0
  trap 'if [ "$terminal" = 1 ]; then rm -rf "$DOCBOX_REQUEST_DIR"; fi' EXIT
  failures=0
  wait_seconds=5
  for attempt in $(seq 1 120); do
    if [ "$(date +%s)" -ge "$until_epoch" ]; then echo 'Recovery expired; outcome unknown.'; terminal=1; exit; fi
    sleep "$wait_seconds"
    code=$(curl --silent --show-error --max-time 10 \
      -H 'Content-Type: application/json' --data-binary @"$DOCBOX_REQUEST_DIR/status.json" \
      -D "$DOCBOX_REQUEST_DIR/headers" -o "$DOCBOX_REQUEST_DIR/status-response.json" \
      -w '%{http_code}' "https://docbox.sh/api/person-invitation-requests/$id/status") || code=000
    wait_seconds=$(awk 'BEGIN{n=5} tolower($1)=="retry-after:" {v=$2+0;if(v>n)n=v} END{print n}' "$DOCBOX_REQUEST_DIR/headers")
    case "$code" in
      200)
        failures=0
        state=$(jq -er '.status' "$DOCBOX_REQUEST_DIR/status-response.json")
        case "$state" in
          pending) ;;
          approved|rejected|expired) echo "$state"; terminal=1; exit ;;
          *) echo 'Outcome unknown; preserve private recovery files.'; exit 1 ;;
        esac ;;
      400) echo 'Request unavailable; outcome unknown.'; terminal=1; exit ;;
      429) ;;
      000|5??) failures=$((failures+1)); if [ "$failures" -ge 5 ]; then break; fi ;;
      *) break ;;
    esac
  done
  echo 'Still pending or outcome unknown; preserve private recovery files and retry later.'
)
```

Cleanup is opportunistic after recovery expires, not immediate erasure during
idle traffic. Cancellation/deletion disables live capabilities; it cannot
recall delivered mail or erase provider/backup copies.

Set up access on the machine where the user will run `ssh WORKSPACE.AGENT`. You manage
the local agent name, keypair, enrollment, SSH configuration, and verification.
Prepare a request and give a current human workspace member its review link. Human-copied
setup instructions contain no credential and grant no access. The alias must
log in as the named agent using its matching private key.

## 1. Create the local keypair

Inspect `~/.ssh/config` and run `ssh -G WORKSPACE.AGENT` first. Reuse an existing
alias's dedicated key only after verifying the host, username, and matching
public key. A previously working alias can stop authenticating after its key
is revoked or the workspace is reset; that does not mean the local key is bad.
Never overwrite an existing key to repair authentication.

For a new pair, use `~/.ssh/DocBox.d/WORKSPACE.AGENT`. The `.d` directory avoids
colliding with an older private key named `~/.ssh/DocBox`. If the chosen
directory path is itself a file, preserve it and choose another directory.
Create directories with mode 700 and private keys with mode 600. Only submit
the public `.pub` key. This POSIX-shell example creates a new unencrypted key
for unattended use, or verifies and reuses the existing pair; an existing
encrypted key requires its passphrase or a working ssh-agent arrangement:

```sh
(
  set -eu
  umask 077
  DocBox_key_dir="$HOME/.ssh/DocBox.d"
  DocBox_key="$DocBox_key_dir/WORKSPACE.AGENT"
  if [ -L "$DocBox_key_dir" ] || { [ -e "$DocBox_key_dir" ] && [ ! -d "$DocBox_key_dir" ]; }; then
    echo "Key directory is a file; preserve it and choose another directory." >&2
    exit 1
  fi
  mkdir -p "$DocBox_key_dir"
  chmod 700 "$DocBox_key_dir"
  if [ -L "$DocBox_key" ] || [ -L "$DocBox_key.pub" ]; then
    echo "Key path is a symlink; inspect it and choose a dedicated file path." >&2
    exit 1
  fi
  if [ ! -e "$DocBox_key" ]; then
    if [ -e "$DocBox_key.pub" ]; then
      echo "Public key exists without its private key; recover the pair or choose a new path." >&2
      exit 1
    fi
    ssh-keygen -q -t ed25519 -N '' -f "$DocBox_key"
  fi
  chmod 600 "$DocBox_key"
  DocBox_public=$(ssh-keygen -y -f "$DocBox_key")
  DocBox_public=$(printf '%s\n' "$DocBox_public" | awk '{print $1 " " $2}')
  if [ -e "$DocBox_key.pub" ]; then
    DocBox_existing=$(awk '{print $1 " " $2}' "$DocBox_key.pub")
    if [ "$DocBox_existing" != "$DocBox_public" ]; then
      echo "Public/private key mismatch; preserve both and resolve before enrollment." >&2
      exit 1
    fi
  else
    printf '%s\n' "$DocBox_public" > "$DocBox_key.pub"
  fi
  echo "Keypair verified: $DocBox_key"
)
```

Check the command's actual exit status before reporting success. If you choose
a different key location, use that actual path in both enrollment and
`IdentityFile` below.

## 2. Request human approval

This path enrolls a new agent in an existing workspace. It does not create a
workspace, person, membership, or agent before approval. Choose an agent name
matching `[a-z][a-z0-9-]{0,31}` and use the canonical Ed25519 public key above.
Generate 32 cryptographically random bytes locally and encode them as exactly
64 lowercase hexadecimal characters. This is your private recovery/polling
`token`. Keep it out of output, logs, URLs, and command
arguments. Keep it in process memory or a mode-600 file outside the repository
while awaiting approval; remove it after saving the connection result.

POST JSON to `https://docbox.sh/api/agent-requests` with exactly:

```json
{"workspaceName":"WORKSPACE","agentName":"AGENT","publicKey":"<canonical public .pub key>","token":"<your random recovery token>"}
```

Use a JSON encoder and send the body through process input, not command-line
arguments. HTTP 201 returns `id`, `approvalUrl`, `expiresAt`, `recoverUntil`, and
`pollIntervalSeconds`. Give the human only `approvalUrl` and keep polling while
they approve; do not require a second message to check their decision.
Request creation grants nothing and does not confirm that the
workspace exists. A current human member of that workspace opens that link, signs in if
needed, reviews the workspace, agent name, and access, and explicitly approves
or rejects registering the agent's SSH key. The human does not compare key
fingerprints. Do not request a human session or attempt to approve yourself.

If creation's response is lost, retry the same workspace, agent name, canonical
public key, and token. Exact retries recover the same request; changing the
proposal with that token fails. Fixing a name/key requires a new request and
fresh token, never substituting fields on an existing approval.

Poll `POST https://docbox.sh/api/agent-requests/REQUEST_ID/status` with exactly
`{"token":"<your recovery token>"}`. Poll no faster than every five seconds;
respect HTTP 429 `Retry-After`, back off on temporary errors, and stop at expiry.
HTTP 200 returns `status`: `pending`, `completed`, `rejected`, `expired`, or
`conflict`. Only `completed` includes `connection`. Verify that
`connection.fingerprint` matches the
fingerprint of your local public key; stop on a mismatch. Save those non-secret
connection details, verify the server host-key fingerprint, and continue with
section 3. Rejection/expiry
grant no access; a conflict means this request could not enroll its name/key.
Check for an existing enrollment before asking for a new request.

Approval is allowed for 30 minutes. Completed results remain recoverable with
the same token until `recoverUntil` (24 hours from creation), even after approval
expiry. Missing, malformed, or wrong credentials return HTTP 400
`invalid_agent_request`; unavailable results also fail generically. Never put
the token in a query string or share it with the approver. Name/key conflicts
and malformed inputs grant nothing. SSH cannot authenticate before approval.

## 3. Configure SSH

Create or update the exact `Host WORKSPACE.AGENT` entry in `~/.ssh/config`. Preserve unrelated entries and place this specific entry before broad `Host *` defaults. Use the enrolled response's `alias` for WORKSPACE.AGENT, `host` as HostName, `port` for 22, and `username` as User. Set IdentityFile to the matching private key's actual location:

```sshconfig
Host WORKSPACE.AGENT
  HostName SSH_HOST
  User WORKSPACE.AGENT
  Port 22
  IdentityFile ~/.ssh/DocBox.d/WORKSPACE.AGENT
  IdentitiesOnly yes
  RequestTTY no
  ConnectTimeout 10
```

Use mode 600 for the config file. `WORKSPACE.AGENT` is a local SSH alias; its HostName is the workspace's DNS hostname (normally `WORKSPACE.docbox.sh`), but the hostname is only a network endpoint and is not trusted tenant context. The server identifies the exact workspace and agent from the `WORKSPACE.AGENT` SSH username, then requires the enrolled key and a valid signature for that scoped identity. Bare agent usernames and key-based workspace inference are rejected. The returned alias includes the environment when connecting to a preview, keeping its keys and configuration separate from other previews and production.

Keep OpenSSH host-key checking enabled. Before connecting, inspect existing trust
with `ssh-keygen -F "[SSH_HOST]:22"`. If no entry exists, add exactly
`[SSH_HOST]:22 SERVER_HOST_KEY` to the user's known-hosts file after the
fingerprint check above. For port 22, OpenSSH may also use the unbracketed host
form. If an existing entry conflicts with the returned key, stop and report the
conflict; never replace it automatically. The TLS-authenticated completion
response and SSH listener derive these values from the same server key.

## Run workspace commands

DocBox runs shell scripts over workspace documents. Start with `help`.
Quote the remote script so your local shell does not expand it. Pipes, `;`,
`&&`, `||`, variables, globbing, and redirection are supported. Documents live
in `/workspace`, the initial working directory; `ls`, `ls .`, and
`ls /workspace` list that directory. Other paths belong to a temporary virtual
filesystem. The document mount is a restricted writable virtual workspace.
Explicit document commands handle namespace changes; `>`, `>>`, `tee`, and
`sed -i` commit each existing-document write when that operation is encountered.
The host filesystem, arbitrary programs, and network remain unavailable.

Run `doc-*` mutations directly. A request is either one standalone mutating
`doc-*` command or one shell script containing writes to existing documents.
Mixing explicit mutations with shell writes is rejected, but shell writes that
were already encountered remain committed. Successful mutations produce no
command output, so read back the document separately. There is no caller request
ID or replay token. If an SSH result is uncertain, read the latest state before
deciding what to do next; repeating a mutation is new work and may duplicate its
effect.

```sh
ssh -n WORKSPACE.AGENT 'rg -il needle -g "*.md" | sort | head -10'
ssh -n WORKSPACE.AGENT 'doc-create notes.md'
VERSION=$(ssh -n WORKSPACE.AGENT 'doc-read notes.md' | jq -r .version)
printf '# Notes\n' | ssh WORKSPACE.AGENT "doc-append notes.md $VERSION --stdin"
VERSION=$(ssh -n WORKSPACE.AGENT 'doc-read notes.md' | jq -r .version)
ssh -n WORKSPACE.AGENT "doc-replace-text notes.md $VERSION Notes 'Meeting notes'"
ssh -n WORKSPACE.AGENT 'doc-read notes.md'
```

To append a local Markdown file without quoting its contents:

```sh
ssh -n WORKSPACE.AGENT 'doc-create notes.md'
VERSION=$(ssh -n WORKSPACE.AGENT 'doc-read notes.md' | jq -r .version)
ssh WORKSPACE.AGENT "doc-append notes.md $VERSION --stdin" < local.md
ssh -n WORKSPACE.AGENT 'doc-read notes.md'
```

`--stdin` appends one Yjs operation; it cannot replace existing text. Input
preserves Unicode, quotes, backticks, and trailing newlines. Empty or invalid
UTF-8 input fails. Finite requests collect at most 2,000,000 bytes and require
EOF within 10 seconds, before any command executes. Oversized, timed-out, or
interrupted input commits nothing. Use `ssh -n` when no stdin is intended;
SSH clients using an API must end their input stream. Bare SSH, `help`, and
standalone `watch` do not wait for stdin. This is a finite shell, not an
interactive terminal. A request may contain only one stdin-consuming document
operation; this prevents buffered input from being consumed twice. Once an
operation commits, a later disconnect does not undo it. Read back the committed
document after an uncertain result; do not automatically retry a mutation.

**When a human disconnects an agent:**

Every current human member can Disconnect an agent in Team; agents cannot grant
or revoke access themselves. This revokes all of that agent's keys, not its
identity or document/notification history. A new SSH connection fails
authentication; a newly admitted exec using a revoked key fails with
`error: unauthorized` and exit 1. Draining connections reject new channels.
Live streams and transports stop within 30 seconds under a running process.
Database failure or pool starvation beyond the validity lease also closes
transports; `unavailable` does not mean a human revoked access.

Already-admitted database writes may finish after the transport closes, even
beyond 30 seconds. Forced closure may return an error/exit 1 or SSH transport-loss
exit 255; it does **not** prove rollback. Never automatically replay a mutation.
Ask a human or another authorized identity to inspect persisted state before
deciding what new work is needed. A replacement agent has a new principal, so
old receipt/request IDs do not deduplicate work across handles.

Reconnecting requires a new, unused agent handle and a new human-approved
enrollment request. Retrying the completed old enrollment does not restore keys.
Disconnect cannot recall output the agent already received.

`doc-read` returns JSON with content and an opaque version. Text commands resolve
literal, exact anchors in that historical version, then merge their update into
the latest document. Delete and replace require a unique anchor unless a
one-based `--occurrence N` is supplied. Insert defaults to occurrence 1 and puts
`--occurrence N` immediately after its anchor. `doc-append <path> <version>
--stdin` inserts at the end of that historical version. Whitespace, line
endings, combining sequences, and Unicode are significant; anchors are not
regexes and are not normalized. A rename, removal, remove/recreate, or rename
away and back after the named version fails with `stale_document_identity`.
`doc-rename` preserves the document's text identity, and `doc-remove` removes
its path. Create refuses existing paths; rename refuses existing destinations.
Read the current version before each text mutation.

There is no upload or whole-file replacement command. `cp`, `mv`, and `rm`
cannot mutate shared documents, and shell redirection/`tee`/`sed -i` can only
write existing documents. Use temporary paths for intermediate shell output.
A failure before a write commits nothing; writes encountered before a later
failure are not rolled back. Pipelines use `pipefail` by default. Use `&&` or
`set -e` to stop a sequence after other failures.
`watch` emits `ready` and then live-only file/folder change hints while the
standalone SSH command remains connected; it sends no initial worktree or replay.
Native `rg` searches run within
`/workspace`. Network access and arbitrary host programs are unavailable.

After configuring your SSH alias, use this search workflow:

```sh
ssh WORKSPACE.AGENT help
ssh -n WORKSPACE.AGENT 'cat AGENTS.md'
ssh -n WORKSPACE.AGENT 'rg --files --hidden -g "*.md"'
ssh -n WORKSPACE.AGENT 'rg -n -i --hidden -g "*.md" "SEARCH_TERM" .'
```

Ripgrep exit code **0** means matches, **1** means no matches, and **2** means
a search error. Code 1 with no output is a completed search, not a broken
connection. Report no match after checking the intended files; do not retry
unrelated commands or create a matching file unless the human requests it.

## Completion

Verify SSH directly, without piping its output through `head`, `tee`, or another
command that can mask its exit status. `ConnectTimeout 10` is built into OpenSSH
and works on macOS; no GNU `timeout` is needed. A zero exit status and workspace
help must both be present. `Permission denied` is always a failed setup.

Setup is complete when enrollment succeeds, `ssh -G WORKSPACE.AGENT` resolves the intended host, username and private key, and both `ssh WORKSPACE.AGENT` and `ssh WORKSPACE.AGENT help` exit successfully with workspace help. Report the working alias and private-key file path to the user; keep key contents private. Bare SSH displays help and closes the connection. Run each subsequent workspace command with `ssh -n WORKSPACE.AGENT 'COMMAND'`.

## Propose a new workspace

For a **new** workspace, an agent with no DocBox enrollment can prepare a
proposal at the root origin. This is separate from joining an existing
workspace above. The founding human must already have a verified DocBox email;
this flow does not admit a first-time human or bypass the waitlist.

Generate and securely save a random 32-byte lowercase hex `token` before sending
`POST /api/workspace-requests` with `Content-Type: application/json`:

```json
{
  "workspaceName": "my-new-workspace",
  "ownerEmail": "human@example.com",
  "displayName": "Human's name in this workspace",
  "token": "YOUR_64_LOWERCASE_HEX_CHARACTERS"
}
```

Send the returned `approvalUrl` to that human. It is only a locator, not an
access credential. Do not share the recovery token or put it in a URL. The
human signs in through the **exact proposed email**, reviews the immutable
details, and explicitly creates or rejects the workspace. Merely preparing,
opening, or signing in grants nothing and reserves no name. Creation uses
that human's verified account; it never grants the preparing agent human-account
authority.

To connect yourself in the same confirmation, first create a dedicated
Ed25519 keypair using step 1 above. Add `"agent":{"agentName":"assistant",
"publicKey":"ssh-ed25519 …"}` to the proposal. Submit only the public key.
The human reviews that exact name and fingerprint and explicitly confirms
**Create workspace and connect agent**. Workspace, founding membership, and
agent/key are one transaction: an agent conflict cannot leave a workspace
behind. Without `agent`, creation grants no agent access; use the ordinary
existing-workspace enrollment flow later if needed.

A completed status with an agent includes the same `connection` object as
existing-workspace enrollment: workspace-qualified `username`, host, port,
fingerprint and pinned server host key. Follow the SSH configuration and
verification steps above using those returned values. Never use a bare agent
name as the SSH username, and never treat `pending` as a successful setup.

Poll `POST /api/workspace-requests/ID/status` with `{"token":"…"}` no more
often than the returned `pollIntervalSeconds` (currently 5). Outcomes are
`pending`, `completed`, `rejected`, `expired`, or `conflict`. Respect HTTP 429
and `Retry-After`. On a lost creation response, resend the **identical** payload
and token; it returns the same locator and deadlines, not a renewed proposal.
Changing the proposal requires a fresh token. Rejected and conflicted requests
are terminal; do not keep trying to approve them.

Approval expires after 30 minutes; status/recovery is available for 24 hours
from preparation, including completed results. After that window, recovery is
unavailable. Check with the human before proposing another workspace. Limits
are fixed windows: 30 preparations globally/minute and per source/hour; 1200
polls globally/minute and 120 per source/minute. Confirmed creation shares the
human's existing limit of 10 successful workspace creations per 24 hours.
Failures, rejected/conflicted requests, and result replays consume no successful
creation allowance. Expired data is cleaned in bounded batches on traffic;
access expires even while the service is idle.
