Security and privacy
On this page

Security and privacy

Security and privacy

This page says what Handsoff keeps, what it never takes, and who can see what.

Before you start

The examples use the workspace billing from Your first relay. You are dana, its owner, and build-agent and review-agent are your agent handles. build-agent holds two pieces of work. It opened both from your shell with the baton.md file from Your first relay, and gave BILL-61 a transcript pointer:

handsoff open BILL-51 --title "Invoice totals check" --as build-agent --baton baton.md
Opened BILL-51 at r1 (work 98)
Leg 178; lease expires 2026-10-05T05:16:33.939505+00:00
handsoff open BILL-61 --title "Invoice dispute log" --as build-agent --baton baton.md --transcript /srv/agents/build-agent/sessions/0042.jsonl
Opened BILL-61 at r1 (work 99)
Leg 179; lease expires 2026-10-05T05:16:34.39135+00:00

The files secret.md, transcript.md and control.md are copies of baton.md with one change each. Each section below shows the change. Work names are used once in a workspace, so if a name here already exists in yours, use a new one.

What Handsoff keeps

Handsoff keeps the record of the relay. For each piece of work, that is:

  • Its name and title.
  • Each baton you save. A baton is the short note that tells the next carrier where the work stands. The baton explains its parts.
  • Each leg: which handle carried the work, when it started, and how it ended. A leg is one carrier's stretch on the work.
  • The offers, catches, notes, blocks and refusals between legs.
  • The runtime, model and session id that a carrier reports. Handsoff shows these as reported and never checks them.
  • A transcript pointer, when a carrier gives one. This is the place where a session's transcript lives, such as a file path. Handsoff keeps the text of the path and nothing else.

Transcripts stay where they are

Handsoff never reads or stores an agent's transcript. The adapters for Claude Code and Codex get the transcript's path from the runtime and keep it as the transcript pointer. They never open the file.

The standing instruction tells every agent the same thing: "Never ask for or read an earlier session's transcript." A carrier writes a baton from what it knows. Handsoff never writes a baton for it.

Only person handles see transcript pointers. Here a person and an agent read the history of the same work:

handsoff history BILL-61
…
Leg 1: build-agent (agent, unknown)
  opened at r1; saved r1
  runtime unknown and model unknown (as reported); session unknown
  live; lease expires 2026-10-05T05:16:34.39135+00:00
  Transcript pointer (where it lives): /srv/agents/build-agent/sessions/0042.jsonl
…
handsoff history BILL-61 --as review-agent
…
Leg 1: build-agent (agent, unknown)
  opened at r1; saved r1
  runtime unknown and model unknown (as reported); session unknown
  live; lease expires 2026-10-05T05:16:34.39135+00:00
…

The first command runs as dana, a person handle. The second runs as an agent handle, and the pointer is not there. continue never shows a pointer to anyone. So an agent that catches work is never told where its predecessor's transcript lives.

Secrets: say where, never what

A baton must not hold a secret. To tell the next carrier about one, add a secret reference that says where the secret lives:

## References
- [required] source: migrations/0042_invoices_v2.sql -- creates the v2 tables (check: bun test billing)
- secret: vault:billing/archive-read-key -- read access to the archive bucket

The next carrier sees the reference and gets the secret from that place with its own access. Handsoff never opens the place a reference names.

The checks on every save

Handsoff checks every piece of text you send: titles, work names, batons, references, notes, reasons and transcript pointers. It refuses text that looks like a secret, a transcript, or a terminal escape code.

handsoff checks lists what it looks for. It needs no sign-in:

handsoff checks
sk-ant: API token beginning sk-ant-
sk: API token beginning sk-
ghp: Source token beginning ghp_
gho: Source token beginning gho_
github-pat: Source token beginning github_pat_
glpat: Source token beginning glpat-
xox: Chat token beginning xox plus a letter and dash
cloud-access: AKIA followed by sixteen uppercase letters or digits
cloud-api: AIza followed by thirty-five token characters
private-key: Private key block header
signed-token: Three-part signed token with two eyJ segments
bearer: Bearer followed by a long token
url-credentials: User and password inside a URL
secret-assignment: Secret-named assignment with at least twelve characters and 3.5 bits character entropy, excluding references
json-transcript: JSON array of objects containing role and content
speaker-transcript: Different labelled speakers within twelve lines
controls: C0 except tab/newline, DEL, C1, bidi overrides and isolates
These checks catch common shapes, not every secret.

The last line matters. A secret with an unusual shape can get past the checks, so never paste one.

What a refusal looks like

This baton file, secret.md, has a key in its Traps part. It is a published example key that unlocks nothing:

## Traps
The archive bucket needs the key AKIAIOSFODNN7EXAMPLE to read.

The tool refuses it and saves nothing:

handsoff open BILL-50 --title "Archive copy" --as build-agent --baton secret.md
This field looks like a credential; name where the secret lives instead.
content_refused: field Traps, line 1, pattern cloud-access; DESIGN 2.5; fix: name where the secret lives instead; use --baton <file> or handsoff save <ref> --baton /dev/stdin <<'EOF' ... EOF (save without --baton also reads standard input), with Markdown containing these headings in order: ## True now, ## Next action (with Done when: <text>), ## Traps, ## Evidence, ## References

The refusal names the part of the baton, the line in that part, and the check that matched. It never repeats the text it matched. The same check runs on a note:

handsoff note BILL-61 --as build-agent --text "archive key is AKIAIOSFODNN7EXAMPLE"
This field looks like a credential; name where the secret lives instead.
content_refused: field note, line 1, pattern cloud-access; DESIGN 2.5; fix: name where the secret lives instead

Text shaped like a chat transcript is refused too. In transcript.md, two speakers take the place of the Evidence text:

## Evidence
user: did the copy finish?
assistant: yes, it wrote 1,200 rows.

The tool refuses it:

handsoff save BILL-51 --as build-agent --baton transcript.md
Handsoff never takes transcripts; summarise instead and use a leg's transcript pointer for where it lives.
content_refused: field Evidence, line 2, pattern speaker-transcript; DESIGN 2.5; fix: summarise instead; name where it lives with a leg's transcript pointer; use --baton <file> or handsoff save <ref> --baton /dev/stdin <<'EOF' ... EOF (save without --baton also reads standard input), with Markdown containing these headings in order: ## True now, ## Next action (with Done when: <text>), ## Traps, ## Evidence, ## References

A terminal escape code is refused as well. In control.md, the Traps line ends with a code that turns text red. The tool refuses it:

handsoff save BILL-51 --as build-agent --baton control.md
This field contains control characters; remove terminal escape codes.
content_refused: field Markdown, line 9, pattern controls; DESIGN 2.5; fix: remove terminal escape codes; use --baton <file> or handsoff save <ref> --baton /dev/stdin <<'EOF' ... EOF (save without --baton also reads standard input), with Markdown containing these headings in order: ## True now, ## Next action (with Done when: <text>), ## Traps, ## Evidence, ## References

For this check, the line number counts from the top of the whole file.

The tool runs these checks on your machine before it sends anything, so the refused text never leaves it. The service runs the same checks again on everything it gets. Neither keeps the refused text.

The tool also strips control characters from everything it prints, including text that comes back from the service. So a stored field cannot send escape codes to your terminal.

One carrier at a time

Only one handle holds a piece of work at a time. The database allows one open leg per piece of work, so two catches can never both succeed. A second handle that tries to catch held work is refused:

handsoff continue BILL-51 --as review-agent
build-agent holds this work until 2026-10-05T05:16:33.939505+00:00; ask for an offer or wait for the lease.
held_by_other: no catchable offer or drop; fix: ask the carrier for an offer or wait for its lease

The hold is a lease. It runs out if the carrier stops renewing it, and then the work drops so someone else can catch it. Leases and carriers explains this.

How sign-in works

Handsoff has no passwords of its own and makes no credentials of its own. You sign in through your team's sign-in page, as Sign in shows. The tool gets your sign-in tokens from there and keeps them on your machine.

  • The tool keeps them in its config folder: ~/.config/handsoff by default, or the folder in HANDSOFF_CONFIG_DIR.
  • The token file is called tokens-<server hash>-<account hash>.json. Only your machine account can read it (mode 0600). The tool makes its folders readable by your account only (mode 0700).
  • If a token file can be read by others, the tool refuses to use it and tells you to run chmod 600 on it.
  • The tool never prints or logs a token. It sends tokens only to your team's sign-in service and to the Handsoff service you signed in to.
  • The agents on your machine use your sign-in. They never sign in themselves.

The service checks your sign-in on every request. It takes your identity from your sign-in, never from what the request says. So no one can act in your name by typing it.

The tool refuses a service address that is not HTTPS, except a test service on your own machine:

handsoff login --server http://relay.example --no-browser
The server address is not safe; use HTTPS or loopback HTTP without credentials.
invalid: the requested operation needs correction; fix: set --server to an HTTPS address or a loopback test server

To stop someone at once, the owner disables their handle. If you end a person's access at your team's sign-in service instead, it takes effect when their current token runs out.

Who can see what

Inside a workspace, every enabled handle can read all of its work and history. Workspaces lists what only the owner can do.

Outside a workspace, a person sees nothing of it. Work they cannot see gets the same answer as work that does not exist, so they cannot even tell that it is there:

handsoff continue BILL-99 --as review-agent
This record is not available to this handle; check the workspace, work and handle.
not_found: no visible matching record; fix: check the workspace, work and acting handle

On the history page, you see only the workspaces where you hold a handle. The page only reads. It shows batons and hand-overs and never a transcript. A transcript pointer shows as text, never as a link. If you open the address of a workspace or work you cannot see, the page says "Nothing to show here" and names nothing of it.

Refusals of an act on a piece of work go into the history of its workspace, where its handles can read them. A refusal for someone with no handle in the workspace goes only to the service's own log. So an outsider cannot write into your history.

Access to a workspace gives no access to what its batons point to. A handle needs its own access to a tracker, a document or a secret store.

What Handsoff does not do

Handsoff does not:

  • Start, stop, schedule or bill agent sessions.
  • Store or read transcripts.
  • Track tasks or hold knowledge.
  • Fetch what a baton refers to.
  • Decide who should carry work.