diff --git a/.claude/skills/setting-up-agent-relay-desktop/SKILL.md b/.claude/skills/setting-up-agent-relay-desktop/SKILL.md index 82d9527b..130f8301 100644 --- a/.claude/skills/setting-up-agent-relay-desktop/SKILL.md +++ b/.claude/skills/setting-up-agent-relay-desktop/SKILL.md @@ -107,9 +107,18 @@ fi test -S "${relay_socket:-/nonexistent}" && printf 'socket=%s\n' "$relay_socket" ``` +Shell variables do not survive between separate tool calls. Every later block +uses `relay_socket`, and some use variables set earlier in the same section (the +sign-in poll uses `sign_in`, the sign-in request's response). Run a section's +blocks in one shell. In a new shell, re-establish every variable the next block +reads first: re-run this discovery block for `relay_socket`, and never re-send a +sign-in request just to restore `sign_in`; the poll falls back to a 5-second +interval without it. + If the socket exists, inspect it before installing anything: ```sh +test -S "${relay_socket:-/nonexistent}" || { printf 'relay_socket is not set: run the socket discovery block first.\n' >&2; exit 1; } curl -sS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | jq ``` @@ -175,18 +184,19 @@ and at least 13.0.0. Find the same user-level npm, mise, or nvm CLI the app discovers; never run the desktop's `/usr/bin/agent-relay` launcher as a CLI: ```sh -relay_cli= -for candidate in \ - "$HOME/.local/bin/agent-relay" \ - "$HOME/.npm-global/bin/agent-relay" \ - "$HOME/.agentworkforce/relay/bin/agent-relay" -do - if test -x "$candidate" && test "$candidate" != /usr/bin/agent-relay; then - relay_cli=$candidate - break - fi -done -if test -z "$relay_cli"; then +# The installed Agent Relay CLI: user-level npm, mise or nvm, then PATH; +# never the .deb's /usr/bin/agent-relay Desktop launcher. Section 2 and +# section 6 define this identically; change both together. +find_relay_cli() { + for candidate in \ + "$HOME/.local/bin/agent-relay" \ + "$HOME/.npm-global/bin/agent-relay" \ + "$HOME/.agentworkforce/relay/bin/agent-relay" + do + if test -x "$candidate" && test "$candidate" != /usr/bin/agent-relay; then + printf '%s\n' "$candidate"; return + fi + done for relay_root in \ "$HOME/.local/share/mise/installs/node" \ "$HOME/.nvm/versions/node" @@ -195,11 +205,15 @@ if test -z "$relay_cli"; then candidate=$(find -L "$relay_root" -mindepth 3 -maxdepth 3 \ -path '*/bin/agent-relay' -type f -perm -u+x -print -quit 2>/dev/null) if test -n "$candidate" && test "$candidate" != /usr/bin/agent-relay; then - relay_cli=$candidate - break + printf '%s\n' "$candidate"; return fi done -fi + candidate=$(command -v agent-relay 2>/dev/null || true) + if test -n "$candidate" && test -x "$candidate" && test "$candidate" != /usr/bin/agent-relay; then + printf '%s\n' "$candidate" + fi +} +relay_cli=$(find_relay_cli) if test -n "$relay_cli"; then "$relay_cli" --version "$relay_cli" integration subscribe --help >/dev/null @@ -455,7 +469,12 @@ while :; do '{sign_in: .data.sign_in, workspace: .data.workspace}' break ;; - preparing|pending_approval) ;; + preparing|pending_approval) relay_unknown=0 ;; + signed_out|not_signed_in) + # signed_out: the attempt ended or was cancelled; not_signed_in: the error code. + printf 'Sign-in is not in progress; send the sign-in request again (see Recovery).\n' >&2 + exit 1 + ;; denied|expired|error) msg=$(printf '%s' "$state" | jq -r '.data.sign_in_message // .error.message // "no message reported"') printf 'Sign-in %s: %s\n' "$phase" "$msg" >&2 @@ -468,8 +487,18 @@ while :; do esac exit 1 ;; + *) + # A failed or empty status read: allow three in a row, then stop and report. + relay_unknown=$(( ${relay_unknown:-0} + 1 )) + printf 'Unexpected sign-in state: %s (attempt %s of 3)\n' "${phase:-none}" "$relay_unknown" >&2 + if test "$relay_unknown" -ge 3; then + printf 'Agent Relay status is unreadable; check that the app is running, then retry sign-in.\n' >&2 + exit 1 + fi + ;; esac - sleep "$(printf '%s' "$sign_in" | jq -r '.data.interval // 5')" + relay_interval=$(printf '%s' "${sign_in:-}" | jq -r '.data.interval // empty' 2>/dev/null) + sleep "${relay_interval:-5}" done ``` @@ -873,6 +902,39 @@ is set: ```sh pull_glob='/github/repos/OWNER/REPO/pulls/NUMBER/**' +# Shell variables do not survive between tool calls: when relay_cli is unset +# here, find the installed CLI again with section 2's helper, so an installed +# CLI is not replaced by the npx fallback. +# The installed Agent Relay CLI: user-level npm, mise or nvm, then PATH; +# never the .deb's /usr/bin/agent-relay Desktop launcher. Section 2 and +# section 6 define this identically; change both together. +find_relay_cli() { + for candidate in \ + "$HOME/.local/bin/agent-relay" \ + "$HOME/.npm-global/bin/agent-relay" \ + "$HOME/.agentworkforce/relay/bin/agent-relay" + do + if test -x "$candidate" && test "$candidate" != /usr/bin/agent-relay; then + printf '%s\n' "$candidate"; return + fi + done + for relay_root in \ + "$HOME/.local/share/mise/installs/node" \ + "$HOME/.nvm/versions/node" + do + test -d "$relay_root" || continue + candidate=$(find -L "$relay_root" -mindepth 3 -maxdepth 3 \ + -path '*/bin/agent-relay' -type f -perm -u+x -print -quit 2>/dev/null) + if test -n "$candidate" && test "$candidate" != /usr/bin/agent-relay; then + printf '%s\n' "$candidate"; return + fi + done + candidate=$(command -v agent-relay 2>/dev/null || true) + if test -n "$candidate" && test -x "$candidate" && test "$candidate" != /usr/bin/agent-relay; then + printf '%s\n' "$candidate" + fi +} +test -n "${relay_cli:-}" || relay_cli=$(find_relay_cli) relay_cli_major= if test -n "${relay_cli:-}"; then relay_cli_major=$("$relay_cli" --version 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9]*\)\..*/\1/p' | sed -n '1p') @@ -891,7 +953,8 @@ else fi ``` -Use the `relay_cli` found in section 2, never a bare `agent-relay`: on a +Use the `relay_cli` found in section 2 (re-discovered above when this runs in +a new shell), never a bare `agent-relay`: on a `.deb` host `/usr/bin/agent-relay` is the Desktop launcher, and `PATH` may resolve to it or to a different, older CLI than the one whose version was checked. diff --git a/.claude/skills/setting-up-agent-relay-sessions/SKILL.md b/.claude/skills/setting-up-agent-relay-sessions/SKILL.md index 155a6c8f..95b527ff 100644 --- a/.claude/skills/setting-up-agent-relay-sessions/SKILL.md +++ b/.claude/skills/setting-up-agent-relay-sessions/SKILL.md @@ -170,8 +170,18 @@ a `.deb` install, and do not install or upgrade it just for these checks. ```sh relay_cli=$(command -v agent-relay || true) case "$relay_cli" in ''|/usr/bin/agent-relay) relay_cli= ;; esac +relay_cli_version= if test -n "$relay_cli"; then - "$relay_cli" --version + relay_cli_version=$("$relay_cli" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +fi +# Gate only these optional checks on 13.x; keep relay_cli itself, which +# setting-up-agent-relay-desktop section 6 uses with its own version handling. +relay_cli_checks= +case "$relay_cli_version" in + 13.*) relay_cli_checks=1 ;; + *) test -n "$relay_cli" && printf 'agent-relay %s is not 13.x; read the workspace from /setup/status instead.\n' "${relay_cli_version:-unknown}" >&2 ;; +esac +if test -n "$relay_cli_checks"; then "$relay_cli" cloud whoami # signed in, and as whom "$relay_cli" workspace active # the active Cloud workspace (keys stay masked) "$relay_cli" cloud workspaces # every workspace this login can use, with ids diff --git a/prpm.lock b/prpm.lock index 19764f7a..e6798329 100644 --- a/prpm.lock +++ b/prpm.lock @@ -3,9 +3,9 @@ "lockfileVersion": 1, "packages": { "@agent-relay/setting-up-agent-relay-desktop#claude": { - "version": "1.1.7", - "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-desktop/1.1.7.tar.gz", - "integrity": "sha256-8dacdacdd3aeafb004b434a5786d2b0803c2d0f951ecc0575d61999a1fc4d572", + "version": "1.1.9", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-desktop/1.1.9.tar.gz", + "integrity": "sha256-5995b95ee1e6f059572c1b1c1028b8f9a66ca6b22557d28e66793fd28db268d3", "format": "claude", "subtype": "skill", "sourceFormat": "claude", @@ -13,9 +13,9 @@ "installedPath": ".claude/skills/setting-up-agent-relay-desktop/SKILL.md" }, "@agent-relay/setting-up-agent-relay-sessions#claude": { - "version": "0.2.6", - "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-sessions/0.2.6.tar.gz", - "integrity": "sha256-9488abb24d2f52f39db1700c24cc3f00b2ee4873902fdfc4546400ee957862f7", + "version": "0.2.8", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-sessions/0.2.8.tar.gz", + "integrity": "sha256-59dee9d4ba51adde445a5c5bb21e6a260ee8b12fd2ca95d531ecb90e03c9b31a", "format": "claude", "subtype": "skill", "sourceFormat": "claude", @@ -53,5 +53,5 @@ "installedPath": ".claude/skills/setting-up-agent-relay-flows/SKILL.md" } }, - "generated": "2026-10-08T13:36:22.947Z" + "generated": "2026-10-08T16:14:45.506Z" } \ No newline at end of file diff --git a/web/lib/generated/setup-skills.ts b/web/lib/generated/setup-skills.ts index 5bbf50af..d39c68c8 100644 --- a/web/lib/generated/setup-skills.ts +++ b/web/lib/generated/setup-skills.ts @@ -1,11 +1,11 @@ // Generated by web/scripts/generate-setup-skills.mjs from the prpm-installed // @agent-relay setup skills pinned in prpm.lock. Do not edit. -export const SETUP_SKILLS_SOURCE = {"registry":"prpm","packages":{"signing-in-to-agent-relay-cloud":"0.1.0","setting-up-agent-relay-desktop":"1.1.7","setting-up-agent-relay-sessions":"0.2.6","setting-up-agent-relay-flows":"0.1.0","writing-relayflows":"1.6.2"}} as const; +export const SETUP_SKILLS_SOURCE = {"registry":"prpm","packages":{"signing-in-to-agent-relay-cloud":"0.1.0","setting-up-agent-relay-desktop":"1.1.9","setting-up-agent-relay-sessions":"0.2.8","setting-up-agent-relay-flows":"0.1.0","writing-relayflows":"1.6.2"}} as const; export const SETUP_SKILLS: Readonly> = { "signing-in-to-agent-relay-cloud": "# Sign In to Agent Relay Cloud\n\nUse the existing OAuth device flow. No API key, invitation, dashboard wizard,\nor pre-existing Agent Relay account is required. Only the human approves the\nsign-in; never approve access on their behalf, ask for their password, or\noperate the approval page yourself.\n\nPaths below are relative to the Cloud API base, `https://agentrelay.com/cloud`,\nincluding its `/cloud` prefix. When a guide names a different Cloud base (for\nexample a local development stack), use that exact base throughout and never\nfall back to production.\n\n## HTTP client\n\nUse `curl` or `fetch`. Avoid Python's `urllib` or `requests` with their\ndefault User-Agent: the device-token endpoint answers them with HTTP 403 and no\nJSON body (tracked in AgentWorkforce/cloud#4254). Never print a raw auth\nresponse while debugging; on success it contains tokens. Send JSON request\nbodies with `Content-Type: application/json`, set a 30-second request timeout,\nand check every response status before proceeding.\n\n## 1. Start the device grant\n\n`POST /api/v1/auth/device/start` with:\n\n```json\n{\"client_name\":\"My agent setup\"}\n```\n\nAn agent-driven signup guide also passes its product as `signup_source`\n(`\"teams\"` or `\"flows\"`); keep the value the guide names. Omit it otherwise.\n\nExpect HTTP 201 with `device_code`, `user_code`, `verification_uri_complete`,\n`verification_uri`, `interval` (seconds), and `expires_in` (seconds). Keep\n`device_code` private. Open `verification_uri_complete` in the person's\nbrowser with the OS URL opener (for example macOS `open`, with the URL passed as\na separate argument, never interpolated into shell code), or give them the link\nif no opener is available. Show the `user_code` so they can compare it. The page\nlets them sign in with Google, review the requesting device, and Approve or\nDeny. A signup marker in the returned URL creates the right account type;\npreserve it through sign-in. Do not call `/auth/device/approve` yourself.\n\nFor a fresh signup, you can open `/api/auth/google/start?next=`\non the same Cloud base first (in production,\n`https://agentrelay.com/cloud/api/auth/google/start?next=`), where `encoded-return-path` is the URL-encoded pathname plus query of\n`verification_uri_complete`. This opens Google immediately and returns to the\nsame device approval with its code and signup marker intact. Never open the\nproduction URL for a non-production base: that would sign the human in to\nproduction instead.\n\n## 2. Poll for tokens\n\nWhile the browser is open, wait `interval` seconds between POSTs to\n`/api/v1/auth/device/token` with this JSON (substitute the private\n`device_code`):\n\n```json\n{\"grant_type\":\"urn:ietf:params:oauth:grant-type:device_code\",\"device_code\":\"\"}\n```\n\n- `authorization_pending`: keep waiting; respect a returned interval.\n- `slow_down`: increase the interval by at least 5 seconds.\n- HTTP 429: respect `Retry-After` and increase the interval.\n- HTTP 5xx or request timeout: retry with backoff, bounded by `expires_in`.\n- `access_denied`: stop. `expired_token` or `invalid_grant`: explain and start\n a new grant only if the person still wants to continue. Never poll past\n expiry.\n\nHTTP 200 returns `access_token`, `refresh_token`, `access_token_expires_at`,\n`refresh_token_expires_at`, `api_url` and `token_type`. Keep credentials in\nmemory or a private file (directory 0700, file 0600) outside repositories.\nNever echo tokens, put them in chat or URLs, or dump full authentication\nresponses. Use `Authorization: Bearer ` for subsequent Cloud\nrequests. Reject an `api_url` pointing at another origin; keep using the Cloud\nbase above.\n\n## 3. Refresh before expiry\n\nBefore expiry, `POST /api/v1/auth/token/refresh` with\n`{\"refreshToken\":\"\"}`. The response uses camelCase:\n`accessToken`, `refreshToken`, `accessTokenExpiresAt`, `refreshTokenExpiresAt`,\n`apiUrl`. Replace both stored tokens atomically. Serialize refreshes: the\nrefresh token rotates and must not be shared between machines. An\ninvalid/expired refresh requires a new device login, not an endless retry.\n\n## 4. Read the account and workspace\n\n`GET /api/v1/auth/whoami`. Require `authenticated: true` and read `user.id`,\n`user.email`, `currentWorkspace.id`, and `currentOrganization.id`. New signups\ncreate a workspace automatically. Reuse it; do not create duplicate\naccounts/workspaces. If `currentWorkspace` is missing, or an existing account\nis in the wrong workspace, resolve that with the person before connecting or\nactivating anything. Never silently replace an existing connection to another\naccount.\n\n## 5. Hand the session to the official CLI\n\nWhen running a child process, pass these through its environment from your\nprivate session object (never interpolate their values into logged commands):\n\n```text\nCLOUD_API_URL=\nCLOUD_API_ACCESS_TOKEN=\nCLOUD_API_REFRESH_TOKEN=\nCLOUD_API_ACCESS_TOKEN_EXPIRES_AT=\nCLOUD_API_REFRESH_TOKEN_EXPIRES_AT=\n```\n\nThe official CLI consumes this session. Refresh the parent session before\nstarting a long command; do not concurrently refresh it from parent and child\nprocesses. Do not overwrite an existing CLI auth file or copy a session to\nanother machine. Delete temporary authentication files after the work is\ncomplete.\n", - "setting-up-agent-relay-desktop": "# Set Up Agent Relay Desktop\n\nComplete the setup through the private session socket. Do not ask the human to\nclick through the desktop app. The only normal human step is approving one\ndevice-login link when no reusable `agent-relay` CLI login exists.\n\nPrefer setting this up with the Agent Relay agent at\n, which checks your current state and confirms\neach step. Fetch its instructions with\n`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`.\n\nThe finished state is:\n\n- the current release is installed and running;\n- sign-in is complete;\n- sharing mode is `new` (new sessions upload automatically), unless an existing\n install already uses another mode and the human chose to keep it (section 4);\n- auto-activate is on, with every existing live session and every future session on the relay;\n- Claude Code direct delivery is on (`crossSessionInbound` is `accept`),\n unless the human already set `crossSessionInbound` themselves (for example\n `hold`), which is kept and reported;\n- this Codex or Claude session is registered, and accepts direct delivery\n unless that kept choice holds or refuses Claude Code's relayed messages;\n- the requested webhook and integration subscriptions work;\n- the human receives the session's `agent@machine` address and verification evidence.\n\nNever put a Cloud token, workspace key, agent token, or webhook secret in a\nRelay message. The webhook secret is returned once to this local calling\nsession; keep it in a mode-0600 temporary file and delete the file after the\ntest.\n\n## Install this skill for Codex and Claude Code\n\nWhen the task includes installing this skill, install its canonical Claude\npackage, then place that unchanged `SKILL.md` where Codex actually loads global\nskills:\n\n```sh\nnpx prpm install @agent-relay/setting-up-agent-relay-desktop --as claude --global -y\ncodex_skill=\"$HOME/.codex/skills/setting-up-agent-relay-desktop\"\nmkdir -p \"$codex_skill\"\ninstall -m 0644 \\\n \"$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md\" \\\n \"$codex_skill/SKILL.md\"\ncmp \\\n \"$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md\" \\\n \"$codex_skill/SKILL.md\"\n```\n\nDo not use prpm's `--as codex` conversion for this package. It currently writes\nunder `~/.agents/skills`, which Codex does not load as its global skill folder,\nand its conversion drops explanatory paragraphs from this Markdown workflow.\nConfirm both final paths explicitly before continuing.\n\n## 1. Detect the host and current session\n\nRun setup commands from this agent's own shell tool. Do not run them from an\nunrelated terminal: the app identifies the caller from kernel credentials and\nthe Codex/Claude process ancestry.\n\n```sh\nuname -s\nuname -m\ntest -n \"${TMUX:-}\" && printf 'session=tmux\\n' || true\ntest -n \"${SSH_CONNECTION:-}\" && printf 'transport=ssh\\n' || true\nif test \"$(uname -s)\" = Linux; then\n if command -v dpkg-query >/dev/null; then\n printf 'linux_install=deb\\n'\n else\n printf 'linux_install=per-user-tarball\\n'\n fi\nfi\ncommand -v systemctl >/dev/null && systemctl --user is-system-running || true\n```\n\nOn Ubuntu with `dpkg`, prefer the `.deb` path below. On Arch-based and other\nsystemd Linux hosts without `dpkg`, use the checksum-verified per-user tarball\npath; do not try to install the `.deb`. The app scans same-user process trees\nand TTYs; it does not require the service itself to share the tmux session.\n\n`systemctl --user is-system-running` may report `degraded` because an unrelated\nuser unit failed. That is not a Relay failure when\n`systemctl --user is-active agent-relay.service` reports `active` and the Relay\nsocket passes its status check.\n\nFind an existing socket without printing private files:\n\n```sh\npointer=\"$HOME/.agentworkforce/desktop/relay-socket\"\nif test -r \"$pointer\"; then relay_socket=$(sed -n '1p' \"$pointer\"); fi\nif test -n \"${relay_socket:-}\" && ! test -S \"$relay_socket\"; then unset relay_socket; fi\nif test -z \"${relay_socket:-}\" && test -n \"${XDG_RUNTIME_DIR:-}\"; then\n relay_socket=\"$XDG_RUNTIME_DIR/agent-relay/relay.sock\"\nfi\nif ! test -S \"${relay_socket:-/nonexistent}\" && test \"$(uname -s)\" = Linux; then\n relay_socket=\"/run/user/$(id -u)/agent-relay/relay.sock\"\nfi\nc=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\nif ! test -S \"${relay_socket:-/nonexistent}\" && test \"$(uname -s)\" = Darwin && test -S \"$c\"; then # pointer file missing: use the app's own socket\n relay_socket=$c\nfi\ntest -S \"${relay_socket:-/nonexistent}\" && printf 'socket=%s\\n' \"$relay_socket\"\n```\n\nIf the socket exists, inspect it before installing anything:\n\n```sh\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | jq\n```\n\n**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can\ntake about 30 seconds on a busy Desktop (tracked in\nAgentWorkforce/relay-desktop#333). Give each socket call a 60-second timeout\n(`curl --max-time 60`), and do not treat a slow reply as a failure. The probe's\n`relay socket-request` can give up sooner with \"Probe could not finish. Check\nyour connection and run setup again\"; on these paths that is the same latency,\nnot a setup problem. Retry the call once before reporting a blocker, and do not\nstart a second install or sign-in because of it.\n\nPreserve a working newer install. When the socket is absent, check for an\ninstalled-but-stopped copy before downloading: use `dpkg-query -W agent-relay`\nwhen `dpkg-query` exists and `systemctl --user status agent-relay.service` on\nLinux, or test `/Applications/Agent Relay.app` on macOS. Start it and retry\nstatus first.\nUpdate an older install only when it lacks the setup contract or reports an\nolder version. A `404` from `/setup/status` means the installed build is too\nold for agent-driven setup and should be updated.\n\n## 2. Install or update with checksum verification\n\nUse a private temporary directory. Never skip the published SHA-256 check.\n\n### Ubuntu LTS, headless `.deb` (preferred)\n\nSelect the release architecture and install:\n\nThis path needs passwordless sudo or one human-approved sudo prompt for the\npackage install and `loginctl enable-linger`. Explain that prerequisite before\nstarting; never capture or relay a sudo password.\n\n```sh\ncase \"$(uname -m)\" in\n x86_64|amd64) relay_arch=x64 ;;\n aarch64|arm64) relay_arch=arm64 ;;\n *) printf 'Unsupported Linux architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.deb\" -o \"$relay_tmp/AgentRelay.deb\"\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.deb.sha256\" -o \"$relay_tmp/AgentRelay.deb.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-Linux-$relay_arch.deb#AgentRelay.deb#\" AgentRelay.deb.sha256 | sha256sum --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nsudo apt-get install -y \"$relay_tmp/AgentRelay.deb\"\nsudo loginctl enable-linger \"$USER\"\nsystemctl --user daemon-reload\nsystemctl --user enable agent-relay.service\nsystemctl --user restart agent-relay.service\nsystemctl --user is-active agent-relay.service\nloginctl show-user \"$USER\" -p Linger\n```\n\nThe unit runs the bundled core headless: no display, X server, or GTK\nwindow is needed. This session's own GitHub subscriptions (section 6) go\nthrough the Desktop and need no CLI. The separate Agent Relay CLI is needed\nonly to subscribe a fleet-spawned or named agent; record whether it is present\nand at least 13.0.0. Find the same user-level npm, mise, or nvm CLI the app\ndiscovers; never run the desktop's `/usr/bin/agent-relay` launcher as a CLI:\n\n```sh\nrelay_cli=\nfor candidate in \\\n \"$HOME/.local/bin/agent-relay\" \\\n \"$HOME/.npm-global/bin/agent-relay\" \\\n \"$HOME/.agentworkforce/relay/bin/agent-relay\"\ndo\n if test -x \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n relay_cli=$candidate\n break\n fi\ndone\nif test -z \"$relay_cli\"; then\n for relay_root in \\\n \"$HOME/.local/share/mise/installs/node\" \\\n \"$HOME/.nvm/versions/node\"\n do\n test -d \"$relay_root\" || continue\n candidate=$(find -L \"$relay_root\" -mindepth 3 -maxdepth 3 \\\n -path '*/bin/agent-relay' -type f -perm -u+x -print -quit 2>/dev/null)\n if test -n \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n relay_cli=$candidate\n break\n fi\n done\nfi\nif test -n \"$relay_cli\"; then\n \"$relay_cli\" --version\n \"$relay_cli\" integration subscribe --help >/dev/null\nelse\n printf 'No Agent Relay CLI found; Desktop subscriptions do not need one.\\n'\nfi\n```\n\nOn Linux without `dpkg`, use this per-user tarball installation under a\nversioned user directory. It is also the fallback on Ubuntu when `.deb`\ninstallation is unavailable and the human approves a per-user install. The\narchive contains a unit whose `/usr/bin` path is correct for packages, so\nrewrite only that `ExecStart` in the user copy:\n\n```sh\ncase \"$(uname -m)\" in\n x86_64|amd64) relay_arch=x64 ;;\n aarch64|arm64) relay_arch=arm64 ;;\n *) printf 'Unsupported Linux architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.tar.gz\" -o \"$relay_tmp/AgentRelay.tar.gz\"\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.tar.gz.sha256\" -o \"$relay_tmp/AgentRelay.tar.gz.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-Linux-$relay_arch.tar.gz#AgentRelay.tar.gz#\" AgentRelay.tar.gz.sha256 | sha256sum --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nmkdir \"$relay_tmp/extracted\"\ntar -xzf \"$relay_tmp/AgentRelay.tar.gz\" -C \"$relay_tmp/extracted\"\nrelay_tree=\"$relay_tmp/extracted/AgentRelay-linux-$relay_arch\"\ntest -x \"$relay_tree/usr/bin/agent-relay\"\nrelay_version=$(sed -n 's/^__version__ = \"\\([0-9A-Za-z._-]*\\)\"$/\\1/p' \\\n \"$relay_tree/usr/lib/agent-relay/agent_relay/__init__.py\")\ncase \"$relay_version\" in\n ''|*[!0-9A-Za-z._-]*) printf 'Invalid Agent Relay version in archive.\\n' >&2; exit 1 ;;\nesac\nrelay_unit=\"$HOME/.config/systemd/user/agent-relay.service\"\nrelay_installed_version=\nif test -S \"${relay_socket:-/nonexistent}\"; then\n relay_installed_version=$(curl -fsS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status 2>/dev/null | jq -r '.data.version // empty' || true)\nfi\nif test -z \"$relay_installed_version\" && test -r \"$relay_unit\"; then\n relay_installed_version=$(sed -n \\\n 's#^ExecStart=.*/\\.local/lib/agent-relay/\\([^/ ]*\\)/.*#\\1#p' \\\n \"$relay_unit\")\nfi\ncase \"$relay_installed_version\" in\n ''|*[!0-9A-Za-z._-]*) relay_installed_version= ;;\nesac\nif test -n \"$relay_installed_version\" && \\\n test \"$relay_installed_version\" != \"$relay_version\" && \\\n test \"$(printf '%s\\n%s\\n' \"$relay_version\" \"$relay_installed_version\" | sort -V | tail -n 1)\" = \"$relay_installed_version\"; then\n printf 'Installed Agent Relay %s is newer than archive %s; refusing downgrade.\\n' \\\n \"$relay_installed_version\" \"$relay_version\" >&2\n exit 1\nfi\nrelay_prefix=\"$HOME/.local/lib/agent-relay/$relay_version\"\nmkdir -p \"$relay_prefix\"\ncp -a \"$relay_tree/usr\" \"$relay_prefix/\"\nmkdir -p \"$(dirname \"$relay_unit\")\"\nsed \"s#^ExecStart=/usr/#ExecStart=$relay_prefix/usr/#\" \\\n \"$relay_tree/usr/lib/systemd/user/agent-relay.service\" >\"$relay_unit.tmp\"\nchmod 0644 \"$relay_unit.tmp\"\nmv \"$relay_unit.tmp\" \"$relay_unit\"\ngrep -F \"ExecStart=$relay_prefix/usr/\" \"$relay_unit\" | grep -q -- ' --headless$'\nif ! loginctl show-user \"$USER\" -p Linger | grep -qx 'Linger=yes'; then\n sudo loginctl enable-linger \"$USER\"\nfi\nsystemd-analyze --user verify \"$relay_unit\"\nsystemctl --user daemon-reload\nsystemctl --user enable agent-relay.service\nsystemctl --user restart agent-relay.service\nsystemctl --user is-active agent-relay.service\nloginctl show-user \"$USER\" -p Linger\n# List the app in the desktop's app launcher (no-op on a headless server).\n# Releases before v2026.10.13 lack agent-relay-desktop; the app then adds the\n# entry the first time it is opened.\nif test -x \"$relay_prefix/usr/bin/agent-relay-desktop\"; then\n \"$relay_prefix/usr/bin/agent-relay-desktop\" --install-launcher || true\nfi\n```\n\nThe unit's `ExecStart` names the archive's own path under `/usr/`, either the\nlauncher or the bundled `agent-relay-probe`; the rewrite moves whichever it is\nunder `$relay_prefix`. The launcher entry runs `agent-relay-desktop` by its\nfull path, never the bare `agent-relay`, which is also the CLI's name.\n\nDo not improvise Alpine or RHEL distribution packages. On a compatible\nsystemd host without `dpkg`, use only the verified per-user tarball path above;\nnon-systemd hosts remain unsupported by this Linux workflow.\n\n### macOS DMG\n\n```sh\ncase \"$(uname -m)\" in\n arm64) relay_arch=arm64 ;;\n x86_64) relay_arch=x64 ;;\n *) printf 'Unsupported Mac architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-macOS-$relay_arch.dmg\" -o \"$relay_tmp/AgentRelay.dmg\"\ncurl -fL \"$release/AgentRelay-macOS-$relay_arch.dmg.sha256\" -o \"$relay_tmp/AgentRelay.dmg.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-macOS-$relay_arch.dmg#AgentRelay.dmg#\" AgentRelay.dmg.sha256 | shasum -a 256 --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nmount_point=$(hdiutil attach -nobrowse -readonly \"$relay_tmp/AgentRelay.dmg\" | awk '/\\/Volumes\\// {sub(/^.*\\/Volumes\\//,\"/Volumes/\"); print; exit}')\nincoming_id=$(defaults read \"$mount_point/Agent Relay.app/Contents/Info\" CFBundleIdentifier 2>/dev/null || true)\nif test \"$incoming_id\" != com.agentrelay.desktop; then\n hdiutil detach \"$mount_point\" >/dev/null 2>&1 || true\n printf 'Downloaded app has unexpected bundle id %s; refusing installation.\\n' \"$incoming_id\" >&2\n exit 1\nfi\nif test -d \"/Applications/Agent Relay.app\"; then\n existing_id=$(defaults read \"/Applications/Agent Relay.app/Contents/Info\" CFBundleIdentifier 2>/dev/null || true)\n if test \"$existing_id\" != com.agentrelay.desktop; then\n hdiutil detach \"$mount_point\" >/dev/null 2>&1 || true\n printf 'Existing Agent Relay.app has unexpected bundle id %s; refusing overwrite.\\n' \"$existing_id\" >&2\n exit 1\n fi\nfi\nosascript -e 'tell application \"Agent Relay\" to quit' 2>/dev/null || true\nditto \"$mount_point/Agent Relay.app\" \"/Applications/Agent Relay.app\"\nhdiutil detach \"$mount_point\"\nopen -a \"Agent Relay\"\n```\n\nIf `/Applications` needs administrator access, use `sudo ditto` only after the\nhuman has authorized installation. Do not remove or overwrite another app with\nan unexpected bundle identity.\n\nAfter either install, wait up to 30 seconds for the pointer:\n\n```sh\napp_sock=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\nfor _ in $(seq 1 30); do\n test -r \"$HOME/.agentworkforce/desktop/relay-socket\" && break\n test \"$(uname -s)\" = Darwin && test -S \"$app_sock\" && break # the app is already listening\n sleep 1\ndone\nrelay_socket=$(sed -n '1p' \"$HOME/.agentworkforce/desktop/relay-socket\" 2>/dev/null)\nif ! test -S \"$relay_socket\" && test \"$(uname -s)\" = Darwin && test -S \"$app_sock\"; then\n relay_socket=$app_sock\nfi\nif ! test -S \"$relay_socket\" && test \"$(uname -s)\" = Linux; then\n relay_socket=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/agent-relay/relay.sock\"\nfi\ntest -S \"$relay_socket\"\ncurl -fsS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | jq -e '.ok and (.data.version | length > 0)'\n```\n\n## 3. Sign in by device approval\n\nBefore choosing a workspace, check what the human already has. These commands\nonly read state. The CLI checks run only when section 2 found a `relay_cli`;\nnever call the `.deb`'s `/usr/bin/agent-relay` launcher for them:\n\n```sh\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | \\\n jq '{sign_in: .data.sign_in, workspace: .data.workspace}'\nif test -n \"${relay_cli:-}\"; then\n \"$relay_cli\" cloud whoami # signed in, and as whom\n \"$relay_cli\" workspace active # the active Cloud workspace (keys stay masked)\n \"$relay_cli\" cloud workspaces # every workspace this login can use, with ids\n \"$relay_cli\" status # workspace, cloud login and local broker\nfi\n```\n\nSign in to the workspace they already use. Pick a different or new workspace\nonly when the human explicitly asks for it.\n\nFor a multi-server rollout, obtain the existing enterprise workspace UUID from\nthe human and pass it explicitly on every server. Do not guess it and do not\nuse `{}` when the target workspace is known: that could select a default/free\nworkspace and split the fleet. Use `{}` only for an individual setup where the\nhuman has confirmed that the reusable login's current workspace is intended.\n\n```sh\nrelay_workspace_uuid='HUMAN-PROVIDED-WORKSPACE-UUID'\nsign_in_payload=$(jq -nc --arg workspace \"$relay_workspace_uuid\" '{workspace:$workspace}')\nsign_in=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d \"$sign_in_payload\" http://relay/setup/sign-in)\nprintf '%s\\n' \"$sign_in\" | jq \\\n '{ok, data: {\n status: .data.status,\n reused_login: .data.reused_login,\n workspace: .data.workspace\n }, error}'\n```\n\nIf sign-in reports that older upload schedules could not be inspected, first\ninspect the user's legacy cron, launchd, and user-service upload schedules.\nAfter completing that safety check, retry the original request with the\nacknowledgement added; never use it merely to suppress the error:\n\n```sh\nsign_in_payload=$(printf '%s\\n' \"$sign_in_payload\" | \\\n jq '. + {acknowledge_uninspected_schedules:true}')\nsign_in=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d \"$sign_in_payload\" http://relay/setup/sign-in)\nprintf '%s\\n' \"$sign_in\" | jq \\\n '{ok, data: {\n status: .data.status,\n reused_login: .data.reused_login,\n workspace: .data.workspace\n }, error}'\n```\n\nPrint a device link and code only when the response actually contains a code:\n\n```sh\nuser_code=$(printf '%s\\n' \"$sign_in\" | jq -r '.data.user_code // empty')\nif test -n \"$user_code\"; then\n verification_url_complete=$(printf '%s\\n' \"$sign_in\" | \\\n jq -r '.data.verification_url_complete // empty')\n if test -n \"$verification_url_complete\"; then\n printf 'Open %s and approve code %s\\n' \"$verification_url_complete\" \"$user_code\"\n else\n verification_url=$(printf '%s\\n' \"$sign_in\" | \\\n jq -r '.data.verification_url // empty')\n printf 'Open %s and enter code %s\\n' \"$verification_url\" \"$user_code\"\n fi\nfi\n```\n\nIf the response is `signed_in` with `reused_login:true`, the app reused an\neligible same-origin CLI or desktop login and intentionally issued no device\ncode. Confirm the returned workspace id and name with the human rather than\nwaiting for a code, then continue only if it is the intended workspace.\n\nFor `pending_approval`, the human can approve from any phone or browser. Do not\ncreate another code while this one is pending. A `preparing` response is also\nnormal: poll `/setup/status` at the reported interval. Preparing local history\ncan take about a minute when the app reuses an existing `agent-relay` CLI login.\nPoll both states until sign-in finishes. The reason for a failure is in\n`.data.sign_in_message`, not in `.data.sign_in` (which only says `error`), so\nprint it, and map the known messages to their fix instead of dumping raw state:\n\n```sh\nwhile :; do\n state=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status)\n phase=$(printf '%s' \"$state\" | jq -r '.data.sign_in // .error.code')\n case \"$phase\" in\n signed_in)\n printf '%s\\n' \"$state\" | jq \\\n '{sign_in: .data.sign_in, workspace: .data.workspace}'\n break\n ;;\n preparing|pending_approval) ;;\n denied|expired|error)\n msg=$(printf '%s' \"$state\" | jq -r '.data.sign_in_message // .error.message // \"no message reported\"')\n printf 'Sign-in %s: %s\\n' \"$phase\" \"$msg\" >&2\n case \"$msg\" in\n *\"older managed history uploader\"*)\n printf 'See \"Sign-in blocked by a legacy uploader\" under Recovery.\\n' >&2 ;;\n *\"older upload schedules\"*)\n printf 'Inspect legacy schedules, then retry with acknowledge_uninspected_schedules (see above).\\n' >&2 ;;\n *) printf '%s\\n' \"$state\" | jq '.data' >&2 ;;\n esac\n exit 1\n ;;\n esac\n sleep \"$(printf '%s' \"$sign_in\" | jq -r '.data.interval // 5')\"\ndone\n```\n\n## 4. Apply the agent-led defaults and register this session\n\nRead the current sharing mode before changing it. `/setup/status` reports it as\n`.data.sharing_mode` (`new`, `all`, or `selected`; `new` when nothing is\nuploading yet). The probe reports the same field, `sharing_mode`, in\n`agent-relay-probe status --account --workspace --json`.\n\n```sh\nrelay_prior_mode=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status | jq -r '.data.sharing_mode // empty')\ncase \"$relay_prior_mode\" in\n new|all|selected) printf 'sharing_mode=%s\\n' \"$relay_prior_mode\" ;;\n *) printf 'Could not read the sharing mode; ask the human before changing sharing.\\n' >&2\n exit 1 ;;\nesac\n```\n\nContinue only with a verified `new`, `all`, or `selected`. If the status read\nfails or reports no mode, retry once; if it is still unknown, stop and ask the\nhuman how to proceed rather than sending any sharing request.\n\nOn a fresh install, or when it already reports `new`, keep `new`. When an\nexisting install reports `all` (which also uploads past sessions, so it is\nbroader than this skill's default) or `selected`, tell the human the current\nmode and ask whether to keep it or change it to `new`. Never change it silently.\nOnly when the human explicitly chooses a different mode, set\n`relay_sharing_mode` to that answer at the top of the next block; left unset,\nthe block keeps the mode the app reports, so nothing changes without an answer.\nRemember the chosen mode: section 8 checks it again, and shell variables do not\nsurvive between separate tool calls.\n\nThen set and verify all three agent-led defaults even when the app's `/setup/*`\nbootstrap already applied them. Sharing mode `new` uploads every new session;\nauto-activation puts every existing live session and every future session on\nthe relay; direct delivery sets Claude Code `crossSessionInbound` to `accept`\nby default, the same default the app's first-run setup applies.\n\nThe default never overrides a value the human set: `{\"default\":true}` writes\n`accept` only when `~/.claude/settings.json` does not contain the\n`crossSessionInbound` key, and answers the human's own value as `user_choice`.\nA present key with any value (`hold`, `refuse`) is an explicit choice: keep it,\nreport it, and continue setup. Only POST `{\"enabled\":true}` over it when the\nhuman asks for direct delivery after being told. A build older than this\ndefault answers `invalid_enabled`; there the skill reads the settings file\nitself and POSTs `{\"enabled\":true}` only after proving the key is absent (no\nfile, or exactly one JSON object without the key) or already `accept`, so an\nolder build keeps an explicit choice too. A file that exists but cannot be read or parsed (blank,\nbroken, not one object) proves nothing, and neither does a value other than\n`hold`, `refuse` or `accept` (even `\"\"`): stop and ask the human rather than\nwriting over it. Only `hold` and `refuse` are kept choices this skill acts on:\n\n```sh\n# Unset keeps the current mode; set it only to the human's explicit answer.\nif test -z \"${relay_sharing_mode:-}\"; then\n relay_sharing_mode=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status | jq -r '.data.sharing_mode // empty')\nfi\ncase \"$relay_sharing_mode\" in\n new|all|selected) ;;\n *) printf 'Sharing mode unknown; ask the human before changing sharing.\\n' >&2\n exit 1 ;;\nesac\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). setting-up-agent-relay-sessions\n# section 2 mirrors this helper; change both together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nfile_choice=$(claude_inbound_choice)\ndirect_delivery=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"default\":true}' http://relay/setup/direct-delivery)\nif printf '%s\\n' \"$direct_delivery\" | jq -e '.error.code == \"invalid_enabled\"' >/dev/null; then\n case \"$file_choice\" in\n absent|present:accept)\n direct_delivery=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"enabled\":true}' http://relay/setup/direct-delivery) ;;\n present:hold|present:refuse)\n direct_delivery=$(jq -nc --arg choice \"${file_choice#present:}\" \\\n '{ok: true, data: {direct_delivery: false, user_choice: $choice}}') ;;\n *)\n printf 'Claude Code settings are unreadable or name crossSessionInbound as something other than hold, refuse or accept (%s), so delivery was left unchanged; ask the human.\\n' \\\n \"$file_choice\" >&2\n exit 1 ;;\n esac\nfi\nprintf '%s\\n' \"$direct_delivery\" | jq\nkept_choice=$(printf '%s\\n' \"$direct_delivery\" | \\\n jq -r 'select(.ok and .data.direct_delivery == false) | .data.user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\nif printf '%s\\n' \"$direct_delivery\" | jq -e '.ok and .data.direct_delivery == false and\n .data.user_choice != null and (.data.user_choice | IN(\"hold\", \"refuse\", \"accept\") | not)' >/dev/null; then\n printf 'Your crossSessionInbound is set to an unrecognized value, so delivery was left unchanged; ask the human.\\n' >&2\n exit 1\nfi\nif [ -n \"$kept_choice\" ]; then\n case \"$kept_choice\" in\n hold) effect='relayed messages to Claude Code wait for your approval' ;;\n refuse) effect='relayed messages to Claude Code are dropped' ;;\n esac\n printf 'Kept your own crossSessionInbound value \"%s\": %s.\\n' \"$kept_choice\" \"$effect\" >&2\nelif ! printf '%s\\n' \"$direct_delivery\" | jq -e '.ok and .data.direct_delivery'; then\n if printf '%s\\n' \"$direct_delivery\" | jq -e '.error.code == \"managed_policy\"' >/dev/null; then\n printf 'Organization-managed Claude settings forbid direct delivery.\\n' >&2\n fi\n exit 1\nfi\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d \"$(jq -nc --arg mode \"$relay_sharing_mode\" '{mode:$mode}')\" \\\n http://relay/setup/sharing | jq\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"enabled\":true}' \\\n http://relay/setup/auto-activate | jq\nregister=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d '{}' http://relay/register)\nprintf '%s\\n' \"$register\" | jq\nregistration_status=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status)\nprintf '%s\\n' \"$registration_status\" | \\\n jq '{session: .data.session, direct_delivery: .data.direct_delivery, error}'\nprintf '%s\\n' \"$registration_status\" | jq -e --arg kept \"$kept_choice\" \\\n '.ok and (.data.session.id | type == \"string\" and length > 0) and\n .data.session.registered == true and\n (.data.session.direct_delivery == true or\n (.data.session.direct_delivery == false and $kept != \"\"))'\n```\n\n**Codex on macOS.** The system `curl` hides a Codex session's identity from the\nsocket there, so `/register` is refused `not_a_relay_session` (section 6 says\nthe same for subscriptions). In a Codex session on macOS, find `relay_probe`\nwith section 6's first block and send the registration through it instead of\nthe `curl` line above, then read `/setup/status` as above:\n\n```sh\nregister=$(printf '{}' | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method POST --path /register)\n```\n\nIf `/setup/sign-in` is refused the same way, send its payload through the probe\nthe same way. Without a probe, stop and report it rather than registering\nanother process.\n\nDirect delivery is checked first so a managed-policy refusal stops setup\nbefore auto-activation or upload settings are changed. Report that refusal\nclearly; never claim the three-default setup completed. A kept explicit\nchoice is not a refusal: report it as \"direct delivery kept off at your\n`crossSessionInbound` value\" (with `hold`, relayed messages wait for the\nhuman's approval; with `refuse`, they are dropped) and finish the rest of\nsetup.\n\nSharing mode and auto-activate are different settings: the former\ncontrols upload eligibility and the latter controls Relay registration. The\nsetup flow requires both.\n\n`POST /register` discovers the nearest live Codex or Claude ancestor. A session\ninside tmux over SSH is valid. Never pass a session id or token in the body.\nThe default name includes a 128-bit session-derived suffix, so dozens of\nsessions in the same checkout receive distinct `name@direct` addresses. Save\nand report the address from `/setup/status` for each session; never reuse one\nsession's address for another. `/register` may omit the session id or\ndirect-delivery state, so its response alone is not verification. Require the\nstatus response to show this session's non-empty id, `registered:true`, and\n`session.direct_delivery:true`, or `session.direct_delivery:false` exactly\nwhen the human kept their own `crossSessionInbound` (a missing field is never\nsuccess), as above. Do not override a kept choice to satisfy this check; only\nPOST `{\"enabled\":true}` when the human says yes after being told. An app fix is expected to add those\nfields to `/register`, but the status check remains authoritative.\n\n## 5. Create and test the webhook\n\n**When a kept choice holds this session's messages.** If `/setup/status`\nshows `session.direct_delivery:false` because the human kept their own\n`crossSessionInbound` (a Claude Code session; Codex is unaffected), the\ninjection checks in sections 5–7 cannot complete by themselves. With `hold`,\nthe test message arrives as a held message: ask the human to approve it, then\nconfirm the marker and message id as below. With `refuse`, it is dropped:\ncreate the webhook or subscription if asked, but report its injection test as\nunverified because of the kept choice, rather than waiting for a marker that\ncannot arrive.\n\nPerform this section only when the human requested a webhook (or explicitly\nasked for the full end-to-end setup). Otherwise skip it; webhook creation is a\npersistent external mutation, not an install prerequisite.\n\nCapture the one-time secret without echoing it:\n\n```sh\numask 077\nhook_file=$(mktemp)\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{}' http://relay/webhooks >\"$hook_file\"\njq '{ok, created: .data.created, webhook_id: .data.webhook_id, url: .data.url, error}' \"$hook_file\"\n```\n\nWhen `created:true`, send a unique marker. Initialize every temporary path so\ncleanup can name and delete exact files without `-f` or globs:\n\n```sh\nhook_config=\nmarker=\nwebhook_post_ok=false\nwebhook_post_status=0\nif test \"$(jq -r '.data.created // false' \"$hook_file\")\" = true && \\\n test \"$(jq -r '.data.secret // empty' \"$hook_file\")\" != ''; then\n hook_url=$(jq -r '.data.url' \"$hook_file\")\n hook_secret=$(jq -r '.data.secret' \"$hook_file\")\n hook_config=$(mktemp)\n chmod 600 \"$hook_config\"\n printf 'header = \"Authorization: Bearer %s\"\\n' \"$hook_secret\" >\"$hook_config\"\n marker=\"agent-relay-webhook-test-$(date +%s)\"\n if curl -fsS --config \"$hook_config\" -X POST \"$hook_url\" \\\n -H 'Content-Type: application/json' \\\n -d \"{\\\"text\\\":\\\"$marker\\\",\\\"source\\\":\\\"agent-driven-setup\\\"}\"; then\n webhook_post_ok=true\n else\n webhook_post_status=$?\n fi\nelse\n printf 'Webhook already exists; its original secret is required to repeat delivery verification.\\n' >&2\nfi\nif test -n \"$hook_config\" && test -e \"$hook_config\"; then\n rm -- \"$hook_config\"\nfi\nif test -e \"$hook_file\"; then\n rm -- \"$hook_file\"\nfi\nunset hook_secret\nif test \"$webhook_post_ok\" = true; then\n printf 'Webhook test sent; end this turn and wait for marker: %s\\n' \"$marker\"\nelif test -n \"$marker\"; then\n printf 'Webhook test POST failed with curl status %s; do not wait for delivery.\\n' \\\n \"$webhook_post_status\" >&2\n exit \"$webhook_post_status\"\nfi\n```\n\nDo not claim success merely because the POST returned 2xx, and do not sleep or\npoll for the injected message during the same turn. Relay injects only after\nthe session becomes idle between turns. After the POST and exact-file cleanup,\nend the current turn by saying that setup is waiting for the exact marker. On\nthe next turn, confirm that the injected body contains that marker and its\nAgent Relay header contains a full message id; record that id in the final\nevidence. If a polling consumer is part of the setup, also observe that same id\nthere before declaring the webhook test complete. If the webhook already\nexisted, its secret is correctly returned as null; do not revoke or recreate it\nwithout human authorization. Ask for the existing secret or report that the\ndelivery test could not be repeated.\n\n## 6. Subscribe requested integrations\n\nRequire the provider and repository from the human. A GitHub pull request needs\none resource glob, its number (`pulls/NUMBER/**`). Relayfile matches the PR's\nreviews, review comments, conversation comments and check runs to that glob by\nthe PR they reference, so no other glob is needed: nothing is written under\n`pulls/NUMBER/reviews/**`, `pulls/NUMBER/status/**` or\n`issues/NUMBER/comments/**`, and subscriptions on those never deliver. This\nworks on every released Desktop and is the path to use today. If a review or\ncomment then never arrives, read it back in relayfile at its own path (the\ntable in `subscribing-relay-webhooks-and-writebacks`): a 404 means the event\nnever reached relayfile\n([relayfile-cloud#317](https://github.com/AgentWorkforce/relayfile-cloud/issues/317)),\nnot that the subscription is wrong.\n\nFirst find the Desktop's `agent-relay-probe`. macOS needs it for Codex sessions\n(below) and it is the client for the one-command form once that ships:\n\n```sh\nrelay_probe=\nfor candidate in \\\n \"$HOME/.local/bin/agent-relay-probe\" \\\n /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \\\n \"/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe\"\ndo\n if test -x \"$candidate\"; then relay_probe=$candidate; break; fi\ndone\nif test -z \"$relay_probe\" && test -d \"$HOME/.local/lib/agent-relay\"; then\n relay_probe=$(find \"$HOME/.local/lib/agent-relay\" -type f -perm -u+x \\\n -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null)\nfi\nprintf 'relay_probe=%s\\n' \"${relay_probe:-none}\"\n```\n\nA running Desktop keeps `~/.local/bin/agent-relay-probe` at its own version; the\nother candidates are the copies bundled by the `.deb`, the macOS app, and the\nper-user tarball. Then subscribe through the socket. On macOS use the probe\nclient when there is one: the system `curl` hides a Codex session's identity\nthere and is refused `not_a_relay_session`, so a Codex session with no probe\nstops instead. `curl` is correct for Claude Code and on Linux:\n\n```sh\n# One client for every socket request in this section and in undo: the probe\n# on macOS (system curl hides a Codex session there), curl elsewhere.\nrelay_req() { # METHOD PATH [JSON body]\n if test \"$(uname -s)\" = Darwin && test -n \"${relay_probe:-}\"; then\n printf '%s' \"${3:-}\" | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method \"$1\" --path \"$2\"\n elif test \"$(uname -s)\" = Darwin && test -n \"${CODEX_THREAD_ID:-}\"; then\n printf 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.\\n' >&2\n return 1\n elif test -n \"${3:-}\"; then\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \\\n -H 'Content-Type: application/json' -d \"$3\" \"http://relay$2\"\n else\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \"http://relay$2\"\n fi\n}\n\n# This PR's subscriptions, in whichever form they were made. Fails, rather than\n# answering \"none\", when the status cannot be read: both callers stop then.\npr_url='https://github.com/OWNER/REPO/pull/NUMBER'\npr_resources() {\n relay_status=$(relay_req GET /setup/status) || return 1\n # -s reads the whole body as one list, so an empty body is an error too.\n printf '%s\\n' \"$relay_status\" | jq -rs --arg url \"$pr_url\" \\\n --arg short 'OWNER/REPO#NUMBER' \\\n --arg pulls '/github/repos/OWNER/REPO/pulls/NUMBER/' \\\n --arg issues '/github/repos/OWNER/REPO/issues/NUMBER/' '\n if length == 1 and .[0].ok == true\n and (.[0].data.integrations | type) == \"array\" then\n .[0].data.integrations[] | select(.provider == \"github\") | .resource\n | select(. == $url or . == $short or startswith($url + \"/\")\n or startswith($pulls) or startswith($issues))\n else\n error(\"Agent Relay status is unreadable; no subscription was changed.\")\n end'\n}\n\nheld=$(pr_resources) || exit 1\nif printf '%s\\n' \"$held\" | grep -Fqx -e \"$pr_url\" -e 'OWNER/REPO#NUMBER'; then\n # The one-command subscription is a single complete request: nothing to add.\n printf 'Already subscribed:\\n%s\\n' \"$held\"\nelif test -z \"$held\" && test -n \"${relay_probe:-}\" && \\\n \"$relay_probe\" relay --help 2>&1 | grep -q '^ subscribe'; then\n # The one-command form (below), only for a PR with no subscription yet and\n # only when the installed probe has it.\n \"$relay_probe\" relay subscribe \"$pr_url\" | jq\nelse\n relay_req POST /integrations/subscribe \\\n \"$(jq -nc --arg resource '/github/repos/OWNER/REPO/pulls/NUMBER/**' \\\n '{provider:\"github\",resource:$resource}')\" | jq\nfi\nrelay_req GET /setup/status | jq -c '.data.integrations'\n```\n\nRequire `.ok` and `.data.subscribed` to be `true`, and the entry in the status\nlist to be `ready: true`. A repeated request returns HTTP 409\n`conflict` (\"already subscribed\"). That is success only for a binding made on\nor after 2026-09-19. Earlier bindings predate PR-identity matching\n(relayfile-cloud#237, relaycast#447), match paths literally and miss reviews\nand comments. If the binding may be older, or you cannot tell, recreate it\nonce: `relay_req DELETE /integrations/subscribe` with the same body, then\nsubscribe again. Verified on\nDesktop 2026.10.6 with both the probe client and `curl`.\n\n**One-command form (not yet released).** `relay-desktop#216` adds\n`agent-relay-probe relay subscribe 'https://github.com/OWNER/REPO/pull/NUMBER'`\n(or `OWNER/REPO#NUMBER`), which makes one numeric `pulls/NUMBER/**` subscription\nthat the Desktop correlates with the PR's reviews, comments, and checks. It is\nabsent from Desktop 2026.10.6, where it fails with `unrecognized subcommand\n'subscribe'`, so do not lead with it. Use it only when the installed probe\nadvertises it. The block above makes that choice, and first checks the\nsession's existing subscriptions. A pull request already subscribed with the\none-command form is left alone. A pull request already holding\nthe number glob answers 409 `conflict` on a repeat; recreate that binding once\nif it may predate 2026-09-19 (see above). Extra `reviews`, `status` or\n`issues/NUMBER/comments` globs from an earlier version of this skill never\ndeliver; unsubscribe them.\n\nOnce released, re-test it before relying on its details; the behavior described\nin that PR (a repeat answering `\"already_subscribed\":true`, `--remove` to end it,\nand a numeric subscription covering reviews, comments and checks) has not been verified here.\n\nRefusals and what they mean:\n\n- `not_a_relay_session`, \"Register this session before subscribing it\": run\n section 4's `POST /register` first.\n- `not_a_relay_session`, \"already managed by this computer's relay\" from\n `/register`: this session was started by the Agent Relay fleet broker, not\n the Desktop. Use the workspace CLI below instead.\n- `refused` or `busy`: an earlier subscription is still being made or cleaned\n up; repeat the command shortly.\n\nA fleet-spawned session, or an operator subscribing a named agent, uses the\n`agent-relay` CLI with `--to @AGENT_NAME` (a spawned worker's name is in\n`RELAY_AGENT_NAME`). **`agent-relay` 13.0.0 is the minimum.** Older CLIs let\nthe worker's ambient `RELAY_AGENT_TOKEN` displace the workspace key and fail\nwith `Workspace key required (rk_live_...)` even though `RELAY_WORKSPACE_KEY`\nis set:\n\n```sh\npull_glob='/github/repos/OWNER/REPO/pulls/NUMBER/**'\nrelay_cli_major=\nif test -n \"${relay_cli:-}\"; then\n relay_cli_major=$(\"$relay_cli\" --version 2>/dev/null | sed -n 's/^[^0-9]*\\([0-9][0-9]*\\)\\..*/\\1/p' | sed -n '1p')\nfi\nif test -n \"$relay_cli_major\" && test \"$relay_cli_major\" -ge 13; then\n \"$relay_cli\" integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nelif test -n \"$relay_cli_major\"; then\n # 12.x and older: keep the ambient agent token from displacing the key.\n env -u RELAY_AGENT_TOKEN \"$relay_cli\" integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nelse\n # No usable installed CLI: run the fixed release without installing it.\n npx -y agent-relay@13 integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nfi\n```\n\nUse the `relay_cli` found in section 2, never a bare `agent-relay`: on a\n`.deb` host `/usr/bin/agent-relay` is the Desktop launcher, and `PATH` may\nresolve to it or to a different, older CLI than the one whose version was\nchecked.\n\nThe CLI path follows `orchestrating-agent-relay`, which subscribes the same\nsingle `pulls/NUMBER/**` glob. Never put the workspace key in argv.\n\nMake the subscribe step a required post-create step in the agent's PR\nworkflow: whenever it authors a PR, it subscribes its own live session\nimmediately, without waiting for an operator to name the PR. This is the\nauthored-PR pattern from `orchestrating-agent-relay`; it covers every future PR\nthe agent authors without subscribing unrelated repository traffic. The\nDesktop removes the subscription itself after the PR closes or merges.\nFor real GitHub verification, use a harmless event the human authorized\non that PR (for example, a test conversation comment and a requested bot\nreview). After causing the event, do not wait or poll within the active turn:\nend the turn saying which event marker is expected. Confirm the injected event\nand record its full message id on the next turn after Relay has delivered it.\nDo not create a comment, review, or rerun without authorization. A successful\nsubscribe response alone does not prove end-to-end delivery. With a kept\n`hold` or `refuse`, follow the note at the start of section 5.\n\n## 7. Coexist with a REST-polling MCP\n\nDo not disable an existing polling consumer. Desktop injection acknowledges\nonly this registered agent's delivery queue and does not advance a separate\nworkspace consumer's cursor. A polling MCP should use its own identity/cursor,\nnot this session agent's `/v1/deliveries` token.\n\nInjected text contains both the full Relaycast message id and a short `ref`.\nWhen polling and injection can surface the same workspace message, deduplicate\non the full message id before acting. During verification, leave the poller\nrunning and observe the test message there. Then end the current turn so Relay\ncan inject it, and on the next turn confirm the same full id arrived while the\nagent acted only once.\n\n## 8. Final verification and report\n\nRead status again:\n\n```sh\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | \\\n jq '{version: .data.version, sign_in: .data.sign_in, workspace: .data.workspace, sharing_mode: .data.sharing_mode, auto_activate: .data.auto_activate, direct_delivery: .data.direct_delivery, direct_delivery_user_choice: .data.direct_delivery_user_choice, defaults_error: .data.defaults_error, uploader: .data.uploader, session: .data.session, webhook: .data.webhook, integrations: .data.integrations}'\n```\n\nRequire all three defaults before declaring setup complete. Set\n`relay_sharing_mode` again to the mode chosen in section 4 (`new`, or the\nexisting mode the human kept); the check refuses to guess it. This block runs\nin a fresh shell after the turns of sections 5–7, so it also derives the\nkept direct-delivery choice again: from status's\n`direct_delivery_user_choice`, or from the settings file on a build older\nthan that field:\n\n```sh\n: \"${relay_sharing_mode:?set relay_sharing_mode to the mode chosen in section 4}\"\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). setting-up-agent-relay-sessions\n# section 2 mirrors this helper; change both together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nfinal_status=$(curl -fsS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status)\nkept_choice=$(printf '%s\\n' \"$final_status\" | \\\n jq -r 'select(.data.direct_delivery == false) | .data.direct_delivery_user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\n# A build older than direct_delivery_user_choice: read the settings file.\nif [ -z \"$kept_choice\" ] && printf '%s\\n' \"$final_status\" | \\\n jq -e '.data.direct_delivery == false and (.data | has(\"direct_delivery_user_choice\") | not)' >/dev/null; then\n final_choice=$(claude_inbound_choice)\n case \"$final_choice\" in\n present:hold) kept_choice=hold ;;\n present:refuse) kept_choice=refuse ;;\n *) printf 'Direct delivery is off and no kept hold or refuse could be confirmed (%s); do not enable it here, ask the human.\\n' \\\n \"$final_choice\" >&2 ;;\n esac\nfi\nprintf '%s\\n' \"$final_status\" | jq '{direct_delivery: .data.direct_delivery, direct_delivery_user_choice: .data.direct_delivery_user_choice}'\nprintf '%s\\n' \"$final_status\" | \\\n jq -e --arg mode \"$relay_sharing_mode\" --arg kept \"$kept_choice\" '.ok and .data.sharing_mode == $mode and\n .data.auto_activate == true and\n (.data.direct_delivery == true or (.data.direct_delivery == false and $kept != \"\"))'\n```\n\nReport the exact version, `signed_in`, signed-in workspace id and name, sharing\nmode (`new`, or the existing mode the human kept), auto-activate `true`,\ndirect-delivery `true` (or the human's kept `crossSessionInbound` value),\nuploader health, session address, direct-delivery state, webhook test marker,\nsubscriptions, real GitHub event evidence, and polling-coexistence result.\nSeparate verified facts from steps that still require a human or external\nevent.\n\n## Recovery and undo\n\n- **Socket missing or slow:** check `systemctl --user status agent-relay.service`\n on Linux or `open -a \"Agent Relay\"` on macOS. Confirm the pointer names a\n socket. Never delete a live socket; restart the owning service/app. A reply\n that takes up to about 30 seconds is the known latency in section 1\n (relay-desktop#333), not a fault: allow 60 seconds and retry once.\n- **`not_a_relay_session`:** the command is not a descendant of a supported\n live Codex/Claude session, or the session id/process start no longer matches.\n Run it through this agent's shell tool. In tmux, confirm the agent process and\n shell share the pane's process tree.\n- **Sign-in blocked by a legacy uploader** (`sign_in: error`, message \"An older\n managed history uploader is active. Stop it explicitly before enabling this\n probe.\"): the probe counts any of these legacy `ai-hist push` registrations as\n active, even when idle: the file\n `~/Library/LaunchAgents/com.ai-hist.push.plist` (even if unloaded), a loaded\n launchd label `com.ai-hist.push`, or a crontab line containing\n `# ai-hist push (managed)`. Check each, then with the human's approval unload\n and move aside (do not delete) what exists. Create the backup directory first,\n use a unique no-clobber destination, and confirm the move before retrying:\n\n ```sh\n # bootout exits non-zero when the label was never loaded; that is fine, so\n # judge success by whether the label is still loaded, not by its exit code.\n launchctl bootout \"gui/$(id -u)/com.ai-hist.push\" 2>/dev/null || true\n if launchctl list | awk '{print $NF}' | grep -qx com.ai-hist.push; then\n echo 'com.ai-hist.push is still loaded; do not retry sign-in' >&2\n exit 1\n fi\n plist=\"$HOME/Library/LaunchAgents/com.ai-hist.push.plist\"\n if test -e \"$plist\"; then\n backup_dir=\"$HOME/.agentworkforce/backup\"\n mkdir -p \"$backup_dir\"\n mv -n \"$plist\" \"$backup_dir/com.ai-hist.push.plist.bak-$(date +%Y%m%d%H%M%S)\"\n test ! -e \"$plist\" || { echo 'plist was not moved; do not retry sign-in' >&2; exit 1; }\n fi\n ```\n\n If instead the marked cron line is the cause, back up the full crontab first\n and remove only that line, so the change can be reversed with\n `crontab `:\n\n ```sh\n mkdir -p \"$HOME/.agentworkforce/backup\"\n cron_backup=\"$HOME/.agentworkforce/backup/crontab.bak-$(date +%Y%m%d%H%M%S)\"\n crontab -l > \"$cron_backup\" && test -s \"$cron_backup\" || { echo 'crontab backup failed; do not edit it' >&2; exit 1; }\n grep -vF '# ai-hist push (managed)' \"$cron_backup\" | crontab -\n ! crontab -l | grep -qF '# ai-hist push (managed)' || { echo 'cron line still present; do not retry sign-in' >&2; exit 1; }\n ```\n\n Leave `com.ai-hist.sync` alone; it is not checked. Then re-run\n `/setup/sign-in` with the same payload.\n- **`not_signed_in`:** run `/setup/sign-in`; do not paste tokens into the\n request. Restart after `expired` or `denied` to obtain a new code.\n- **Older schedules could not be inspected:** inspect the user's legacy cron,\n launchd, or user-service upload schedules first. Only after that inspection,\n retry `/setup/sign-in` with the original payload plus\n `\"acknowledge_uninspected_schedules\":true`. This flag records a completed\n safety check; never send it merely to silence an error.\n- **`not_allowed` from `/register`:** enable the app's self-registration\n setting. Do not bypass it with workspace credentials.\n- **Managed Claude policy:** if direct delivery reports `managed_policy`, the\n organization controls `crossSessionInbound`; report the policy block rather\n than modifying managed settings.\n- **Undo registration:** `curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X DELETE http://relay/register | jq`.\n- **Undo an integration:** with section 6's `relay_probe`, `relay_socket`,\n `relay_req`, and `pr_resources` defined, remove every subscription the\n session holds for the pull request, in whichever form it was made. On macOS\n `relay_req` uses the probe, and a Codex session without one stops instead of\n sending a `curl` request that cannot be identified:\n\n ```sh\n held=$(pr_resources) || exit 1\n printf '%s\\n' \"$held\" | while IFS= read -r resource; do\n test -n \"$resource\" || continue\n relay_req DELETE /integrations/subscribe \\\n \"$(jq -nc --arg resource \"$resource\" '{provider:\"github\",resource:$resource}')\" | jq\n done\n relay_req GET /setup/status | jq -c '.data.integrations'\n ```\n\n Require the final list to hold none of that pull request's resources.\n- **Undo a webhook:** `curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X DELETE http://relay/webhooks | jq`.\n- **Undo direct delivery:** POST `{\"enabled\":false}` to\n `/setup/direct-delivery`. On Claude this writes `\"crossSessionInbound\":\n \"hold\"` (Claude Code's own default) on current builds, an explicit choice\n that no later default turns back on; older builds removed the key instead,\n so there set `\"hold\"` by hand to keep it off. Managed organization policy\n still wins.\n- **Undo sharing:** POST the human's prior mode (`selected`, `new`, or `all`) to\n `/setup/sharing`. Ask before changing it when the earlier value is unknown.\n- **Auto-activate accidentally enabled:** POST `{\"enabled\":false}` to\n `/setup/auto-activate`. This does not remove existing registrations.\n- **Uninstall:** stop/disable the user service before removing the Linux\n package, or quit the Mac app before removing it. Do not delete app data or\n revoke webhooks unless the human explicitly asks for data removal.\n", - "setting-up-agent-relay-sessions": "# Set Up Agent Relay Session Handoff\n\nDrive the whole onboarding from this agent's own shell and MCP tools. Do **not**\nhand the person the six-step Markdown guide and ask them to click through the\napp and the dashboard.\n\nPrefer setting this up with the Agent Relay agent at\n, which checks your current state and confirms\neach step. Fetch its instructions with\n`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`.\n\nThe finished state is:\n\n- the desktop app is installed, signed in to the **intended workspace**, the\n uploader is healthy, and this session is registered with direct delivery on,\n or with the person's own `crossSessionInbound` (`hold` or `refuse`) kept and\n reported (see section 2; delivery is never turned on without their yes);\n- the **agent-sessions cloud MCP** is installed in this project and loaded by\n this session, so the handoff tools (`list_relay_agents`, `send_relay_message`,\n `read_relay_conversation`, `check_relay_inbox`, `get_shared_session`,\n `get_shared_session_context`, `search_shared_sessions`) are available;\n- a live round trip is proven when a teammate is online: this agent messages the\n teammate's agent, **reads the teammate's real session back** with\n `get_shared_session_context`, and the teammate reads this session and replies.\n When no teammate is online yet, the fallback is the roster plus this person's\n own uploaded session reading back cleanly — reported as self-verified, not as a\n proven two-way handoff.\n\n## Requires\n\nThis skill builds on `setting-up-agent-relay-desktop` (verified install, sign-in,\nregistration, agent-led defaults). If that skill is **not installed** in this\nagent, stop, tell the person, and have them install the companions together,\nthen restart the agent. Do not improvise the install or sign-in from memory.\n\n- prpm: `npx prpm install collections/agent-relay-setup --as claude --global -y`\n- skills.sh: `npx skills add https://github.com/AgentWorkforce/skills --skill setting-up-agent-relay-desktop --skill setting-up-agent-relay-sessions`\n- Codex: do not use prpm's `--as codex` (it drops paragraphs and writes under `~/.agents/skills`, which Codex does not load as its global skill folder). Mirror both installed `SKILL.md` files into `~/.codex/skills//` with the loop in **Install this skill (and its dependency)** below.\n\n## The only human steps\n\nEverything else is automatable; be honest that these are not. **Ask for all of\nthem up front, before touching anything** — the token in particular is needed\nonly at section 3, but minting it and exporting it into the launching\nenvironment takes the person a few minutes, so have them start it while the\ndesktop install and sign-in run:\n\n1. **Approve one Google device-login link** — only when no reusable `agent-relay`\n CLI/desktop login exists. A reused login needs zero clicks.\n2. **Provide one Bearer token** minted in the dashboard's **\"Connect your agent\"**\n card. No setup route returns this token by design. **Do not have the person\n paste it into this conversation** — the uploader shares this session's prompts\n with teammates, so a pasted secret would become teammate-readable. Instead the\n person puts it into the **environment that launches their agent** (or an OS\n keychain / a mode-0600 file) via a shell *they* run, and this agent references\n it only by variable name — never by value. See **Token handling** below.\n3. **Same workspace, both sides.** The teammate (or workspace admin) must have\n invited this person's email to the **shared** workspace — two agents can only\n reach each other inside one workspace. Confirm the workspace id matches on\n both machines; do not guess it.\n\nOne structural note so expectations are right: there is **no automatic ack** to\nthe sender when a handoff is picked up, and **no partial session sharing** — a\nsession is on-or-off on the relay. Confirm a handoff by activity, not a push.\n\n## How this composes with `setting-up-agent-relay-desktop`\n\nThis skill is a thin layer on a working desktop. The desktop skill owns install,\nsign-in, defaults and self-registration; this one owns only the agent-sessions\ncloud MCP and the live handoff. Section 2 checks the desktop prerequisite and,\nif it is not met, invokes `/setting-up-agent-relay-desktop` to completion and\nreturns here. Keep the `/setup/*` details in that one skill so a contract change\nupdates it once.\n\n## Install this skill (and its dependency) for Codex and Claude Code\n\nWhen the task includes installing this skill, install its canonical Claude\npackage **and** the desktop skill it depends on, then mirror both unchanged\n`SKILL.md` files where Codex loads global skills:\n\n```sh\nfor pkg in setting-up-agent-relay-sessions setting-up-agent-relay-desktop; do\n npx prpm install \"@agent-relay/$pkg\" --as claude --global -y\n codex_skill=\"$HOME/.codex/skills/$pkg\"\n mkdir -p \"$codex_skill\"\n install -m 0644 \"$HOME/.claude/skills/$pkg/SKILL.md\" \"$codex_skill/SKILL.md\"\n cmp \"$HOME/.claude/skills/$pkg/SKILL.md\" \"$codex_skill/SKILL.md\"\ndone\n```\n\nWith skills.sh instead, use the command under **Requires**; it installs into each agent's skills folder directly, so the manual Codex mirror below is only for the prpm route.\n\nDo not use prpm's `--as codex` conversion for these packages: it writes under\n`~/.agents/skills`, which Codex does not load as its global skill folder, and it\ndrops explanatory paragraphs from the Markdown workflow. Confirm both final\npaths explicitly before continuing.\n\n## Why ordering matters\n\nMCPs load at agent **startup**. The agent-sessions MCP must be installed\n**before** the session that will use it starts. A session already running when\nthe MCP was added has no handoff tools and must be **restarted**. Install the\nMCP (section 3) before relying on the tools (sections 4–5); if this very session\npredates the MCP, say so and restart it rather than reporting missing tools as a\nfailure.\n\n## Token handling\n\nThe token must never land in the agent conversation, in a Relay message, in\nprocess argv, or in the repo. Three places leak it, and this skill avoids all\nthree:\n\n- **The chat** — this session is uploaded and teammate-readable, so a token\n pasted to the agent leaks to the workspace. The person sets it themselves (see\n below); the agent only ever sees the variable **name**.\n- **Process argv** — a token interpolated into `claude mcp add --header\n \"... Bearer $TOKEN\"` is visible to `ps` and gets written literally into\n `~/.claude.json`. Pass the unexpanded placeholder `${AGENT_RELAY_SESSIONS_TOKEN}`\n instead; Claude Code expands `${VAR}` in MCP headers from the environment at\n launch, so the stored config holds only a reference and argv holds only the\n literal `${…}`. **This expansion exists only in Claude Code ≥ 2.1.119** — an\n older client sends the placeholder literally and auth fails, so check the\n version first (section 3) and update Claude rather than inlining the token.\n- **The launching environment** — both CLIs read the token from an environment\n variable (`${VAR}` for Claude, `--bearer-token-env-var` for Codex), so the\n variable must exist in the environment that **launches the agent**, not in a\n transient shell-tool subprocess (which cannot alter its parent or a later\n process).\n\nUse the single variable name `AGENT_RELAY_SESSIONS_TOKEN` throughout. Keep the\none-time entry separate from durable supply:\n\n- **One-time, into the current shell** (non-echoing, run by the person, never\n the agent): `read -rs AGENT_RELAY_SESSIONS_TOKEN && export AGENT_RELAY_SESSIONS_TOKEN`,\n then launch the agent from *that* shell. Do **not** add this `read` to a shell\n profile — every shell that sources the profile would block waiting for input.\n- **Durable across restarts:** store the token in the OS keychain or a mode-0600\n file, and have the launcher export it **non-interactively**, e.g.\n `export AGENT_RELAY_SESSIONS_TOKEN=\"$(security find-generic-password -s agent-relay-sessions -w)\"`\n (macOS keychain) or from a 0600 file. Put that line in a dedicated launcher the\n person starts the agent from — not an interactive prompt in the profile.\n\nThe agent never prints the value; verify presence only with\n`test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"`.\n\n## 0. Preflight: collect the human inputs first\n\nBefore section 1, tell the person what they need and ask for it in one message:\n\n1. the **shared workspace id** (or the teammate/admin who invited them);\n2. a **Bearer token** minted in the dashboard's \"Connect your agent\" card for\n that workspace, stored by *them* as `AGENT_RELAY_SESSIONS_TOKEN` in the\n environment that launches their agent (see **Token handling** — never pasted\n into this chat);\n3. the **teammate's relay address** for the round trip, if one will be online.\n\nBefore sending that message, check what this machine already has. If an Agent\nRelay CLI 13.x is installed (a user-level npm, mise or nvm `agent-relay`, not\nthe `.deb`'s `/usr/bin/agent-relay` Desktop launcher), run these read-only\nchecks. Without one, section 2 reads the signed-in workspace from\n`/setup/status` instead. The CLI is optional for this skill: detect it as\nbelow, which skips the Desktop launcher that `command -v agent-relay` finds on\na `.deb` install, and do not install or upgrade it just for these checks.\n\n```sh\nrelay_cli=$(command -v agent-relay || true)\ncase \"$relay_cli\" in ''|/usr/bin/agent-relay) relay_cli= ;; esac\nif test -n \"$relay_cli\"; then\n \"$relay_cli\" --version\n \"$relay_cli\" cloud whoami # signed in, and as whom\n \"$relay_cli\" workspace active # the active Cloud workspace (keys stay masked)\n \"$relay_cli\" cloud workspaces # every workspace this login can use, with ids\n \"$relay_cli\" status # workspace, cloud login and local broker\nfi\n```\n\nIf the active workspace is already the shared one, confirm it with the person\ninstead of asking them to look it up. Never switch to or create another\nworkspace unless they explicitly ask.\n\nCheck presence without printing the value:\n\n```sh\ntest -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\" && echo token=present || echo token=missing\n```\n\nA missing token does **not** block sections 1–3: the MCP config stores only a\n`${AGENT_RELAY_SESSIONS_TOKEN}` reference, never the value, so it can be added\nnow. Do the desktop work and add the MCP, then have the person restart the agent\n**once**, from an environment that exports the token, so that single restart both\nloads the MCP and supplies the credential. Sections 4–5 are gated on that\nrestart. If the person would rather not defer, they can export the token and\nrestart first, which then costs a second restart only if the MCP was added after.\n\n## 1. Confirm the host and this session\n\nRun every command from this agent's own shell tool, and only from a **genuine\ninteractive** Claude Code or Codex session. The app identifies the caller from\nkernel credentials and resolves its process ancestry against discovered live\nsessions, so a command from an unrelated terminal — or from a non-interactive\n`codex exec` / `claude -p` run — is rejected with `not_a_relay_session`. (This\nis verified: a plain SSH shell and a `codex exec` invocation are both refused;\nan interactive session is admitted.)\n\n```sh\nuname -s; uname -m\ntest -n \"${SSH_CONNECTION:-}\" && printf 'transport=ssh\\n' || true\ntest -n \"${TMUX:-}\" && printf 'session=tmux\\n' || true\n```\n\nDefine the request helper once per shell. It finds the private setup socket\nwithout printing private files and uses the desktop's `agent-relay-probe` on\nmacOS (the system `curl` hides a Codex session's identity there and is refused\n`not_a_relay_session`), `curl` elsewhere, and stops with a message for a macOS\nCodex session that has no probe:\n\n```sh\npointer=\"$HOME/.agentworkforce/desktop/relay-socket\"\nif test -r \"$pointer\"; then relay_socket=$(sed -n '1p' \"$pointer\"); fi\nif test -n \"${relay_socket:-}\" && ! test -S \"$relay_socket\"; then unset relay_socket; fi\nif test -z \"${relay_socket:-}\" && test \"$(uname -s)\" = Linux; then\n for c in \"${XDG_RUNTIME_DIR:-/nonexistent}/agent-relay/relay.sock\" \"/run/user/$(id -u)/agent-relay/relay.sock\"; do\n if test -S \"$c\"; then relay_socket=$c; break; fi\n done\nfi\nif test -z \"${relay_socket:-}\" && test \"$(uname -s)\" = Darwin; then # pointer file missing: use the app's own socket\n c=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\n if test -S \"$c\"; then relay_socket=$c; fi\nfi\nrelay_probe=\nfor c in \"$HOME/.local/bin/agent-relay-probe\" \\\n /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \\\n \"/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe\"; do\n if test -x \"$c\"; then relay_probe=$c; break; fi\ndone\nif test -z \"$relay_probe\" && test -d \"$HOME/.local/lib/agent-relay\"; then # per-user tarball\n relay_probe=$(find \"$HOME/.local/lib/agent-relay\" -type f -perm -u+x \\\n -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null)\nfi\n# relay_req METHOD PATH [JSON-BODY]\nrelay_req() {\n if ! test -S \"${relay_socket:-/nonexistent}\"; then\n echo 'No Agent Relay socket found; start Agent Relay (the desktop) and retry.' >&2\n return 1\n fi\n if test \"$(uname -s)\" = Darwin && test -n \"$relay_probe\"; then\n printf '%s' \"${3:-}\" | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method \"$1\" --path \"$2\"\n elif test \"$(uname -s)\" = Darwin && test -n \"${CODEX_THREAD_ID:-}\"; then\n echo 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.' >&2\n return 1\n elif test -n \"${3:-}\"; then\n # body on stdin: curl treats a -d/--data-binary value starting with @ as a filename\n printf '%s' \"$3\" | curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \\\n -H 'Content-Type: application/json' --data-binary @- \"http://relay$2\"\n else\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \"http://relay$2\"\n fi\n}\n```\n\n**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can\ntake about 30 seconds on a busy Desktop (tracked in\nAgentWorkforce/relay-desktop#333). Allow 60 seconds per call (add\n`--max-time 60` to the `curl` lines above), and do not treat a slow reply as a\nfailure. The probe's `relay socket-request` can give up sooner with \"Probe\ncould not finish. Check your connection and run setup again\"; on these paths\nthat is the same latency, not a setup problem. Retry once before reporting a\nblocker, and do not rerun desktop setup because of it.\n\n## 2. Make sure the desktop is set up first\n\nSession handoff needs a signed-in, healthy desktop underneath it. If the socket\nis missing, or `/setup/status` returns `404`, or sign-in/uploader/registration\nare not in the finished state below, **invoke the `setting-up-agent-relay-desktop`\nskill** (`/setting-up-agent-relay-desktop`; if it is not installed, see **Requires**), let it run to completion, and then\nreturn to section 2's verification. Do not re-implement its install, sign-in, or\n`/setup/*` steps here — that skill is the single source of truth for them, and\nthis skill resumes once it reports a registered, signed-in session.\n\nVerify the desktop state this skill depends on:\n\n```sh\nrelay_req GET /setup/status | jq '{\n version: .data.version,\n sign_in: .data.sign_in,\n workspace: .data.workspace,\n sharing_mode: .data.sharing_mode,\n uploader: .data.uploader,\n direct_delivery: .data.direct_delivery,\n session: .data.session,\n defaults_error: .data.defaults_error\n}'\n```\n\nRequire all of these before continuing, and stop with a clear message if any\nfails. `/setup/status` reports sign-in as the string `sign_in` (match the desktop\nskill), and the flag that actually governs whether **this** session's handoff\nreplies arrive live is the session-level `session.direct_delivery`, not the\ntop-level default. Direct delivery is on by default, but the desktop keeps a\n`crossSessionInbound` value the person set themselves (`hold` or `refuse`), and\nso does this skill: such a kept choice is reported in\n`direct_delivery_user_choice` (read from the settings file on a desktop build\nolder than that field) and is accepted here only when the top-level flag is\nexplicitly `false` (a missing field is never success). Any other value is not\na kept choice this skill acts on:\n\n```sh\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). Mirrors\n# setting-up-agent-relay-desktop sections 4 and 8; change them together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nsetup_status=$(relay_req GET /setup/status)\nkept_choice=$(printf '%s\\n' \"$setup_status\" | \\\n jq -r 'select(.data.direct_delivery == false) | .data.direct_delivery_user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\nif [ -z \"$kept_choice\" ] && printf '%s\\n' \"$setup_status\" | \\\n jq -e '.data.direct_delivery == false and (.data | has(\"direct_delivery_user_choice\") | not)' >/dev/null; then\n case \"$(claude_inbound_choice)\" in\n present:hold) kept_choice=hold ;;\n present:refuse) kept_choice=refuse ;;\n esac\nfi\nprintf '%s\\n' \"$setup_status\" | jq -e --arg kept \"$kept_choice\" '\n .ok\n and .data.sign_in == \"signed_in\"\n and (.data.workspace.id | type == \"string\" and length > 0)\n and .data.uploader.healthy == true\n and (.data.session.id | type == \"string\" and length > 0)\n and .data.session.registered == true\n and ((.data.direct_delivery == true and .data.session.direct_delivery == true)\n or (.data.direct_delivery == false and $kept != \"\"\n and (.data.session.direct_delivery | type == \"boolean\")))'\n```\n\n**When the person kept their own choice** (`kept_choice` is set): do not\nchange it, and do not re-run desktop setup to \"fix\" it, since that keeps it\ntoo. `crossSessionInbound` is Claude Code's setting: when this session reports\n`session.direct_delivery:true` (a Codex session), report the kept value and\ncontinue normally; the round trip completes as usual. When it reports\n`session.direct_delivery:false` (this Claude Code session), tell them what it\nmeans for the handoff:\n\n- `hold`: relayed messages, including the teammate's handoff reply, arrive as\n held messages they must approve in Claude Code before this agent sees them.\n Setup continues; section 5's round trip completes once they approve the held\n reply.\n- `refuse`: relayed messages to this session are dropped, so the round trip\n cannot complete here. Setup continues through section 4, then stops at\n section 5 as **blocked on the person's choice**.\n\nEither way, offer the opt-in once: \"Turn on direct delivery for Claude Code?\nIt sets `crossSessionInbound` to `accept` in your Claude Code settings.\" Only\non an explicit yes, POST `{\"enabled\":true}` to `/setup/direct-delivery` and\nre-run this check; on no, keep their value and continue as above.\n\nConfirm `workspace.id` is the **shared** workspace both sides agreed on. A\nhealthy uploader is the one non-obvious prerequisite: for a teammate's agent to\nread **this** person's session, this machine must have uploaded it. If\n`uploader.healthy` is false (paused, or a failed cycle), fix it before promising\nanyone can read this session — this is the single most common reason a live\nhandoff silently fails.\n\n`sharing_mode` decides which sessions upload at all, and a healthy uploader\ndoes not prove that **this** session is among them. `all` includes it. `new`\nincludes sessions that started after `new` was enabled, so a session that was\nalready running when the desktop was set up may not be uploaded. Under\n`selected`, only the sessions the person chose upload. Unless the mode is\n`all`, tell the person that this session may not be shared yet, and ask them to\nselect it in the app or to continue from a new session; do not change the\nsharing mode or select sessions on their behalf. Section 5's round trip is the\nproof: if the teammate cannot read this session there, this is the first\nthing to check.\n\n## 3. Install the agent-sessions cloud MCP (before the session uses it)\n\nCheck whether the token is present in the environment **without printing it**\n(see **Token handling**). It is not required to *add* the MCP, since only a\nreference is stored, but it is required for the tools to authenticate after the\nrestart. If it is missing, add the MCP anyway, tell the person plainly that the\ntools will fail auth until they restart the agent from an environment that\nexports `AGENT_RELAY_SESSIONS_TOKEN`, and do not report setup complete:\n\n```sh\nif ! test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"; then\n echo 'AGENT_RELAY_SESSIONS_TOKEN is not set here; the MCP will be added by reference, but the agent must be restarted from an environment that exports it.' >&2\nfi\n```\n\nDetect which agent CLI this session runs and install accordingly.\n\nClaude Code — pass the **unexpanded** placeholder so the token stays out of argv\nand out of `~/.claude.json`; Claude expands `${VAR}` in MCP headers from the\nenvironment at launch. This expansion requires **Claude Code ≥ 2.1.119**, so\nverify the version first and update rather than inlining the token on an older\nclient. Single-quote the header so the shell does not expand it here. Default\nscope is `local` (this project only), which is what you want:\n\n```sh\nclaude_ver=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1)\nif test -z \"$claude_ver\" || test \"$(printf '%s\\n2.1.119\\n' \"$claude_ver\" | sort -V | head -1)\" != 2.1.119; then\n echo \"Claude Code $claude_ver does not expand \\${VAR} in MCP headers (need >= 2.1.119); update Claude Code before adding this server.\" >&2\n exit 1\nfi\nclaude mcp add --transport http agent-relay-sessions \\\n https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \\\n --header 'Authorization: Bearer ${AGENT_RELAY_SESSIONS_TOKEN}'\nclaude mcp list | grep -F agent-relay-sessions\n```\n\nCodex — `codex mcp add` records the **variable name**, not the value, and writes\nto user-level `~/.codex/config.toml`, so the server is enabled for **all** Codex\nprojects (Codex has no project-scoped MCP). Confirm the person is OK with\nuser-wide access before adding it:\n\n```sh\ncodex mcp add agent-relay-sessions \\\n --url https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \\\n --bearer-token-env-var AGENT_RELAY_SESSIONS_TOKEN\ncodex mcp list | grep -F agent-relay-sessions\n```\n\nBecause MCPs load at startup, the running session must be **restarted** to pick\nup the server — and because both CLIs read the token from the environment at\nlaunch, the restarted process must **inherit** `AGENT_RELAY_SESSIONS_TOKEN`.\nThat is why the variable belongs in the shell profile / launcher, not a transient\nshell-tool subprocess: an `export` in this agent's shell tool cannot reach the\nparent or a freshly launched session. After the restart, re-verify presence with\n`test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"` before using the tools, and do not\nreport the tools as missing without first confirming the session started *after*\nthe MCP was added and with the variable set.\n\n## 4. Confirm the handoff tools loaded\n\nIn the session that started after the MCP was added, confirm the tools are\npresent before using them. List this session's MCP tools and check for the\nagent-sessions set; the authoritative check is that the tools actually appear to\nthis agent:\n\n```sh\n# Claude Code: the tools surface as mcp__agent-relay-sessions__*\nclaude mcp list | grep -F agent-relay-sessions\n```\n\nThen call `list_relay_agents` through the MCP (an MCP tool call, not a shell\ncommand). A successful call that returns the workspace roster proves the MCP is\nloaded, the token is valid, and this session can see the shared workspace. If\nthe call errors with auth, the token is wrong or for another workspace — have\nthe person re-mint it from the correct workspace's dashboard.\n\n## 5. Prove one live round trip\n\nThe real proof is agent-to-agent, so it needs a counterpart. Pick the path that\nmatches what is available:\n\n**A teammate's agent is online (full round trip).** Ask the person for the\nteammate's relay address (e.g. `@manav`), then:\n\n1. `list_relay_agents` — confirm the teammate's address is listed.\n2. **Prove the reverse read path first:** call `get_shared_session_context` on\n the teammate's session and confirm it returns their **real** content (prompts/\n events + a dashboard citation link). If it errors or is empty, their uploader\n is unhealthy — stop and have them fix it; do not declare GREEN, because this\n side cannot actually read their work yet.\n3. `send_relay_message` — send a short, uniquely-marked message to that address.\n4. The teammate's agent reads this session with `get_shared_session_context` and\n continues; have them reply through the relay.\n5. GREEN requires **both directions**: this agent read the teammate's real\n content (step 2) **and** the teammate's reply lands here. There is no\n automatic ack — confirm by the reply/activity, not a push. Because Relay\n injects an inbound message only when the session is idle between turns, **end\n the turn** after sending and confirm the reply on the next turn; do not sleep\n or poll inside one turn.\n6. Only when this session reports `session.direct_delivery:false` (a Claude\n Code session with a kept choice): with `hold`, the reply arrives as a held\n message; ask the person to approve it, then confirm it on the next turn.\n With `refuse` and no yes to the opt-in, the reply is dropped: report setup\n as **blocked on the person's choice**, naming the opt-in, not as complete,\n and not as failed. A session reporting `true` follows steps 1–5 unchanged.\n\n**No teammate online yet (self-verify the read path).** Prove the machinery\nwithout a second person:\n\n1. `list_relay_agents` returns the roster — MCP + token + workspace are good.\n2. `search_shared_sessions` / `get_shared_session_context` return this person's\n **own** recently uploaded session (real prompts/events plus a dashboard\n citation link) — the uploader→read path works.\n3. Record that the two-party round trip still needs the teammate online, and\n name it as the remaining step rather than claiming the handoff is proven.\n Setup is not complete until the full round trip succeeds.\n\nNever paste the Bearer token or any session token into a `send_relay_message`\nbody or any artifact.\n\n## 6. Report\n\nSeparate verified facts from what still needs a human or a second machine:\n\n- app version, `signed_in`, the signed-in **workspace id and name**;\n- uploader `healthy`, this session's `agent@machine` address, and direct\n delivery on, or the person's kept `crossSessionInbound` value and what it\n does (held for approval, or dropped), with the opt-in they were offered;\n- agent-sessions MCP installed and its tools confirmed loaded in this session;\n- `list_relay_agents` roster seen; and either the full round-trip evidence (the\n teammate's real content + reply) or the self-verified read path plus the named\n remaining step (teammate online).\n\nDo not call the setup complete on a 2xx from `claude mcp add` alone, and do not\ncount a roster listing as a proven handoff.\n\n## Recovery\n\n- **`not_a_relay_session` from `/setup/*`:** the caller is not inside a discovered\n interactive session. Run it from this agent's own shell inside an **interactive**\n Claude Code or Codex session — a non-interactive `codex exec` or `claude -p` run\n is not admitted, and neither is an unrelated terminal. In tmux/SSH, confirm the\n agent process and shell share the pane's process tree.\n- **No handoff tools in the session:** the MCP was added after the session\n started, or at the wrong scope. Reinstall at project scope (section 3) and\n **restart** the session; MCPs load only at startup.\n- **`list_relay_agents` auth error:** the token is wrong, expired, or from a\n different workspace. Because the MCP config stores only a **reference** to\n `AGENT_RELAY_SESSIONS_TOKEN`, fixing it means updating the **value in the\n launch environment** (keychain / 0600 file) and **restarting** the agent so it\n re-reads it — re-running `claude mcp add` / `codex mcp add` alone changes\n nothing. Re-mint from the intended workspace's \"Connect your agent\" card if the\n token itself is bad. On Claude, also confirm the client is ≥ 2.1.119 (older\n clients send the `${VAR}` placeholder literally).\n- **Teammate's address missing from the roster:** they are not on the **same**\n workspace, or their session is not on the relay. Have them register their\n session and confirm the shared workspace id.\n- **Can't read the teammate's session:** their **uploader** is not healthy\n (paused or a failed cycle). Have them check `/setup/status` shows\n `uploader.healthy == true`, not \"Sync paused/offline\".\n- **Message arrives held, not live:** direct delivery is off for the recipient\n (Codex delivers directly by default). If their `/setup/status` shows a\n `direct_delivery_user_choice`, they chose it: tell them, and POST\n `{\"enabled\":true}` to `/setup/direct-delivery` on their machine only when\n they say yes. Otherwise apply the default the way\n `setting-up-agent-relay-desktop` section 4 does: `{\"default\":true}`, which\n never overrides a choice, and on an older build that answers\n `invalid_enabled`, its settings-file check before any `{\"enabled\":true}`. A managed Claude policy can block this; report it\n rather than editing managed settings.\n- **Remove the MCP:** `claude mcp remove agent-relay-sessions` /\n `codex mcp remove agent-relay-sessions`. This removes only the handoff tools;\n it does not sign the desktop out or stop uploads.\n\n## Relationship to the other skills\n\n- **`setting-up-agent-relay-desktop`** — the layer below this one: install,\n sign-in, agent-led defaults, self-registration. Use it when section 2 is not\n yet satisfied.\n- **`relay-connect`** — a lighter, account-free, time-boxed way for two agents to\n share one session via a hand-over link. Prefer it for an ad-hoc pairing;\n prefer this skill for durable teammate-to-teammate handoff inside a shared\n workspace.\n", + "setting-up-agent-relay-desktop": "# Set Up Agent Relay Desktop\n\nComplete the setup through the private session socket. Do not ask the human to\nclick through the desktop app. The only normal human step is approving one\ndevice-login link when no reusable `agent-relay` CLI login exists.\n\nPrefer setting this up with the Agent Relay agent at\n, which checks your current state and confirms\neach step. Fetch its instructions with\n`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`.\n\nThe finished state is:\n\n- the current release is installed and running;\n- sign-in is complete;\n- sharing mode is `new` (new sessions upload automatically), unless an existing\n install already uses another mode and the human chose to keep it (section 4);\n- auto-activate is on, with every existing live session and every future session on the relay;\n- Claude Code direct delivery is on (`crossSessionInbound` is `accept`),\n unless the human already set `crossSessionInbound` themselves (for example\n `hold`), which is kept and reported;\n- this Codex or Claude session is registered, and accepts direct delivery\n unless that kept choice holds or refuses Claude Code's relayed messages;\n- the requested webhook and integration subscriptions work;\n- the human receives the session's `agent@machine` address and verification evidence.\n\nNever put a Cloud token, workspace key, agent token, or webhook secret in a\nRelay message. The webhook secret is returned once to this local calling\nsession; keep it in a mode-0600 temporary file and delete the file after the\ntest.\n\n## Install this skill for Codex and Claude Code\n\nWhen the task includes installing this skill, install its canonical Claude\npackage, then place that unchanged `SKILL.md` where Codex actually loads global\nskills:\n\n```sh\nnpx prpm install @agent-relay/setting-up-agent-relay-desktop --as claude --global -y\ncodex_skill=\"$HOME/.codex/skills/setting-up-agent-relay-desktop\"\nmkdir -p \"$codex_skill\"\ninstall -m 0644 \\\n \"$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md\" \\\n \"$codex_skill/SKILL.md\"\ncmp \\\n \"$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md\" \\\n \"$codex_skill/SKILL.md\"\n```\n\nDo not use prpm's `--as codex` conversion for this package. It currently writes\nunder `~/.agents/skills`, which Codex does not load as its global skill folder,\nand its conversion drops explanatory paragraphs from this Markdown workflow.\nConfirm both final paths explicitly before continuing.\n\n## 1. Detect the host and current session\n\nRun setup commands from this agent's own shell tool. Do not run them from an\nunrelated terminal: the app identifies the caller from kernel credentials and\nthe Codex/Claude process ancestry.\n\n```sh\nuname -s\nuname -m\ntest -n \"${TMUX:-}\" && printf 'session=tmux\\n' || true\ntest -n \"${SSH_CONNECTION:-}\" && printf 'transport=ssh\\n' || true\nif test \"$(uname -s)\" = Linux; then\n if command -v dpkg-query >/dev/null; then\n printf 'linux_install=deb\\n'\n else\n printf 'linux_install=per-user-tarball\\n'\n fi\nfi\ncommand -v systemctl >/dev/null && systemctl --user is-system-running || true\n```\n\nOn Ubuntu with `dpkg`, prefer the `.deb` path below. On Arch-based and other\nsystemd Linux hosts without `dpkg`, use the checksum-verified per-user tarball\npath; do not try to install the `.deb`. The app scans same-user process trees\nand TTYs; it does not require the service itself to share the tmux session.\n\n`systemctl --user is-system-running` may report `degraded` because an unrelated\nuser unit failed. That is not a Relay failure when\n`systemctl --user is-active agent-relay.service` reports `active` and the Relay\nsocket passes its status check.\n\nFind an existing socket without printing private files:\n\n```sh\npointer=\"$HOME/.agentworkforce/desktop/relay-socket\"\nif test -r \"$pointer\"; then relay_socket=$(sed -n '1p' \"$pointer\"); fi\nif test -n \"${relay_socket:-}\" && ! test -S \"$relay_socket\"; then unset relay_socket; fi\nif test -z \"${relay_socket:-}\" && test -n \"${XDG_RUNTIME_DIR:-}\"; then\n relay_socket=\"$XDG_RUNTIME_DIR/agent-relay/relay.sock\"\nfi\nif ! test -S \"${relay_socket:-/nonexistent}\" && test \"$(uname -s)\" = Linux; then\n relay_socket=\"/run/user/$(id -u)/agent-relay/relay.sock\"\nfi\nc=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\nif ! test -S \"${relay_socket:-/nonexistent}\" && test \"$(uname -s)\" = Darwin && test -S \"$c\"; then # pointer file missing: use the app's own socket\n relay_socket=$c\nfi\ntest -S \"${relay_socket:-/nonexistent}\" && printf 'socket=%s\\n' \"$relay_socket\"\n```\n\nShell variables do not survive between separate tool calls. Every later block\nuses `relay_socket`, and some use variables set earlier in the same section (the\nsign-in poll uses `sign_in`, the sign-in request's response). Run a section's\nblocks in one shell. In a new shell, re-establish every variable the next block\nreads first: re-run this discovery block for `relay_socket`, and never re-send a\nsign-in request just to restore `sign_in`; the poll falls back to a 5-second\ninterval without it.\n\nIf the socket exists, inspect it before installing anything:\n\n```sh\ntest -S \"${relay_socket:-/nonexistent}\" || { printf 'relay_socket is not set: run the socket discovery block first.\\n' >&2; exit 1; }\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | jq\n```\n\n**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can\ntake about 30 seconds on a busy Desktop (tracked in\nAgentWorkforce/relay-desktop#333). Give each socket call a 60-second timeout\n(`curl --max-time 60`), and do not treat a slow reply as a failure. The probe's\n`relay socket-request` can give up sooner with \"Probe could not finish. Check\nyour connection and run setup again\"; on these paths that is the same latency,\nnot a setup problem. Retry the call once before reporting a blocker, and do not\nstart a second install or sign-in because of it.\n\nPreserve a working newer install. When the socket is absent, check for an\ninstalled-but-stopped copy before downloading: use `dpkg-query -W agent-relay`\nwhen `dpkg-query` exists and `systemctl --user status agent-relay.service` on\nLinux, or test `/Applications/Agent Relay.app` on macOS. Start it and retry\nstatus first.\nUpdate an older install only when it lacks the setup contract or reports an\nolder version. A `404` from `/setup/status` means the installed build is too\nold for agent-driven setup and should be updated.\n\n## 2. Install or update with checksum verification\n\nUse a private temporary directory. Never skip the published SHA-256 check.\n\n### Ubuntu LTS, headless `.deb` (preferred)\n\nSelect the release architecture and install:\n\nThis path needs passwordless sudo or one human-approved sudo prompt for the\npackage install and `loginctl enable-linger`. Explain that prerequisite before\nstarting; never capture or relay a sudo password.\n\n```sh\ncase \"$(uname -m)\" in\n x86_64|amd64) relay_arch=x64 ;;\n aarch64|arm64) relay_arch=arm64 ;;\n *) printf 'Unsupported Linux architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.deb\" -o \"$relay_tmp/AgentRelay.deb\"\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.deb.sha256\" -o \"$relay_tmp/AgentRelay.deb.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-Linux-$relay_arch.deb#AgentRelay.deb#\" AgentRelay.deb.sha256 | sha256sum --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nsudo apt-get install -y \"$relay_tmp/AgentRelay.deb\"\nsudo loginctl enable-linger \"$USER\"\nsystemctl --user daemon-reload\nsystemctl --user enable agent-relay.service\nsystemctl --user restart agent-relay.service\nsystemctl --user is-active agent-relay.service\nloginctl show-user \"$USER\" -p Linger\n```\n\nThe unit runs the bundled core headless: no display, X server, or GTK\nwindow is needed. This session's own GitHub subscriptions (section 6) go\nthrough the Desktop and need no CLI. The separate Agent Relay CLI is needed\nonly to subscribe a fleet-spawned or named agent; record whether it is present\nand at least 13.0.0. Find the same user-level npm, mise, or nvm CLI the app\ndiscovers; never run the desktop's `/usr/bin/agent-relay` launcher as a CLI:\n\n```sh\n# The installed Agent Relay CLI: user-level npm, mise or nvm, then PATH;\n# never the .deb's /usr/bin/agent-relay Desktop launcher. Section 2 and\n# section 6 define this identically; change both together.\nfind_relay_cli() {\n for candidate in \\\n \"$HOME/.local/bin/agent-relay\" \\\n \"$HOME/.npm-global/bin/agent-relay\" \\\n \"$HOME/.agentworkforce/relay/bin/agent-relay\"\n do\n if test -x \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"; return\n fi\n done\n for relay_root in \\\n \"$HOME/.local/share/mise/installs/node\" \\\n \"$HOME/.nvm/versions/node\"\n do\n test -d \"$relay_root\" || continue\n candidate=$(find -L \"$relay_root\" -mindepth 3 -maxdepth 3 \\\n -path '*/bin/agent-relay' -type f -perm -u+x -print -quit 2>/dev/null)\n if test -n \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"; return\n fi\n done\n candidate=$(command -v agent-relay 2>/dev/null || true)\n if test -n \"$candidate\" && test -x \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"\n fi\n}\nrelay_cli=$(find_relay_cli)\nif test -n \"$relay_cli\"; then\n \"$relay_cli\" --version\n \"$relay_cli\" integration subscribe --help >/dev/null\nelse\n printf 'No Agent Relay CLI found; Desktop subscriptions do not need one.\\n'\nfi\n```\n\nOn Linux without `dpkg`, use this per-user tarball installation under a\nversioned user directory. It is also the fallback on Ubuntu when `.deb`\ninstallation is unavailable and the human approves a per-user install. The\narchive contains a unit whose `/usr/bin` path is correct for packages, so\nrewrite only that `ExecStart` in the user copy:\n\n```sh\ncase \"$(uname -m)\" in\n x86_64|amd64) relay_arch=x64 ;;\n aarch64|arm64) relay_arch=arm64 ;;\n *) printf 'Unsupported Linux architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.tar.gz\" -o \"$relay_tmp/AgentRelay.tar.gz\"\ncurl -fL \"$release/AgentRelay-Linux-$relay_arch.tar.gz.sha256\" -o \"$relay_tmp/AgentRelay.tar.gz.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-Linux-$relay_arch.tar.gz#AgentRelay.tar.gz#\" AgentRelay.tar.gz.sha256 | sha256sum --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nmkdir \"$relay_tmp/extracted\"\ntar -xzf \"$relay_tmp/AgentRelay.tar.gz\" -C \"$relay_tmp/extracted\"\nrelay_tree=\"$relay_tmp/extracted/AgentRelay-linux-$relay_arch\"\ntest -x \"$relay_tree/usr/bin/agent-relay\"\nrelay_version=$(sed -n 's/^__version__ = \"\\([0-9A-Za-z._-]*\\)\"$/\\1/p' \\\n \"$relay_tree/usr/lib/agent-relay/agent_relay/__init__.py\")\ncase \"$relay_version\" in\n ''|*[!0-9A-Za-z._-]*) printf 'Invalid Agent Relay version in archive.\\n' >&2; exit 1 ;;\nesac\nrelay_unit=\"$HOME/.config/systemd/user/agent-relay.service\"\nrelay_installed_version=\nif test -S \"${relay_socket:-/nonexistent}\"; then\n relay_installed_version=$(curl -fsS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status 2>/dev/null | jq -r '.data.version // empty' || true)\nfi\nif test -z \"$relay_installed_version\" && test -r \"$relay_unit\"; then\n relay_installed_version=$(sed -n \\\n 's#^ExecStart=.*/\\.local/lib/agent-relay/\\([^/ ]*\\)/.*#\\1#p' \\\n \"$relay_unit\")\nfi\ncase \"$relay_installed_version\" in\n ''|*[!0-9A-Za-z._-]*) relay_installed_version= ;;\nesac\nif test -n \"$relay_installed_version\" && \\\n test \"$relay_installed_version\" != \"$relay_version\" && \\\n test \"$(printf '%s\\n%s\\n' \"$relay_version\" \"$relay_installed_version\" | sort -V | tail -n 1)\" = \"$relay_installed_version\"; then\n printf 'Installed Agent Relay %s is newer than archive %s; refusing downgrade.\\n' \\\n \"$relay_installed_version\" \"$relay_version\" >&2\n exit 1\nfi\nrelay_prefix=\"$HOME/.local/lib/agent-relay/$relay_version\"\nmkdir -p \"$relay_prefix\"\ncp -a \"$relay_tree/usr\" \"$relay_prefix/\"\nmkdir -p \"$(dirname \"$relay_unit\")\"\nsed \"s#^ExecStart=/usr/#ExecStart=$relay_prefix/usr/#\" \\\n \"$relay_tree/usr/lib/systemd/user/agent-relay.service\" >\"$relay_unit.tmp\"\nchmod 0644 \"$relay_unit.tmp\"\nmv \"$relay_unit.tmp\" \"$relay_unit\"\ngrep -F \"ExecStart=$relay_prefix/usr/\" \"$relay_unit\" | grep -q -- ' --headless$'\nif ! loginctl show-user \"$USER\" -p Linger | grep -qx 'Linger=yes'; then\n sudo loginctl enable-linger \"$USER\"\nfi\nsystemd-analyze --user verify \"$relay_unit\"\nsystemctl --user daemon-reload\nsystemctl --user enable agent-relay.service\nsystemctl --user restart agent-relay.service\nsystemctl --user is-active agent-relay.service\nloginctl show-user \"$USER\" -p Linger\n# List the app in the desktop's app launcher (no-op on a headless server).\n# Releases before v2026.10.13 lack agent-relay-desktop; the app then adds the\n# entry the first time it is opened.\nif test -x \"$relay_prefix/usr/bin/agent-relay-desktop\"; then\n \"$relay_prefix/usr/bin/agent-relay-desktop\" --install-launcher || true\nfi\n```\n\nThe unit's `ExecStart` names the archive's own path under `/usr/`, either the\nlauncher or the bundled `agent-relay-probe`; the rewrite moves whichever it is\nunder `$relay_prefix`. The launcher entry runs `agent-relay-desktop` by its\nfull path, never the bare `agent-relay`, which is also the CLI's name.\n\nDo not improvise Alpine or RHEL distribution packages. On a compatible\nsystemd host without `dpkg`, use only the verified per-user tarball path above;\nnon-systemd hosts remain unsupported by this Linux workflow.\n\n### macOS DMG\n\n```sh\ncase \"$(uname -m)\" in\n arm64) relay_arch=arm64 ;;\n x86_64) relay_arch=x64 ;;\n *) printf 'Unsupported Mac architecture: %s\\n' \"$(uname -m)\" >&2; exit 2 ;;\nesac\nrelay_tmp=$(mktemp -d)\nchmod 700 \"$relay_tmp\"\nrelease=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download\ncurl -fL \"$release/AgentRelay-macOS-$relay_arch.dmg\" -o \"$relay_tmp/AgentRelay.dmg\"\ncurl -fL \"$release/AgentRelay-macOS-$relay_arch.dmg.sha256\" -o \"$relay_tmp/AgentRelay.dmg.sha256\"\nif ! (cd \"$relay_tmp\" && sed \"s#AgentRelay-macOS-$relay_arch.dmg#AgentRelay.dmg#\" AgentRelay.dmg.sha256 | shasum -a 256 --check -); then\n printf 'Agent Relay checksum verification failed; refusing installation.\\n' >&2\n exit 1\nfi\nmount_point=$(hdiutil attach -nobrowse -readonly \"$relay_tmp/AgentRelay.dmg\" | awk '/\\/Volumes\\// {sub(/^.*\\/Volumes\\//,\"/Volumes/\"); print; exit}')\nincoming_id=$(defaults read \"$mount_point/Agent Relay.app/Contents/Info\" CFBundleIdentifier 2>/dev/null || true)\nif test \"$incoming_id\" != com.agentrelay.desktop; then\n hdiutil detach \"$mount_point\" >/dev/null 2>&1 || true\n printf 'Downloaded app has unexpected bundle id %s; refusing installation.\\n' \"$incoming_id\" >&2\n exit 1\nfi\nif test -d \"/Applications/Agent Relay.app\"; then\n existing_id=$(defaults read \"/Applications/Agent Relay.app/Contents/Info\" CFBundleIdentifier 2>/dev/null || true)\n if test \"$existing_id\" != com.agentrelay.desktop; then\n hdiutil detach \"$mount_point\" >/dev/null 2>&1 || true\n printf 'Existing Agent Relay.app has unexpected bundle id %s; refusing overwrite.\\n' \"$existing_id\" >&2\n exit 1\n fi\nfi\nosascript -e 'tell application \"Agent Relay\" to quit' 2>/dev/null || true\nditto \"$mount_point/Agent Relay.app\" \"/Applications/Agent Relay.app\"\nhdiutil detach \"$mount_point\"\nopen -a \"Agent Relay\"\n```\n\nIf `/Applications` needs administrator access, use `sudo ditto` only after the\nhuman has authorized installation. Do not remove or overwrite another app with\nan unexpected bundle identity.\n\nAfter either install, wait up to 30 seconds for the pointer:\n\n```sh\napp_sock=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\nfor _ in $(seq 1 30); do\n test -r \"$HOME/.agentworkforce/desktop/relay-socket\" && break\n test \"$(uname -s)\" = Darwin && test -S \"$app_sock\" && break # the app is already listening\n sleep 1\ndone\nrelay_socket=$(sed -n '1p' \"$HOME/.agentworkforce/desktop/relay-socket\" 2>/dev/null)\nif ! test -S \"$relay_socket\" && test \"$(uname -s)\" = Darwin && test -S \"$app_sock\"; then\n relay_socket=$app_sock\nfi\nif ! test -S \"$relay_socket\" && test \"$(uname -s)\" = Linux; then\n relay_socket=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/agent-relay/relay.sock\"\nfi\ntest -S \"$relay_socket\"\ncurl -fsS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | jq -e '.ok and (.data.version | length > 0)'\n```\n\n## 3. Sign in by device approval\n\nBefore choosing a workspace, check what the human already has. These commands\nonly read state. The CLI checks run only when section 2 found a `relay_cli`;\nnever call the `.deb`'s `/usr/bin/agent-relay` launcher for them:\n\n```sh\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | \\\n jq '{sign_in: .data.sign_in, workspace: .data.workspace}'\nif test -n \"${relay_cli:-}\"; then\n \"$relay_cli\" cloud whoami # signed in, and as whom\n \"$relay_cli\" workspace active # the active Cloud workspace (keys stay masked)\n \"$relay_cli\" cloud workspaces # every workspace this login can use, with ids\n \"$relay_cli\" status # workspace, cloud login and local broker\nfi\n```\n\nSign in to the workspace they already use. Pick a different or new workspace\nonly when the human explicitly asks for it.\n\nFor a multi-server rollout, obtain the existing enterprise workspace UUID from\nthe human and pass it explicitly on every server. Do not guess it and do not\nuse `{}` when the target workspace is known: that could select a default/free\nworkspace and split the fleet. Use `{}` only for an individual setup where the\nhuman has confirmed that the reusable login's current workspace is intended.\n\n```sh\nrelay_workspace_uuid='HUMAN-PROVIDED-WORKSPACE-UUID'\nsign_in_payload=$(jq -nc --arg workspace \"$relay_workspace_uuid\" '{workspace:$workspace}')\nsign_in=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d \"$sign_in_payload\" http://relay/setup/sign-in)\nprintf '%s\\n' \"$sign_in\" | jq \\\n '{ok, data: {\n status: .data.status,\n reused_login: .data.reused_login,\n workspace: .data.workspace\n }, error}'\n```\n\nIf sign-in reports that older upload schedules could not be inspected, first\ninspect the user's legacy cron, launchd, and user-service upload schedules.\nAfter completing that safety check, retry the original request with the\nacknowledgement added; never use it merely to suppress the error:\n\n```sh\nsign_in_payload=$(printf '%s\\n' \"$sign_in_payload\" | \\\n jq '. + {acknowledge_uninspected_schedules:true}')\nsign_in=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d \"$sign_in_payload\" http://relay/setup/sign-in)\nprintf '%s\\n' \"$sign_in\" | jq \\\n '{ok, data: {\n status: .data.status,\n reused_login: .data.reused_login,\n workspace: .data.workspace\n }, error}'\n```\n\nPrint a device link and code only when the response actually contains a code:\n\n```sh\nuser_code=$(printf '%s\\n' \"$sign_in\" | jq -r '.data.user_code // empty')\nif test -n \"$user_code\"; then\n verification_url_complete=$(printf '%s\\n' \"$sign_in\" | \\\n jq -r '.data.verification_url_complete // empty')\n if test -n \"$verification_url_complete\"; then\n printf 'Open %s and approve code %s\\n' \"$verification_url_complete\" \"$user_code\"\n else\n verification_url=$(printf '%s\\n' \"$sign_in\" | \\\n jq -r '.data.verification_url // empty')\n printf 'Open %s and enter code %s\\n' \"$verification_url\" \"$user_code\"\n fi\nfi\n```\n\nIf the response is `signed_in` with `reused_login:true`, the app reused an\neligible same-origin CLI or desktop login and intentionally issued no device\ncode. Confirm the returned workspace id and name with the human rather than\nwaiting for a code, then continue only if it is the intended workspace.\n\nFor `pending_approval`, the human can approve from any phone or browser. Do not\ncreate another code while this one is pending. A `preparing` response is also\nnormal: poll `/setup/status` at the reported interval. Preparing local history\ncan take about a minute when the app reuses an existing `agent-relay` CLI login.\nPoll both states until sign-in finishes. The reason for a failure is in\n`.data.sign_in_message`, not in `.data.sign_in` (which only says `error`), so\nprint it, and map the known messages to their fix instead of dumping raw state:\n\n```sh\nwhile :; do\n state=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status)\n phase=$(printf '%s' \"$state\" | jq -r '.data.sign_in // .error.code')\n case \"$phase\" in\n signed_in)\n printf '%s\\n' \"$state\" | jq \\\n '{sign_in: .data.sign_in, workspace: .data.workspace}'\n break\n ;;\n preparing|pending_approval) relay_unknown=0 ;;\n signed_out|not_signed_in)\n # signed_out: the attempt ended or was cancelled; not_signed_in: the error code.\n printf 'Sign-in is not in progress; send the sign-in request again (see Recovery).\\n' >&2\n exit 1\n ;;\n denied|expired|error)\n msg=$(printf '%s' \"$state\" | jq -r '.data.sign_in_message // .error.message // \"no message reported\"')\n printf 'Sign-in %s: %s\\n' \"$phase\" \"$msg\" >&2\n case \"$msg\" in\n *\"older managed history uploader\"*)\n printf 'See \"Sign-in blocked by a legacy uploader\" under Recovery.\\n' >&2 ;;\n *\"older upload schedules\"*)\n printf 'Inspect legacy schedules, then retry with acknowledge_uninspected_schedules (see above).\\n' >&2 ;;\n *) printf '%s\\n' \"$state\" | jq '.data' >&2 ;;\n esac\n exit 1\n ;;\n *)\n # A failed or empty status read: allow three in a row, then stop and report.\n relay_unknown=$(( ${relay_unknown:-0} + 1 ))\n printf 'Unexpected sign-in state: %s (attempt %s of 3)\\n' \"${phase:-none}\" \"$relay_unknown\" >&2\n if test \"$relay_unknown\" -ge 3; then\n printf 'Agent Relay status is unreadable; check that the app is running, then retry sign-in.\\n' >&2\n exit 1\n fi\n ;;\n esac\n relay_interval=$(printf '%s' \"${sign_in:-}\" | jq -r '.data.interval // empty' 2>/dev/null)\n sleep \"${relay_interval:-5}\"\ndone\n```\n\n## 4. Apply the agent-led defaults and register this session\n\nRead the current sharing mode before changing it. `/setup/status` reports it as\n`.data.sharing_mode` (`new`, `all`, or `selected`; `new` when nothing is\nuploading yet). The probe reports the same field, `sharing_mode`, in\n`agent-relay-probe status --account --workspace --json`.\n\n```sh\nrelay_prior_mode=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status | jq -r '.data.sharing_mode // empty')\ncase \"$relay_prior_mode\" in\n new|all|selected) printf 'sharing_mode=%s\\n' \"$relay_prior_mode\" ;;\n *) printf 'Could not read the sharing mode; ask the human before changing sharing.\\n' >&2\n exit 1 ;;\nesac\n```\n\nContinue only with a verified `new`, `all`, or `selected`. If the status read\nfails or reports no mode, retry once; if it is still unknown, stop and ask the\nhuman how to proceed rather than sending any sharing request.\n\nOn a fresh install, or when it already reports `new`, keep `new`. When an\nexisting install reports `all` (which also uploads past sessions, so it is\nbroader than this skill's default) or `selected`, tell the human the current\nmode and ask whether to keep it or change it to `new`. Never change it silently.\nOnly when the human explicitly chooses a different mode, set\n`relay_sharing_mode` to that answer at the top of the next block; left unset,\nthe block keeps the mode the app reports, so nothing changes without an answer.\nRemember the chosen mode: section 8 checks it again, and shell variables do not\nsurvive between separate tool calls.\n\nThen set and verify all three agent-led defaults even when the app's `/setup/*`\nbootstrap already applied them. Sharing mode `new` uploads every new session;\nauto-activation puts every existing live session and every future session on\nthe relay; direct delivery sets Claude Code `crossSessionInbound` to `accept`\nby default, the same default the app's first-run setup applies.\n\nThe default never overrides a value the human set: `{\"default\":true}` writes\n`accept` only when `~/.claude/settings.json` does not contain the\n`crossSessionInbound` key, and answers the human's own value as `user_choice`.\nA present key with any value (`hold`, `refuse`) is an explicit choice: keep it,\nreport it, and continue setup. Only POST `{\"enabled\":true}` over it when the\nhuman asks for direct delivery after being told. A build older than this\ndefault answers `invalid_enabled`; there the skill reads the settings file\nitself and POSTs `{\"enabled\":true}` only after proving the key is absent (no\nfile, or exactly one JSON object without the key) or already `accept`, so an\nolder build keeps an explicit choice too. A file that exists but cannot be read or parsed (blank,\nbroken, not one object) proves nothing, and neither does a value other than\n`hold`, `refuse` or `accept` (even `\"\"`): stop and ask the human rather than\nwriting over it. Only `hold` and `refuse` are kept choices this skill acts on:\n\n```sh\n# Unset keeps the current mode; set it only to the human's explicit answer.\nif test -z \"${relay_sharing_mode:-}\"; then\n relay_sharing_mode=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status | jq -r '.data.sharing_mode // empty')\nfi\ncase \"$relay_sharing_mode\" in\n new|all|selected) ;;\n *) printf 'Sharing mode unknown; ask the human before changing sharing.\\n' >&2\n exit 1 ;;\nesac\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). setting-up-agent-relay-sessions\n# section 2 mirrors this helper; change both together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nfile_choice=$(claude_inbound_choice)\ndirect_delivery=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"default\":true}' http://relay/setup/direct-delivery)\nif printf '%s\\n' \"$direct_delivery\" | jq -e '.error.code == \"invalid_enabled\"' >/dev/null; then\n case \"$file_choice\" in\n absent|present:accept)\n direct_delivery=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"enabled\":true}' http://relay/setup/direct-delivery) ;;\n present:hold|present:refuse)\n direct_delivery=$(jq -nc --arg choice \"${file_choice#present:}\" \\\n '{ok: true, data: {direct_delivery: false, user_choice: $choice}}') ;;\n *)\n printf 'Claude Code settings are unreadable or name crossSessionInbound as something other than hold, refuse or accept (%s), so delivery was left unchanged; ask the human.\\n' \\\n \"$file_choice\" >&2\n exit 1 ;;\n esac\nfi\nprintf '%s\\n' \"$direct_delivery\" | jq\nkept_choice=$(printf '%s\\n' \"$direct_delivery\" | \\\n jq -r 'select(.ok and .data.direct_delivery == false) | .data.user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\nif printf '%s\\n' \"$direct_delivery\" | jq -e '.ok and .data.direct_delivery == false and\n .data.user_choice != null and (.data.user_choice | IN(\"hold\", \"refuse\", \"accept\") | not)' >/dev/null; then\n printf 'Your crossSessionInbound is set to an unrecognized value, so delivery was left unchanged; ask the human.\\n' >&2\n exit 1\nfi\nif [ -n \"$kept_choice\" ]; then\n case \"$kept_choice\" in\n hold) effect='relayed messages to Claude Code wait for your approval' ;;\n refuse) effect='relayed messages to Claude Code are dropped' ;;\n esac\n printf 'Kept your own crossSessionInbound value \"%s\": %s.\\n' \"$kept_choice\" \"$effect\" >&2\nelif ! printf '%s\\n' \"$direct_delivery\" | jq -e '.ok and .data.direct_delivery'; then\n if printf '%s\\n' \"$direct_delivery\" | jq -e '.error.code == \"managed_policy\"' >/dev/null; then\n printf 'Organization-managed Claude settings forbid direct delivery.\\n' >&2\n fi\n exit 1\nfi\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d \"$(jq -nc --arg mode \"$relay_sharing_mode\" '{mode:$mode}')\" \\\n http://relay/setup/sharing | jq\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{\"enabled\":true}' \\\n http://relay/setup/auto-activate | jq\nregister=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n -H 'Content-Type: application/json' \\\n -d '{}' http://relay/register)\nprintf '%s\\n' \"$register\" | jq\nregistration_status=$(curl -sS --max-time 60 --unix-socket \"$relay_socket\" \\\n http://relay/setup/status)\nprintf '%s\\n' \"$registration_status\" | \\\n jq '{session: .data.session, direct_delivery: .data.direct_delivery, error}'\nprintf '%s\\n' \"$registration_status\" | jq -e --arg kept \"$kept_choice\" \\\n '.ok and (.data.session.id | type == \"string\" and length > 0) and\n .data.session.registered == true and\n (.data.session.direct_delivery == true or\n (.data.session.direct_delivery == false and $kept != \"\"))'\n```\n\n**Codex on macOS.** The system `curl` hides a Codex session's identity from the\nsocket there, so `/register` is refused `not_a_relay_session` (section 6 says\nthe same for subscriptions). In a Codex session on macOS, find `relay_probe`\nwith section 6's first block and send the registration through it instead of\nthe `curl` line above, then read `/setup/status` as above:\n\n```sh\nregister=$(printf '{}' | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method POST --path /register)\n```\n\nIf `/setup/sign-in` is refused the same way, send its payload through the probe\nthe same way. Without a probe, stop and report it rather than registering\nanother process.\n\nDirect delivery is checked first so a managed-policy refusal stops setup\nbefore auto-activation or upload settings are changed. Report that refusal\nclearly; never claim the three-default setup completed. A kept explicit\nchoice is not a refusal: report it as \"direct delivery kept off at your\n`crossSessionInbound` value\" (with `hold`, relayed messages wait for the\nhuman's approval; with `refuse`, they are dropped) and finish the rest of\nsetup.\n\nSharing mode and auto-activate are different settings: the former\ncontrols upload eligibility and the latter controls Relay registration. The\nsetup flow requires both.\n\n`POST /register` discovers the nearest live Codex or Claude ancestor. A session\ninside tmux over SSH is valid. Never pass a session id or token in the body.\nThe default name includes a 128-bit session-derived suffix, so dozens of\nsessions in the same checkout receive distinct `name@direct` addresses. Save\nand report the address from `/setup/status` for each session; never reuse one\nsession's address for another. `/register` may omit the session id or\ndirect-delivery state, so its response alone is not verification. Require the\nstatus response to show this session's non-empty id, `registered:true`, and\n`session.direct_delivery:true`, or `session.direct_delivery:false` exactly\nwhen the human kept their own `crossSessionInbound` (a missing field is never\nsuccess), as above. Do not override a kept choice to satisfy this check; only\nPOST `{\"enabled\":true}` when the human says yes after being told. An app fix is expected to add those\nfields to `/register`, but the status check remains authoritative.\n\n## 5. Create and test the webhook\n\n**When a kept choice holds this session's messages.** If `/setup/status`\nshows `session.direct_delivery:false` because the human kept their own\n`crossSessionInbound` (a Claude Code session; Codex is unaffected), the\ninjection checks in sections 5–7 cannot complete by themselves. With `hold`,\nthe test message arrives as a held message: ask the human to approve it, then\nconfirm the marker and message id as below. With `refuse`, it is dropped:\ncreate the webhook or subscription if asked, but report its injection test as\nunverified because of the kept choice, rather than waiting for a marker that\ncannot arrive.\n\nPerform this section only when the human requested a webhook (or explicitly\nasked for the full end-to-end setup). Otherwise skip it; webhook creation is a\npersistent external mutation, not an install prerequisite.\n\nCapture the one-time secret without echoing it:\n\n```sh\numask 077\nhook_file=$(mktemp)\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" -H 'Content-Type: application/json' \\\n -d '{}' http://relay/webhooks >\"$hook_file\"\njq '{ok, created: .data.created, webhook_id: .data.webhook_id, url: .data.url, error}' \"$hook_file\"\n```\n\nWhen `created:true`, send a unique marker. Initialize every temporary path so\ncleanup can name and delete exact files without `-f` or globs:\n\n```sh\nhook_config=\nmarker=\nwebhook_post_ok=false\nwebhook_post_status=0\nif test \"$(jq -r '.data.created // false' \"$hook_file\")\" = true && \\\n test \"$(jq -r '.data.secret // empty' \"$hook_file\")\" != ''; then\n hook_url=$(jq -r '.data.url' \"$hook_file\")\n hook_secret=$(jq -r '.data.secret' \"$hook_file\")\n hook_config=$(mktemp)\n chmod 600 \"$hook_config\"\n printf 'header = \"Authorization: Bearer %s\"\\n' \"$hook_secret\" >\"$hook_config\"\n marker=\"agent-relay-webhook-test-$(date +%s)\"\n if curl -fsS --config \"$hook_config\" -X POST \"$hook_url\" \\\n -H 'Content-Type: application/json' \\\n -d \"{\\\"text\\\":\\\"$marker\\\",\\\"source\\\":\\\"agent-driven-setup\\\"}\"; then\n webhook_post_ok=true\n else\n webhook_post_status=$?\n fi\nelse\n printf 'Webhook already exists; its original secret is required to repeat delivery verification.\\n' >&2\nfi\nif test -n \"$hook_config\" && test -e \"$hook_config\"; then\n rm -- \"$hook_config\"\nfi\nif test -e \"$hook_file\"; then\n rm -- \"$hook_file\"\nfi\nunset hook_secret\nif test \"$webhook_post_ok\" = true; then\n printf 'Webhook test sent; end this turn and wait for marker: %s\\n' \"$marker\"\nelif test -n \"$marker\"; then\n printf 'Webhook test POST failed with curl status %s; do not wait for delivery.\\n' \\\n \"$webhook_post_status\" >&2\n exit \"$webhook_post_status\"\nfi\n```\n\nDo not claim success merely because the POST returned 2xx, and do not sleep or\npoll for the injected message during the same turn. Relay injects only after\nthe session becomes idle between turns. After the POST and exact-file cleanup,\nend the current turn by saying that setup is waiting for the exact marker. On\nthe next turn, confirm that the injected body contains that marker and its\nAgent Relay header contains a full message id; record that id in the final\nevidence. If a polling consumer is part of the setup, also observe that same id\nthere before declaring the webhook test complete. If the webhook already\nexisted, its secret is correctly returned as null; do not revoke or recreate it\nwithout human authorization. Ask for the existing secret or report that the\ndelivery test could not be repeated.\n\n## 6. Subscribe requested integrations\n\nRequire the provider and repository from the human. A GitHub pull request needs\none resource glob, its number (`pulls/NUMBER/**`). Relayfile matches the PR's\nreviews, review comments, conversation comments and check runs to that glob by\nthe PR they reference, so no other glob is needed: nothing is written under\n`pulls/NUMBER/reviews/**`, `pulls/NUMBER/status/**` or\n`issues/NUMBER/comments/**`, and subscriptions on those never deliver. This\nworks on every released Desktop and is the path to use today. If a review or\ncomment then never arrives, read it back in relayfile at its own path (the\ntable in `subscribing-relay-webhooks-and-writebacks`): a 404 means the event\nnever reached relayfile\n([relayfile-cloud#317](https://github.com/AgentWorkforce/relayfile-cloud/issues/317)),\nnot that the subscription is wrong.\n\nFirst find the Desktop's `agent-relay-probe`. macOS needs it for Codex sessions\n(below) and it is the client for the one-command form once that ships:\n\n```sh\nrelay_probe=\nfor candidate in \\\n \"$HOME/.local/bin/agent-relay-probe\" \\\n /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \\\n \"/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe\"\ndo\n if test -x \"$candidate\"; then relay_probe=$candidate; break; fi\ndone\nif test -z \"$relay_probe\" && test -d \"$HOME/.local/lib/agent-relay\"; then\n relay_probe=$(find \"$HOME/.local/lib/agent-relay\" -type f -perm -u+x \\\n -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null)\nfi\nprintf 'relay_probe=%s\\n' \"${relay_probe:-none}\"\n```\n\nA running Desktop keeps `~/.local/bin/agent-relay-probe` at its own version; the\nother candidates are the copies bundled by the `.deb`, the macOS app, and the\nper-user tarball. Then subscribe through the socket. On macOS use the probe\nclient when there is one: the system `curl` hides a Codex session's identity\nthere and is refused `not_a_relay_session`, so a Codex session with no probe\nstops instead. `curl` is correct for Claude Code and on Linux:\n\n```sh\n# One client for every socket request in this section and in undo: the probe\n# on macOS (system curl hides a Codex session there), curl elsewhere.\nrelay_req() { # METHOD PATH [JSON body]\n if test \"$(uname -s)\" = Darwin && test -n \"${relay_probe:-}\"; then\n printf '%s' \"${3:-}\" | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method \"$1\" --path \"$2\"\n elif test \"$(uname -s)\" = Darwin && test -n \"${CODEX_THREAD_ID:-}\"; then\n printf 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.\\n' >&2\n return 1\n elif test -n \"${3:-}\"; then\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \\\n -H 'Content-Type: application/json' -d \"$3\" \"http://relay$2\"\n else\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \"http://relay$2\"\n fi\n}\n\n# This PR's subscriptions, in whichever form they were made. Fails, rather than\n# answering \"none\", when the status cannot be read: both callers stop then.\npr_url='https://github.com/OWNER/REPO/pull/NUMBER'\npr_resources() {\n relay_status=$(relay_req GET /setup/status) || return 1\n # -s reads the whole body as one list, so an empty body is an error too.\n printf '%s\\n' \"$relay_status\" | jq -rs --arg url \"$pr_url\" \\\n --arg short 'OWNER/REPO#NUMBER' \\\n --arg pulls '/github/repos/OWNER/REPO/pulls/NUMBER/' \\\n --arg issues '/github/repos/OWNER/REPO/issues/NUMBER/' '\n if length == 1 and .[0].ok == true\n and (.[0].data.integrations | type) == \"array\" then\n .[0].data.integrations[] | select(.provider == \"github\") | .resource\n | select(. == $url or . == $short or startswith($url + \"/\")\n or startswith($pulls) or startswith($issues))\n else\n error(\"Agent Relay status is unreadable; no subscription was changed.\")\n end'\n}\n\nheld=$(pr_resources) || exit 1\nif printf '%s\\n' \"$held\" | grep -Fqx -e \"$pr_url\" -e 'OWNER/REPO#NUMBER'; then\n # The one-command subscription is a single complete request: nothing to add.\n printf 'Already subscribed:\\n%s\\n' \"$held\"\nelif test -z \"$held\" && test -n \"${relay_probe:-}\" && \\\n \"$relay_probe\" relay --help 2>&1 | grep -q '^ subscribe'; then\n # The one-command form (below), only for a PR with no subscription yet and\n # only when the installed probe has it.\n \"$relay_probe\" relay subscribe \"$pr_url\" | jq\nelse\n relay_req POST /integrations/subscribe \\\n \"$(jq -nc --arg resource '/github/repos/OWNER/REPO/pulls/NUMBER/**' \\\n '{provider:\"github\",resource:$resource}')\" | jq\nfi\nrelay_req GET /setup/status | jq -c '.data.integrations'\n```\n\nRequire `.ok` and `.data.subscribed` to be `true`, and the entry in the status\nlist to be `ready: true`. A repeated request returns HTTP 409\n`conflict` (\"already subscribed\"). That is success only for a binding made on\nor after 2026-09-19. Earlier bindings predate PR-identity matching\n(relayfile-cloud#237, relaycast#447), match paths literally and miss reviews\nand comments. If the binding may be older, or you cannot tell, recreate it\nonce: `relay_req DELETE /integrations/subscribe` with the same body, then\nsubscribe again. Verified on\nDesktop 2026.10.6 with both the probe client and `curl`.\n\n**One-command form (not yet released).** `relay-desktop#216` adds\n`agent-relay-probe relay subscribe 'https://github.com/OWNER/REPO/pull/NUMBER'`\n(or `OWNER/REPO#NUMBER`), which makes one numeric `pulls/NUMBER/**` subscription\nthat the Desktop correlates with the PR's reviews, comments, and checks. It is\nabsent from Desktop 2026.10.6, where it fails with `unrecognized subcommand\n'subscribe'`, so do not lead with it. Use it only when the installed probe\nadvertises it. The block above makes that choice, and first checks the\nsession's existing subscriptions. A pull request already subscribed with the\none-command form is left alone. A pull request already holding\nthe number glob answers 409 `conflict` on a repeat; recreate that binding once\nif it may predate 2026-09-19 (see above). Extra `reviews`, `status` or\n`issues/NUMBER/comments` globs from an earlier version of this skill never\ndeliver; unsubscribe them.\n\nOnce released, re-test it before relying on its details; the behavior described\nin that PR (a repeat answering `\"already_subscribed\":true`, `--remove` to end it,\nand a numeric subscription covering reviews, comments and checks) has not been verified here.\n\nRefusals and what they mean:\n\n- `not_a_relay_session`, \"Register this session before subscribing it\": run\n section 4's `POST /register` first.\n- `not_a_relay_session`, \"already managed by this computer's relay\" from\n `/register`: this session was started by the Agent Relay fleet broker, not\n the Desktop. Use the workspace CLI below instead.\n- `refused` or `busy`: an earlier subscription is still being made or cleaned\n up; repeat the command shortly.\n\nA fleet-spawned session, or an operator subscribing a named agent, uses the\n`agent-relay` CLI with `--to @AGENT_NAME` (a spawned worker's name is in\n`RELAY_AGENT_NAME`). **`agent-relay` 13.0.0 is the minimum.** Older CLIs let\nthe worker's ambient `RELAY_AGENT_TOKEN` displace the workspace key and fail\nwith `Workspace key required (rk_live_...)` even though `RELAY_WORKSPACE_KEY`\nis set:\n\n```sh\npull_glob='/github/repos/OWNER/REPO/pulls/NUMBER/**'\n# Shell variables do not survive between tool calls: when relay_cli is unset\n# here, find the installed CLI again with section 2's helper, so an installed\n# CLI is not replaced by the npx fallback.\n# The installed Agent Relay CLI: user-level npm, mise or nvm, then PATH;\n# never the .deb's /usr/bin/agent-relay Desktop launcher. Section 2 and\n# section 6 define this identically; change both together.\nfind_relay_cli() {\n for candidate in \\\n \"$HOME/.local/bin/agent-relay\" \\\n \"$HOME/.npm-global/bin/agent-relay\" \\\n \"$HOME/.agentworkforce/relay/bin/agent-relay\"\n do\n if test -x \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"; return\n fi\n done\n for relay_root in \\\n \"$HOME/.local/share/mise/installs/node\" \\\n \"$HOME/.nvm/versions/node\"\n do\n test -d \"$relay_root\" || continue\n candidate=$(find -L \"$relay_root\" -mindepth 3 -maxdepth 3 \\\n -path '*/bin/agent-relay' -type f -perm -u+x -print -quit 2>/dev/null)\n if test -n \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"; return\n fi\n done\n candidate=$(command -v agent-relay 2>/dev/null || true)\n if test -n \"$candidate\" && test -x \"$candidate\" && test \"$candidate\" != /usr/bin/agent-relay; then\n printf '%s\\n' \"$candidate\"\n fi\n}\ntest -n \"${relay_cli:-}\" || relay_cli=$(find_relay_cli)\nrelay_cli_major=\nif test -n \"${relay_cli:-}\"; then\n relay_cli_major=$(\"$relay_cli\" --version 2>/dev/null | sed -n 's/^[^0-9]*\\([0-9][0-9]*\\)\\..*/\\1/p' | sed -n '1p')\nfi\nif test -n \"$relay_cli_major\" && test \"$relay_cli_major\" -ge 13; then\n \"$relay_cli\" integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nelif test -n \"$relay_cli_major\"; then\n # 12.x and older: keep the ambient agent token from displacing the key.\n env -u RELAY_AGENT_TOKEN \"$relay_cli\" integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nelse\n # No usable installed CLI: run the fixed release without installing it.\n npx -y agent-relay@13 integration subscribe github \\\n --resource \"$pull_glob\" --to '@AGENT_NAME' --no-input\nfi\n```\n\nUse the `relay_cli` found in section 2 (re-discovered above when this runs in\na new shell), never a bare `agent-relay`: on a\n`.deb` host `/usr/bin/agent-relay` is the Desktop launcher, and `PATH` may\nresolve to it or to a different, older CLI than the one whose version was\nchecked.\n\nThe CLI path follows `orchestrating-agent-relay`, which subscribes the same\nsingle `pulls/NUMBER/**` glob. Never put the workspace key in argv.\n\nMake the subscribe step a required post-create step in the agent's PR\nworkflow: whenever it authors a PR, it subscribes its own live session\nimmediately, without waiting for an operator to name the PR. This is the\nauthored-PR pattern from `orchestrating-agent-relay`; it covers every future PR\nthe agent authors without subscribing unrelated repository traffic. The\nDesktop removes the subscription itself after the PR closes or merges.\nFor real GitHub verification, use a harmless event the human authorized\non that PR (for example, a test conversation comment and a requested bot\nreview). After causing the event, do not wait or poll within the active turn:\nend the turn saying which event marker is expected. Confirm the injected event\nand record its full message id on the next turn after Relay has delivered it.\nDo not create a comment, review, or rerun without authorization. A successful\nsubscribe response alone does not prove end-to-end delivery. With a kept\n`hold` or `refuse`, follow the note at the start of section 5.\n\n## 7. Coexist with a REST-polling MCP\n\nDo not disable an existing polling consumer. Desktop injection acknowledges\nonly this registered agent's delivery queue and does not advance a separate\nworkspace consumer's cursor. A polling MCP should use its own identity/cursor,\nnot this session agent's `/v1/deliveries` token.\n\nInjected text contains both the full Relaycast message id and a short `ref`.\nWhen polling and injection can surface the same workspace message, deduplicate\non the full message id before acting. During verification, leave the poller\nrunning and observe the test message there. Then end the current turn so Relay\ncan inject it, and on the next turn confirm the same full id arrived while the\nagent acted only once.\n\n## 8. Final verification and report\n\nRead status again:\n\n```sh\ncurl -sS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status | \\\n jq '{version: .data.version, sign_in: .data.sign_in, workspace: .data.workspace, sharing_mode: .data.sharing_mode, auto_activate: .data.auto_activate, direct_delivery: .data.direct_delivery, direct_delivery_user_choice: .data.direct_delivery_user_choice, defaults_error: .data.defaults_error, uploader: .data.uploader, session: .data.session, webhook: .data.webhook, integrations: .data.integrations}'\n```\n\nRequire all three defaults before declaring setup complete. Set\n`relay_sharing_mode` again to the mode chosen in section 4 (`new`, or the\nexisting mode the human kept); the check refuses to guess it. This block runs\nin a fresh shell after the turns of sections 5–7, so it also derives the\nkept direct-delivery choice again: from status's\n`direct_delivery_user_choice`, or from the settings file on a build older\nthan that field:\n\n```sh\n: \"${relay_sharing_mode:?set relay_sharing_mode to the mode chosen in section 4}\"\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). setting-up-agent-relay-sessions\n# section 2 mirrors this helper; change both together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nfinal_status=$(curl -fsS --max-time 60 --unix-socket \"$relay_socket\" http://relay/setup/status)\nkept_choice=$(printf '%s\\n' \"$final_status\" | \\\n jq -r 'select(.data.direct_delivery == false) | .data.direct_delivery_user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\n# A build older than direct_delivery_user_choice: read the settings file.\nif [ -z \"$kept_choice\" ] && printf '%s\\n' \"$final_status\" | \\\n jq -e '.data.direct_delivery == false and (.data | has(\"direct_delivery_user_choice\") | not)' >/dev/null; then\n final_choice=$(claude_inbound_choice)\n case \"$final_choice\" in\n present:hold) kept_choice=hold ;;\n present:refuse) kept_choice=refuse ;;\n *) printf 'Direct delivery is off and no kept hold or refuse could be confirmed (%s); do not enable it here, ask the human.\\n' \\\n \"$final_choice\" >&2 ;;\n esac\nfi\nprintf '%s\\n' \"$final_status\" | jq '{direct_delivery: .data.direct_delivery, direct_delivery_user_choice: .data.direct_delivery_user_choice}'\nprintf '%s\\n' \"$final_status\" | \\\n jq -e --arg mode \"$relay_sharing_mode\" --arg kept \"$kept_choice\" '.ok and .data.sharing_mode == $mode and\n .data.auto_activate == true and\n (.data.direct_delivery == true or (.data.direct_delivery == false and $kept != \"\"))'\n```\n\nReport the exact version, `signed_in`, signed-in workspace id and name, sharing\nmode (`new`, or the existing mode the human kept), auto-activate `true`,\ndirect-delivery `true` (or the human's kept `crossSessionInbound` value),\nuploader health, session address, direct-delivery state, webhook test marker,\nsubscriptions, real GitHub event evidence, and polling-coexistence result.\nSeparate verified facts from steps that still require a human or external\nevent.\n\n## Recovery and undo\n\n- **Socket missing or slow:** check `systemctl --user status agent-relay.service`\n on Linux or `open -a \"Agent Relay\"` on macOS. Confirm the pointer names a\n socket. Never delete a live socket; restart the owning service/app. A reply\n that takes up to about 30 seconds is the known latency in section 1\n (relay-desktop#333), not a fault: allow 60 seconds and retry once.\n- **`not_a_relay_session`:** the command is not a descendant of a supported\n live Codex/Claude session, or the session id/process start no longer matches.\n Run it through this agent's shell tool. In tmux, confirm the agent process and\n shell share the pane's process tree.\n- **Sign-in blocked by a legacy uploader** (`sign_in: error`, message \"An older\n managed history uploader is active. Stop it explicitly before enabling this\n probe.\"): the probe counts any of these legacy `ai-hist push` registrations as\n active, even when idle: the file\n `~/Library/LaunchAgents/com.ai-hist.push.plist` (even if unloaded), a loaded\n launchd label `com.ai-hist.push`, or a crontab line containing\n `# ai-hist push (managed)`. Check each, then with the human's approval unload\n and move aside (do not delete) what exists. Create the backup directory first,\n use a unique no-clobber destination, and confirm the move before retrying:\n\n ```sh\n # bootout exits non-zero when the label was never loaded; that is fine, so\n # judge success by whether the label is still loaded, not by its exit code.\n launchctl bootout \"gui/$(id -u)/com.ai-hist.push\" 2>/dev/null || true\n if launchctl list | awk '{print $NF}' | grep -qx com.ai-hist.push; then\n echo 'com.ai-hist.push is still loaded; do not retry sign-in' >&2\n exit 1\n fi\n plist=\"$HOME/Library/LaunchAgents/com.ai-hist.push.plist\"\n if test -e \"$plist\"; then\n backup_dir=\"$HOME/.agentworkforce/backup\"\n mkdir -p \"$backup_dir\"\n mv -n \"$plist\" \"$backup_dir/com.ai-hist.push.plist.bak-$(date +%Y%m%d%H%M%S)\"\n test ! -e \"$plist\" || { echo 'plist was not moved; do not retry sign-in' >&2; exit 1; }\n fi\n ```\n\n If instead the marked cron line is the cause, back up the full crontab first\n and remove only that line, so the change can be reversed with\n `crontab `:\n\n ```sh\n mkdir -p \"$HOME/.agentworkforce/backup\"\n cron_backup=\"$HOME/.agentworkforce/backup/crontab.bak-$(date +%Y%m%d%H%M%S)\"\n crontab -l > \"$cron_backup\" && test -s \"$cron_backup\" || { echo 'crontab backup failed; do not edit it' >&2; exit 1; }\n grep -vF '# ai-hist push (managed)' \"$cron_backup\" | crontab -\n ! crontab -l | grep -qF '# ai-hist push (managed)' || { echo 'cron line still present; do not retry sign-in' >&2; exit 1; }\n ```\n\n Leave `com.ai-hist.sync` alone; it is not checked. Then re-run\n `/setup/sign-in` with the same payload.\n- **`not_signed_in`:** run `/setup/sign-in`; do not paste tokens into the\n request. Restart after `expired` or `denied` to obtain a new code.\n- **Older schedules could not be inspected:** inspect the user's legacy cron,\n launchd, or user-service upload schedules first. Only after that inspection,\n retry `/setup/sign-in` with the original payload plus\n `\"acknowledge_uninspected_schedules\":true`. This flag records a completed\n safety check; never send it merely to silence an error.\n- **`not_allowed` from `/register`:** enable the app's self-registration\n setting. Do not bypass it with workspace credentials.\n- **Managed Claude policy:** if direct delivery reports `managed_policy`, the\n organization controls `crossSessionInbound`; report the policy block rather\n than modifying managed settings.\n- **Undo registration:** `curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X DELETE http://relay/register | jq`.\n- **Undo an integration:** with section 6's `relay_probe`, `relay_socket`,\n `relay_req`, and `pr_resources` defined, remove every subscription the\n session holds for the pull request, in whichever form it was made. On macOS\n `relay_req` uses the probe, and a Codex session without one stops instead of\n sending a `curl` request that cannot be identified:\n\n ```sh\n held=$(pr_resources) || exit 1\n printf '%s\\n' \"$held\" | while IFS= read -r resource; do\n test -n \"$resource\" || continue\n relay_req DELETE /integrations/subscribe \\\n \"$(jq -nc --arg resource \"$resource\" '{provider:\"github\",resource:$resource}')\" | jq\n done\n relay_req GET /setup/status | jq -c '.data.integrations'\n ```\n\n Require the final list to hold none of that pull request's resources.\n- **Undo a webhook:** `curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X DELETE http://relay/webhooks | jq`.\n- **Undo direct delivery:** POST `{\"enabled\":false}` to\n `/setup/direct-delivery`. On Claude this writes `\"crossSessionInbound\":\n \"hold\"` (Claude Code's own default) on current builds, an explicit choice\n that no later default turns back on; older builds removed the key instead,\n so there set `\"hold\"` by hand to keep it off. Managed organization policy\n still wins.\n- **Undo sharing:** POST the human's prior mode (`selected`, `new`, or `all`) to\n `/setup/sharing`. Ask before changing it when the earlier value is unknown.\n- **Auto-activate accidentally enabled:** POST `{\"enabled\":false}` to\n `/setup/auto-activate`. This does not remove existing registrations.\n- **Uninstall:** stop/disable the user service before removing the Linux\n package, or quit the Mac app before removing it. Do not delete app data or\n revoke webhooks unless the human explicitly asks for data removal.\n", + "setting-up-agent-relay-sessions": "# Set Up Agent Relay Session Handoff\n\nDrive the whole onboarding from this agent's own shell and MCP tools. Do **not**\nhand the person the six-step Markdown guide and ask them to click through the\napp and the dashboard.\n\nPrefer setting this up with the Agent Relay agent at\n, which checks your current state and confirms\neach step. Fetch its instructions with\n`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`.\n\nThe finished state is:\n\n- the desktop app is installed, signed in to the **intended workspace**, the\n uploader is healthy, and this session is registered with direct delivery on,\n or with the person's own `crossSessionInbound` (`hold` or `refuse`) kept and\n reported (see section 2; delivery is never turned on without their yes);\n- the **agent-sessions cloud MCP** is installed in this project and loaded by\n this session, so the handoff tools (`list_relay_agents`, `send_relay_message`,\n `read_relay_conversation`, `check_relay_inbox`, `get_shared_session`,\n `get_shared_session_context`, `search_shared_sessions`) are available;\n- a live round trip is proven when a teammate is online: this agent messages the\n teammate's agent, **reads the teammate's real session back** with\n `get_shared_session_context`, and the teammate reads this session and replies.\n When no teammate is online yet, the fallback is the roster plus this person's\n own uploaded session reading back cleanly — reported as self-verified, not as a\n proven two-way handoff.\n\n## Requires\n\nThis skill builds on `setting-up-agent-relay-desktop` (verified install, sign-in,\nregistration, agent-led defaults). If that skill is **not installed** in this\nagent, stop, tell the person, and have them install the companions together,\nthen restart the agent. Do not improvise the install or sign-in from memory.\n\n- prpm: `npx prpm install collections/agent-relay-setup --as claude --global -y`\n- skills.sh: `npx skills add https://github.com/AgentWorkforce/skills --skill setting-up-agent-relay-desktop --skill setting-up-agent-relay-sessions`\n- Codex: do not use prpm's `--as codex` (it drops paragraphs and writes under `~/.agents/skills`, which Codex does not load as its global skill folder). Mirror both installed `SKILL.md` files into `~/.codex/skills//` with the loop in **Install this skill (and its dependency)** below.\n\n## The only human steps\n\nEverything else is automatable; be honest that these are not. **Ask for all of\nthem up front, before touching anything** — the token in particular is needed\nonly at section 3, but minting it and exporting it into the launching\nenvironment takes the person a few minutes, so have them start it while the\ndesktop install and sign-in run:\n\n1. **Approve one Google device-login link** — only when no reusable `agent-relay`\n CLI/desktop login exists. A reused login needs zero clicks.\n2. **Provide one Bearer token** minted in the dashboard's **\"Connect your agent\"**\n card. No setup route returns this token by design. **Do not have the person\n paste it into this conversation** — the uploader shares this session's prompts\n with teammates, so a pasted secret would become teammate-readable. Instead the\n person puts it into the **environment that launches their agent** (or an OS\n keychain / a mode-0600 file) via a shell *they* run, and this agent references\n it only by variable name — never by value. See **Token handling** below.\n3. **Same workspace, both sides.** The teammate (or workspace admin) must have\n invited this person's email to the **shared** workspace — two agents can only\n reach each other inside one workspace. Confirm the workspace id matches on\n both machines; do not guess it.\n\nOne structural note so expectations are right: there is **no automatic ack** to\nthe sender when a handoff is picked up, and **no partial session sharing** — a\nsession is on-or-off on the relay. Confirm a handoff by activity, not a push.\n\n## How this composes with `setting-up-agent-relay-desktop`\n\nThis skill is a thin layer on a working desktop. The desktop skill owns install,\nsign-in, defaults and self-registration; this one owns only the agent-sessions\ncloud MCP and the live handoff. Section 2 checks the desktop prerequisite and,\nif it is not met, invokes `/setting-up-agent-relay-desktop` to completion and\nreturns here. Keep the `/setup/*` details in that one skill so a contract change\nupdates it once.\n\n## Install this skill (and its dependency) for Codex and Claude Code\n\nWhen the task includes installing this skill, install its canonical Claude\npackage **and** the desktop skill it depends on, then mirror both unchanged\n`SKILL.md` files where Codex loads global skills:\n\n```sh\nfor pkg in setting-up-agent-relay-sessions setting-up-agent-relay-desktop; do\n npx prpm install \"@agent-relay/$pkg\" --as claude --global -y\n codex_skill=\"$HOME/.codex/skills/$pkg\"\n mkdir -p \"$codex_skill\"\n install -m 0644 \"$HOME/.claude/skills/$pkg/SKILL.md\" \"$codex_skill/SKILL.md\"\n cmp \"$HOME/.claude/skills/$pkg/SKILL.md\" \"$codex_skill/SKILL.md\"\ndone\n```\n\nWith skills.sh instead, use the command under **Requires**; it installs into each agent's skills folder directly, so the manual Codex mirror below is only for the prpm route.\n\nDo not use prpm's `--as codex` conversion for these packages: it writes under\n`~/.agents/skills`, which Codex does not load as its global skill folder, and it\ndrops explanatory paragraphs from the Markdown workflow. Confirm both final\npaths explicitly before continuing.\n\n## Why ordering matters\n\nMCPs load at agent **startup**. The agent-sessions MCP must be installed\n**before** the session that will use it starts. A session already running when\nthe MCP was added has no handoff tools and must be **restarted**. Install the\nMCP (section 3) before relying on the tools (sections 4–5); if this very session\npredates the MCP, say so and restart it rather than reporting missing tools as a\nfailure.\n\n## Token handling\n\nThe token must never land in the agent conversation, in a Relay message, in\nprocess argv, or in the repo. Three places leak it, and this skill avoids all\nthree:\n\n- **The chat** — this session is uploaded and teammate-readable, so a token\n pasted to the agent leaks to the workspace. The person sets it themselves (see\n below); the agent only ever sees the variable **name**.\n- **Process argv** — a token interpolated into `claude mcp add --header\n \"... Bearer $TOKEN\"` is visible to `ps` and gets written literally into\n `~/.claude.json`. Pass the unexpanded placeholder `${AGENT_RELAY_SESSIONS_TOKEN}`\n instead; Claude Code expands `${VAR}` in MCP headers from the environment at\n launch, so the stored config holds only a reference and argv holds only the\n literal `${…}`. **This expansion exists only in Claude Code ≥ 2.1.119** — an\n older client sends the placeholder literally and auth fails, so check the\n version first (section 3) and update Claude rather than inlining the token.\n- **The launching environment** — both CLIs read the token from an environment\n variable (`${VAR}` for Claude, `--bearer-token-env-var` for Codex), so the\n variable must exist in the environment that **launches the agent**, not in a\n transient shell-tool subprocess (which cannot alter its parent or a later\n process).\n\nUse the single variable name `AGENT_RELAY_SESSIONS_TOKEN` throughout. Keep the\none-time entry separate from durable supply:\n\n- **One-time, into the current shell** (non-echoing, run by the person, never\n the agent): `read -rs AGENT_RELAY_SESSIONS_TOKEN && export AGENT_RELAY_SESSIONS_TOKEN`,\n then launch the agent from *that* shell. Do **not** add this `read` to a shell\n profile — every shell that sources the profile would block waiting for input.\n- **Durable across restarts:** store the token in the OS keychain or a mode-0600\n file, and have the launcher export it **non-interactively**, e.g.\n `export AGENT_RELAY_SESSIONS_TOKEN=\"$(security find-generic-password -s agent-relay-sessions -w)\"`\n (macOS keychain) or from a 0600 file. Put that line in a dedicated launcher the\n person starts the agent from — not an interactive prompt in the profile.\n\nThe agent never prints the value; verify presence only with\n`test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"`.\n\n## 0. Preflight: collect the human inputs first\n\nBefore section 1, tell the person what they need and ask for it in one message:\n\n1. the **shared workspace id** (or the teammate/admin who invited them);\n2. a **Bearer token** minted in the dashboard's \"Connect your agent\" card for\n that workspace, stored by *them* as `AGENT_RELAY_SESSIONS_TOKEN` in the\n environment that launches their agent (see **Token handling** — never pasted\n into this chat);\n3. the **teammate's relay address** for the round trip, if one will be online.\n\nBefore sending that message, check what this machine already has. If an Agent\nRelay CLI 13.x is installed (a user-level npm, mise or nvm `agent-relay`, not\nthe `.deb`'s `/usr/bin/agent-relay` Desktop launcher), run these read-only\nchecks. Without one, section 2 reads the signed-in workspace from\n`/setup/status` instead. The CLI is optional for this skill: detect it as\nbelow, which skips the Desktop launcher that `command -v agent-relay` finds on\na `.deb` install, and do not install or upgrade it just for these checks.\n\n```sh\nrelay_cli=$(command -v agent-relay || true)\ncase \"$relay_cli\" in ''|/usr/bin/agent-relay) relay_cli= ;; esac\nrelay_cli_version=\nif test -n \"$relay_cli\"; then\n relay_cli_version=$(\"$relay_cli\" --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1)\nfi\n# Gate only these optional checks on 13.x; keep relay_cli itself, which\n# setting-up-agent-relay-desktop section 6 uses with its own version handling.\nrelay_cli_checks=\ncase \"$relay_cli_version\" in\n 13.*) relay_cli_checks=1 ;;\n *) test -n \"$relay_cli\" && printf 'agent-relay %s is not 13.x; read the workspace from /setup/status instead.\\n' \"${relay_cli_version:-unknown}\" >&2 ;;\nesac\nif test -n \"$relay_cli_checks\"; then\n \"$relay_cli\" cloud whoami # signed in, and as whom\n \"$relay_cli\" workspace active # the active Cloud workspace (keys stay masked)\n \"$relay_cli\" cloud workspaces # every workspace this login can use, with ids\n \"$relay_cli\" status # workspace, cloud login and local broker\nfi\n```\n\nIf the active workspace is already the shared one, confirm it with the person\ninstead of asking them to look it up. Never switch to or create another\nworkspace unless they explicitly ask.\n\nCheck presence without printing the value:\n\n```sh\ntest -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\" && echo token=present || echo token=missing\n```\n\nA missing token does **not** block sections 1–3: the MCP config stores only a\n`${AGENT_RELAY_SESSIONS_TOKEN}` reference, never the value, so it can be added\nnow. Do the desktop work and add the MCP, then have the person restart the agent\n**once**, from an environment that exports the token, so that single restart both\nloads the MCP and supplies the credential. Sections 4–5 are gated on that\nrestart. If the person would rather not defer, they can export the token and\nrestart first, which then costs a second restart only if the MCP was added after.\n\n## 1. Confirm the host and this session\n\nRun every command from this agent's own shell tool, and only from a **genuine\ninteractive** Claude Code or Codex session. The app identifies the caller from\nkernel credentials and resolves its process ancestry against discovered live\nsessions, so a command from an unrelated terminal — or from a non-interactive\n`codex exec` / `claude -p` run — is rejected with `not_a_relay_session`. (This\nis verified: a plain SSH shell and a `codex exec` invocation are both refused;\nan interactive session is admitted.)\n\n```sh\nuname -s; uname -m\ntest -n \"${SSH_CONNECTION:-}\" && printf 'transport=ssh\\n' || true\ntest -n \"${TMUX:-}\" && printf 'session=tmux\\n' || true\n```\n\nDefine the request helper once per shell. It finds the private setup socket\nwithout printing private files and uses the desktop's `agent-relay-probe` on\nmacOS (the system `curl` hides a Codex session's identity there and is refused\n`not_a_relay_session`), `curl` elsewhere, and stops with a message for a macOS\nCodex session that has no probe:\n\n```sh\npointer=\"$HOME/.agentworkforce/desktop/relay-socket\"\nif test -r \"$pointer\"; then relay_socket=$(sed -n '1p' \"$pointer\"); fi\nif test -n \"${relay_socket:-}\" && ! test -S \"$relay_socket\"; then unset relay_socket; fi\nif test -z \"${relay_socket:-}\" && test \"$(uname -s)\" = Linux; then\n for c in \"${XDG_RUNTIME_DIR:-/nonexistent}/agent-relay/relay.sock\" \"/run/user/$(id -u)/agent-relay/relay.sock\"; do\n if test -S \"$c\"; then relay_socket=$c; break; fi\n done\nfi\nif test -z \"${relay_socket:-}\" && test \"$(uname -s)\" = Darwin; then # pointer file missing: use the app's own socket\n c=\"$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock\"\n if test -S \"$c\"; then relay_socket=$c; fi\nfi\nrelay_probe=\nfor c in \"$HOME/.local/bin/agent-relay-probe\" \\\n /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \\\n \"/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe\"; do\n if test -x \"$c\"; then relay_probe=$c; break; fi\ndone\nif test -z \"$relay_probe\" && test -d \"$HOME/.local/lib/agent-relay\"; then # per-user tarball\n relay_probe=$(find \"$HOME/.local/lib/agent-relay\" -type f -perm -u+x \\\n -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null)\nfi\n# relay_req METHOD PATH [JSON-BODY]\nrelay_req() {\n if ! test -S \"${relay_socket:-/nonexistent}\"; then\n echo 'No Agent Relay socket found; start Agent Relay (the desktop) and retry.' >&2\n return 1\n fi\n if test \"$(uname -s)\" = Darwin && test -n \"$relay_probe\"; then\n printf '%s' \"${3:-}\" | \"$relay_probe\" relay socket-request \\\n --socket \"$relay_socket\" --method \"$1\" --path \"$2\"\n elif test \"$(uname -s)\" = Darwin && test -n \"${CODEX_THREAD_ID:-}\"; then\n echo 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.' >&2\n return 1\n elif test -n \"${3:-}\"; then\n # body on stdin: curl treats a -d/--data-binary value starting with @ as a filename\n printf '%s' \"$3\" | curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \\\n -H 'Content-Type: application/json' --data-binary @- \"http://relay$2\"\n else\n curl -sS --max-time 60 --unix-socket \"$relay_socket\" -X \"$1\" \"http://relay$2\"\n fi\n}\n```\n\n**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can\ntake about 30 seconds on a busy Desktop (tracked in\nAgentWorkforce/relay-desktop#333). Allow 60 seconds per call (add\n`--max-time 60` to the `curl` lines above), and do not treat a slow reply as a\nfailure. The probe's `relay socket-request` can give up sooner with \"Probe\ncould not finish. Check your connection and run setup again\"; on these paths\nthat is the same latency, not a setup problem. Retry once before reporting a\nblocker, and do not rerun desktop setup because of it.\n\n## 2. Make sure the desktop is set up first\n\nSession handoff needs a signed-in, healthy desktop underneath it. If the socket\nis missing, or `/setup/status` returns `404`, or sign-in/uploader/registration\nare not in the finished state below, **invoke the `setting-up-agent-relay-desktop`\nskill** (`/setting-up-agent-relay-desktop`; if it is not installed, see **Requires**), let it run to completion, and then\nreturn to section 2's verification. Do not re-implement its install, sign-in, or\n`/setup/*` steps here — that skill is the single source of truth for them, and\nthis skill resumes once it reports a registered, signed-in session.\n\nVerify the desktop state this skill depends on:\n\n```sh\nrelay_req GET /setup/status | jq '{\n version: .data.version,\n sign_in: .data.sign_in,\n workspace: .data.workspace,\n sharing_mode: .data.sharing_mode,\n uploader: .data.uploader,\n direct_delivery: .data.direct_delivery,\n session: .data.session,\n defaults_error: .data.defaults_error\n}'\n```\n\nRequire all of these before continuing, and stop with a clear message if any\nfails. `/setup/status` reports sign-in as the string `sign_in` (match the desktop\nskill), and the flag that actually governs whether **this** session's handoff\nreplies arrive live is the session-level `session.direct_delivery`, not the\ntop-level default. Direct delivery is on by default, but the desktop keeps a\n`crossSessionInbound` value the person set themselves (`hold` or `refuse`), and\nso does this skill: such a kept choice is reported in\n`direct_delivery_user_choice` (read from the settings file on a desktop build\nolder than that field) and is accepted here only when the top-level flag is\nexplicitly `false` (a missing field is never success). Any other value is not\na kept choice this skill acts on:\n\n```sh\n# absent, unreadable, or present: for crossSessionInbound in the\n# user's Claude settings. Only a regular file holding exactly one JSON object\n# is readable (a dangling symlink or a directory is not); key presence is\n# tracked apart from its value (\"\" included). Mirrors\n# setting-up-agent-relay-desktop sections 4 and 8; change them together.\nclaude_inbound_choice() {\n settings_file=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json\"\n if [ ! -e \"$settings_file\" ] && [ ! -L \"$settings_file\" ]; then echo absent; return; fi\n if [ ! -f \"$settings_file\" ]; then echo unreadable; return; fi\n jq -rse 'if length == 1 and (.[0] | type) == \"object\"\n then (.[0] | if has(\"crossSessionInbound\")\n then \"present:\" + (.crossSessionInbound | if type == \"string\" then . else tojson end)\n else \"absent\" end)\n else error(\"not one JSON object\") end' \"$settings_file\" 2>/dev/null || echo unreadable\n}\nsetup_status=$(relay_req GET /setup/status)\nkept_choice=$(printf '%s\\n' \"$setup_status\" | \\\n jq -r 'select(.data.direct_delivery == false) | .data.direct_delivery_user_choice // empty |\n select(. == \"hold\" or . == \"refuse\")')\nif [ -z \"$kept_choice\" ] && printf '%s\\n' \"$setup_status\" | \\\n jq -e '.data.direct_delivery == false and (.data | has(\"direct_delivery_user_choice\") | not)' >/dev/null; then\n case \"$(claude_inbound_choice)\" in\n present:hold) kept_choice=hold ;;\n present:refuse) kept_choice=refuse ;;\n esac\nfi\nprintf '%s\\n' \"$setup_status\" | jq -e --arg kept \"$kept_choice\" '\n .ok\n and .data.sign_in == \"signed_in\"\n and (.data.workspace.id | type == \"string\" and length > 0)\n and .data.uploader.healthy == true\n and (.data.session.id | type == \"string\" and length > 0)\n and .data.session.registered == true\n and ((.data.direct_delivery == true and .data.session.direct_delivery == true)\n or (.data.direct_delivery == false and $kept != \"\"\n and (.data.session.direct_delivery | type == \"boolean\")))'\n```\n\n**When the person kept their own choice** (`kept_choice` is set): do not\nchange it, and do not re-run desktop setup to \"fix\" it, since that keeps it\ntoo. `crossSessionInbound` is Claude Code's setting: when this session reports\n`session.direct_delivery:true` (a Codex session), report the kept value and\ncontinue normally; the round trip completes as usual. When it reports\n`session.direct_delivery:false` (this Claude Code session), tell them what it\nmeans for the handoff:\n\n- `hold`: relayed messages, including the teammate's handoff reply, arrive as\n held messages they must approve in Claude Code before this agent sees them.\n Setup continues; section 5's round trip completes once they approve the held\n reply.\n- `refuse`: relayed messages to this session are dropped, so the round trip\n cannot complete here. Setup continues through section 4, then stops at\n section 5 as **blocked on the person's choice**.\n\nEither way, offer the opt-in once: \"Turn on direct delivery for Claude Code?\nIt sets `crossSessionInbound` to `accept` in your Claude Code settings.\" Only\non an explicit yes, POST `{\"enabled\":true}` to `/setup/direct-delivery` and\nre-run this check; on no, keep their value and continue as above.\n\nConfirm `workspace.id` is the **shared** workspace both sides agreed on. A\nhealthy uploader is the one non-obvious prerequisite: for a teammate's agent to\nread **this** person's session, this machine must have uploaded it. If\n`uploader.healthy` is false (paused, or a failed cycle), fix it before promising\nanyone can read this session — this is the single most common reason a live\nhandoff silently fails.\n\n`sharing_mode` decides which sessions upload at all, and a healthy uploader\ndoes not prove that **this** session is among them. `all` includes it. `new`\nincludes sessions that started after `new` was enabled, so a session that was\nalready running when the desktop was set up may not be uploaded. Under\n`selected`, only the sessions the person chose upload. Unless the mode is\n`all`, tell the person that this session may not be shared yet, and ask them to\nselect it in the app or to continue from a new session; do not change the\nsharing mode or select sessions on their behalf. Section 5's round trip is the\nproof: if the teammate cannot read this session there, this is the first\nthing to check.\n\n## 3. Install the agent-sessions cloud MCP (before the session uses it)\n\nCheck whether the token is present in the environment **without printing it**\n(see **Token handling**). It is not required to *add* the MCP, since only a\nreference is stored, but it is required for the tools to authenticate after the\nrestart. If it is missing, add the MCP anyway, tell the person plainly that the\ntools will fail auth until they restart the agent from an environment that\nexports `AGENT_RELAY_SESSIONS_TOKEN`, and do not report setup complete:\n\n```sh\nif ! test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"; then\n echo 'AGENT_RELAY_SESSIONS_TOKEN is not set here; the MCP will be added by reference, but the agent must be restarted from an environment that exports it.' >&2\nfi\n```\n\nDetect which agent CLI this session runs and install accordingly.\n\nClaude Code — pass the **unexpanded** placeholder so the token stays out of argv\nand out of `~/.claude.json`; Claude expands `${VAR}` in MCP headers from the\nenvironment at launch. This expansion requires **Claude Code ≥ 2.1.119**, so\nverify the version first and update rather than inlining the token on an older\nclient. Single-quote the header so the shell does not expand it here. Default\nscope is `local` (this project only), which is what you want:\n\n```sh\nclaude_ver=$(claude --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1)\nif test -z \"$claude_ver\" || test \"$(printf '%s\\n2.1.119\\n' \"$claude_ver\" | sort -V | head -1)\" != 2.1.119; then\n echo \"Claude Code $claude_ver does not expand \\${VAR} in MCP headers (need >= 2.1.119); update Claude Code before adding this server.\" >&2\n exit 1\nfi\nclaude mcp add --transport http agent-relay-sessions \\\n https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \\\n --header 'Authorization: Bearer ${AGENT_RELAY_SESSIONS_TOKEN}'\nclaude mcp list | grep -F agent-relay-sessions\n```\n\nCodex — `codex mcp add` records the **variable name**, not the value, and writes\nto user-level `~/.codex/config.toml`, so the server is enabled for **all** Codex\nprojects (Codex has no project-scoped MCP). Confirm the person is OK with\nuser-wide access before adding it:\n\n```sh\ncodex mcp add agent-relay-sessions \\\n --url https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \\\n --bearer-token-env-var AGENT_RELAY_SESSIONS_TOKEN\ncodex mcp list | grep -F agent-relay-sessions\n```\n\nBecause MCPs load at startup, the running session must be **restarted** to pick\nup the server — and because both CLIs read the token from the environment at\nlaunch, the restarted process must **inherit** `AGENT_RELAY_SESSIONS_TOKEN`.\nThat is why the variable belongs in the shell profile / launcher, not a transient\nshell-tool subprocess: an `export` in this agent's shell tool cannot reach the\nparent or a freshly launched session. After the restart, re-verify presence with\n`test -n \"${AGENT_RELAY_SESSIONS_TOKEN:-}\"` before using the tools, and do not\nreport the tools as missing without first confirming the session started *after*\nthe MCP was added and with the variable set.\n\n## 4. Confirm the handoff tools loaded\n\nIn the session that started after the MCP was added, confirm the tools are\npresent before using them. List this session's MCP tools and check for the\nagent-sessions set; the authoritative check is that the tools actually appear to\nthis agent:\n\n```sh\n# Claude Code: the tools surface as mcp__agent-relay-sessions__*\nclaude mcp list | grep -F agent-relay-sessions\n```\n\nThen call `list_relay_agents` through the MCP (an MCP tool call, not a shell\ncommand). A successful call that returns the workspace roster proves the MCP is\nloaded, the token is valid, and this session can see the shared workspace. If\nthe call errors with auth, the token is wrong or for another workspace — have\nthe person re-mint it from the correct workspace's dashboard.\n\n## 5. Prove one live round trip\n\nThe real proof is agent-to-agent, so it needs a counterpart. Pick the path that\nmatches what is available:\n\n**A teammate's agent is online (full round trip).** Ask the person for the\nteammate's relay address (e.g. `@manav`), then:\n\n1. `list_relay_agents` — confirm the teammate's address is listed.\n2. **Prove the reverse read path first:** call `get_shared_session_context` on\n the teammate's session and confirm it returns their **real** content (prompts/\n events + a dashboard citation link). If it errors or is empty, their uploader\n is unhealthy — stop and have them fix it; do not declare GREEN, because this\n side cannot actually read their work yet.\n3. `send_relay_message` — send a short, uniquely-marked message to that address.\n4. The teammate's agent reads this session with `get_shared_session_context` and\n continues; have them reply through the relay.\n5. GREEN requires **both directions**: this agent read the teammate's real\n content (step 2) **and** the teammate's reply lands here. There is no\n automatic ack — confirm by the reply/activity, not a push. Because Relay\n injects an inbound message only when the session is idle between turns, **end\n the turn** after sending and confirm the reply on the next turn; do not sleep\n or poll inside one turn.\n6. Only when this session reports `session.direct_delivery:false` (a Claude\n Code session with a kept choice): with `hold`, the reply arrives as a held\n message; ask the person to approve it, then confirm it on the next turn.\n With `refuse` and no yes to the opt-in, the reply is dropped: report setup\n as **blocked on the person's choice**, naming the opt-in, not as complete,\n and not as failed. A session reporting `true` follows steps 1–5 unchanged.\n\n**No teammate online yet (self-verify the read path).** Prove the machinery\nwithout a second person:\n\n1. `list_relay_agents` returns the roster — MCP + token + workspace are good.\n2. `search_shared_sessions` / `get_shared_session_context` return this person's\n **own** recently uploaded session (real prompts/events plus a dashboard\n citation link) — the uploader→read path works.\n3. Record that the two-party round trip still needs the teammate online, and\n name it as the remaining step rather than claiming the handoff is proven.\n Setup is not complete until the full round trip succeeds.\n\nNever paste the Bearer token or any session token into a `send_relay_message`\nbody or any artifact.\n\n## 6. Report\n\nSeparate verified facts from what still needs a human or a second machine:\n\n- app version, `signed_in`, the signed-in **workspace id and name**;\n- uploader `healthy`, this session's `agent@machine` address, and direct\n delivery on, or the person's kept `crossSessionInbound` value and what it\n does (held for approval, or dropped), with the opt-in they were offered;\n- agent-sessions MCP installed and its tools confirmed loaded in this session;\n- `list_relay_agents` roster seen; and either the full round-trip evidence (the\n teammate's real content + reply) or the self-verified read path plus the named\n remaining step (teammate online).\n\nDo not call the setup complete on a 2xx from `claude mcp add` alone, and do not\ncount a roster listing as a proven handoff.\n\n## Recovery\n\n- **`not_a_relay_session` from `/setup/*`:** the caller is not inside a discovered\n interactive session. Run it from this agent's own shell inside an **interactive**\n Claude Code or Codex session — a non-interactive `codex exec` or `claude -p` run\n is not admitted, and neither is an unrelated terminal. In tmux/SSH, confirm the\n agent process and shell share the pane's process tree.\n- **No handoff tools in the session:** the MCP was added after the session\n started, or at the wrong scope. Reinstall at project scope (section 3) and\n **restart** the session; MCPs load only at startup.\n- **`list_relay_agents` auth error:** the token is wrong, expired, or from a\n different workspace. Because the MCP config stores only a **reference** to\n `AGENT_RELAY_SESSIONS_TOKEN`, fixing it means updating the **value in the\n launch environment** (keychain / 0600 file) and **restarting** the agent so it\n re-reads it — re-running `claude mcp add` / `codex mcp add` alone changes\n nothing. Re-mint from the intended workspace's \"Connect your agent\" card if the\n token itself is bad. On Claude, also confirm the client is ≥ 2.1.119 (older\n clients send the `${VAR}` placeholder literally).\n- **Teammate's address missing from the roster:** they are not on the **same**\n workspace, or their session is not on the relay. Have them register their\n session and confirm the shared workspace id.\n- **Can't read the teammate's session:** their **uploader** is not healthy\n (paused or a failed cycle). Have them check `/setup/status` shows\n `uploader.healthy == true`, not \"Sync paused/offline\".\n- **Message arrives held, not live:** direct delivery is off for the recipient\n (Codex delivers directly by default). If their `/setup/status` shows a\n `direct_delivery_user_choice`, they chose it: tell them, and POST\n `{\"enabled\":true}` to `/setup/direct-delivery` on their machine only when\n they say yes. Otherwise apply the default the way\n `setting-up-agent-relay-desktop` section 4 does: `{\"default\":true}`, which\n never overrides a choice, and on an older build that answers\n `invalid_enabled`, its settings-file check before any `{\"enabled\":true}`. A managed Claude policy can block this; report it\n rather than editing managed settings.\n- **Remove the MCP:** `claude mcp remove agent-relay-sessions` /\n `codex mcp remove agent-relay-sessions`. This removes only the handoff tools;\n it does not sign the desktop out or stop uploads.\n\n## Relationship to the other skills\n\n- **`setting-up-agent-relay-desktop`** — the layer below this one: install,\n sign-in, agent-led defaults, self-registration. Use it when section 2 is not\n yet satisfied.\n- **`relay-connect`** — a lighter, account-free, time-boxed way for two agents to\n share one session via a hand-over link. Prefer it for an ad-hoc pairing;\n prefer this skill for durable teammate-to-teammate handoff inside a shared\n workspace.\n", "setting-up-agent-relay-flows": "# Set Up Agent Relay Flows\n\nDrive the setup through the documented Cloud APIs and the official CLI. Do not\nuse computer use, browser automation, or the dashboard UI. The human approves\nsign-in, tool consent, and provider logins on their own approval pages; open\nthose links with the OS URL opener (or give them the link), then verify by\npolling the API, never by inspecting the browser.\n\nPaths below are relative to the Cloud API base, `https://agentrelay.com/cloud`,\nincluding its `/cloud` prefix; the site is `https://agentrelay.com`. The flow\ncatalog is the one exception: it is a site API, served from the site origin\nwithout `/cloud` (`https://agentrelay.com/api/v1/flows/catalog`). When a\nguide names a different site and Cloud base (for example a local development\nstack), use those exact origins throughout, including for the catalog, the\nCLI's `--api-url` and the dashboard links, and never fall back to production.\nFlows can be configured from any machine with HTTPS and Node.js 22+ for the CLI.\n\n## Requires\n\nA signed-in Cloud session: an access token and the `user.id` and\n`currentWorkspace.id` from whoami. Get them with `signing-in-to-agent-relay-cloud`\nfirst. Send `Authorization: Bearer ` only to the Cloud API\nendpoints below. Never send it to the site's flow catalog or to a catalog\n`source.rawUrl`; fetch those without credentials.\n\n## API map\n\n- Flow catalog (site origin, not under `/cloud`):\n `GET https://agentrelay.com/api/v1/flows/catalog` and `/`.\n- Tool consent links: `POST /api/v1/integrations/connect-link`; poll\n `GET /api/v1/workspaces//integrations//status`.\n- Coding-agent credentials: the official CLI's `cloud connect` (section 3);\n `GET /api/v1/cloud-agents` inspects existing connections.\n- Activation: `POST /api/v1/flows/deploy` with the body in section 4.\n- Custom-flow prompt generator (optional): `GET /api/v1/flows/prompt/generate`\n reports whether it is available.\n- Verification: `GET /api/v1/flows/listeners/`.\n\n## 1. Prebuilt or custom?\n\nAsk the human first, unless their request already says: a **prebuilt** flow\nfrom the Agent Relay catalogue (https://agentrelay.com/flows), or a **custom**\nflow of their own? In an agent-driven signup, ask through that guide's\nweb-input protocol on the signup page, not in chat.\n\n- Prebuilt: continue with section 2.\n- Custom: follow **Custom flow: deploy journey** below. A custom flow is\n activated by the same `POST /api/v1/flows/deploy` API as a prebuilt one and\n as the deploy builder at https://agentrelay.com/cloud/flows/deploy; there is\n no separate custom API.\n\n## 2. Choose the flow and repository\n\nAsk the human for missing product choices: repository, desired\nworkflow/trigger, and approver. In an agent-driven signup, ask through that\nguide's web-input protocol on the signup page, not in chat. Ask for the approver's\nGitHub username in plain language (for example, \"octocat\" or \"@octocat\"),\nnot an internal provider-address format or their Google email. Normalize the\nanswer to github:@handle when constructing the deploy API request. Human-gate\nreplies are matched to that provider identity. Infer choices from the user's request and current\nrepository where clear.\nDo not invent a repository or enable automation on an unrelated project.\n\nGET https://agentrelay.com/api/v1/flows/catalog and select a matching entry from flows.\nGET https://agentrelay.com/api/v1/flows/catalog/ for its full contract. Use the catalog's\nkind before doing anything executable: only an entry with kind: flow (or a\nlegacy entry with kind omitted) is deployable through the direct-source API\nbelow. An entry with kind: extension is discoverable metadata, not a standalone\nlistener. Do not download or deploy its source, even when it declares trigger\nand input fields. Inspect baseFlowId and extension.activation instead. If the\nextension is blocked, report the unmet dependencies and that it can only be\nadded to its base flow after a supported extension activation path is available;\nnever substitute a standalone flow deployment.\n\nFor a deployable flow, use the catalog's\nsupportedRepositoryHosts, defaultTrigger, inputs.required, inputs.defaults, and\ninputs.allowedAgents. Name a model per harness in inputs.models when the house\ndefault is wrong (for example {\"claude\": \"claude-sonnet-4\"}); omit it to fund\nthe house default for each declared agent. Download source.rawUrl, verify its bytes against\nsource.sha256, and use that source text unchanged for a recommended flow.\nThe source is TypeScript, not the source URL. Do not guess a template or hash.\n\n## Custom flow: deploy journey\n\nThere is no journey API. A `journey_id` (for example in a\n`next=/flows/deploy?journey_id=...` link) is only an analytics tag: pass it\nthrough unchanged where you received it, and never call an endpoint with it or\nwait on it. A custom flow is activated by the same `POST /api/v1/flows/deploy`\nas a prebuilt one; only where the source comes from differs.\n\n1. Author the flow with `writing-relayflows` (the Relayflows v2 engine,\n `@relayflows/surface` / `@relayflows/sdk`, CLI `flows`). Defer to that\n skill for the flow's shape and checks; Flows are always v2. Cloud's\n prompt generator is an optional shortcut: use it only when\n `GET /api/v1/flows/prompt/generate` returns `available: true`. If it\n reports unavailable, refuses your session (403 `session_required`), or\n fails, author with `writing-relayflows` instead; do not retry into it.\n2. Choose its repository, trigger and approver as in section 2.\n3. Continue with sections 3 to 5 unchanged. In section 4, `source` is the\n custom TypeScript text, `workflow` is its label, and `promptSpec` is sent\n only when the prompt generator produced one. `base` and `extensions` are\n optional and only for a flow that names them.\n\nThe human-only steps are the same for both paths: Google sign-in and device\napproval, GitHub and trigger-app OAuth including the repository grants,\nmodel-provider login when section 3 calls for one (not for an ordinary first\nactivation), and confirming the repository and trigger before activation. Do every other step yourself through the APIs.\n\n## 3. Connect the required tools and coding agents\n\nUse bearer-authenticated POST /api/v1/integrations/connect-link:\n\n```json\n{\"provider\":\"github\",\"workspaceId\":\"\"}\n```\n\nOpen the returned connectUrl for the user to approve. Keep token/sessionToken\nprivate. Connect only the repository and tools the chosen flow requires.\nFor GitHub the user must grant repository access. Repeat with the chosen trigger\nprovider if different. Reuse existing ready connections rather than relinking.\nCheck GET /api/v1/workspaces//integrations//status\nuntil oauth.connected is true (poll with backoff and a bounded timeout); a\nreturned connect link or a closed popup alone does not prove the connection.\nFor the GitHub-triggered Software Garden flow, activation separately checks\nthat the connected GitHub App covers the selected repository. Its trigger\ndoes not require background data indexing, so do not block solely because\nthe broader status.ready is false from queued syncs.\nIf a different flow declares Relayfile data that requires synced records,\nverify that readiness separately before activating it.\n\nDo not connect a Claude or Codex subscription yet. The first three runs use\nCloud's own model key, so no provider login is needed to activate. If the chosen\nflow declares more than one coding agent (the catalog's `inputs.defaults.agents`,\nwhich becomes `inputs.agents` in the deploy body), included Cloud runs\ncan fund only one of them per run — connect your own subscription for at least\none declared agent before activating a multi-agent flow. After the included\nruns, activation and launches will ask for your own subscription; only then use\nthe official Relay CLI with the private credential environment from\n`signing-in-to-agent-relay-cloud` (its section 5) and a PTY:\n\nThis promotion depends on Cloud's internal house-key proxy and account\nenrollment. If local activation returns flow_credentials_unavailable or\nflow_model_not_connected before the three promotional runs, treat it as an\ninternal configuration or eligibility issue. Do not ask the user to connect\nClaude/Codex, provide an API key, or choose an inactive draft as a workaround.\nReport the blocker (in a signup, through its Progress API) and have an internal developer\nverify the proxy, promotion flag, provider readiness, and enrollment. Never\ncopy a house key into the agent environment or expose it in this guide.\n\n```sh\nnpx --yes agent-relay@13 cloud connect anthropic --api-url 'https://agentrelay.com/cloud'\n```\n\nUse anthropic for Claude or openai for Codex, according to the selected flow.\nThe command drives provider login; open its authorization URL for the user,\nand keep the process alive until it confirms the credential is connected.\nGoogle approval does not grant GitHub or model-provider access: those services\nmay require their own consent. Never fabricate credentials or claim consent\nhappened. GET /api/v1/cloud-agents lets you inspect the account's credential\nstate without reconnecting.\n\n## 4. Activate through the same API as web onboarding\n\nPOST /api/v1/flows/deploy with Content-Type: application/json and the bearer\nsession. The token must be the device-flow session from\n`signing-in-to-agent-relay-cloud` (scope `cli:auth`) or a token with\n`flows:listeners:write`, and `workspaceId` must be that token's workspace. This is the direct-source listener API used by flows deploy, not the\nbrowser onboarding handoff: source is TypeScript text, repository is singular,\nand sources contains provider/settings objects. The catalog supplies the source\nreference and defaults; it is not itself a deploy request. The current endpoint\ndoes not accept a flowId/repositories-only catalog activation request or fetch\nthe source for you. For multiple repositories, submit one deployment per\nrepository with a distinct name and handoffId.\n\nFor the catalog's Software Garden entry (id `software-factory`), construct this body, substituting the\nworkspace, verified source, repository, GitHub approver and a new UUID:\n\n```json\n{\n \"workspaceId\": \"\",\n \"name\": \"Software Garden\",\n \"workflow\": \"software-factory\",\n \"source\": \"\",\n \"handoffId\": \"\",\n \"inputs\": {\"approver\": \"github:@octocat\", \"agents\": [\"claude\"]},\n \"mode\": \"activate\",\n \"repository\": {\"owner\": \"acme\", \"name\": \"api\"},\n \"sources\": [{\"provider\": \"github\", \"settings\": {\"repository\": \"acme/api\"}}]\n}\n```\n\nFor another deployable kind: flow catalog entry use its id as workflow, allowed agents, and\ndefaultTrigger for sources, then apply the user's trigger settings. Scope a\nGitHub issue trigger with settings.repository set to the chosen owner/name;\nfor GitLab use settings.project. Cloud does not derive this filter from the\ndeployment repository. An empty filter can trigger on other repositories in\nthe workspace. Only use a different trigger scope when explicitly requested.\nGive each repository its own trigger filter for multi-repository setup. Do not send\nthe example acme repository or octocat approver unchanged. The workflow field\nis a label, not a source lookup; keep the verified source in the request.\nFor GitLab set repository.host to gitlab and use the namespace path\nas owner. Reuse the handoffId on retry. Before retrying an ambiguous network\nfailure, GET /api/v1/flows/listeners and check whether the flow already exists;\ndo not create a new ID/name on every retry. Activation subscribes to matching\nfuture events and can run work; confirm the intended repository and trigger\nwith the user if they have not specified them.\n\nHTTP 201 must contain agentId and status: listening. A draft is not completion.\nFor a 409 workspace_mismatch, verify the active workspace; for connection\npreflight failures, fix the indicated connection before retrying. If the user\nwants to save incomplete work, use mode: draft explicitly and report that it\nis inactive. Never mask activation failures by silently falling back to draft.\n\n## 5. Verify\n\nGET /api/v1/flows/listeners/. Require listener.status: listening and\nverify its repository and sources match the request. Open\nhttps://agentrelay.com/cloud/dashboard/workflows/listeners/ for the user. Report the flow name,\nworkspace, repository, trigger, and verified listening state. This proves\nactivation; only an actual completed run proves execution. Do not create a\nreal issue or launch paid work merely to make the onboarding check turn green.\nDelete temporary authentication files after the work is complete.\n\nThe desktop app is optional for Flows. If the user also wants local session\nsharing, use `setting-up-agent-relay-desktop` and `setting-up-agent-relay-sessions`\nwith the same account (an agent-driven signup can follow\nhttps://agentrelay.com/signup/agent/teams).\n", "writing-relayflows": "# Writing Relayflows\n\n## Overview\n\nRelayflows turns a coding-agent task into steps a journal can inspect, verify, and resume. A flow is data (YAML/JSON) or code (TypeScript) that compiles to the same journal-backed kernel spec. Every effect is journaled before it's treated as real — a journal write that fails fails the step, with no silent fallback.\n\n**Name collision warning.** An older, unrelated engine is also casually called \"Relayflow\" (singular): `@relayflows/core`'s `WorkflowBuilder`, a chained builder (`workflow('name').pattern('dag').agent(...).step(...).run()`). This skill is the **v2** engine: `@relayflows/surface`'s `flow()` function and the YAML/JSON dialect compiled by `@relayflows/sdk`. If you see `.pattern(`, `.agent(` as a chained builder call, or `ctx.workflow.run()`, you are looking at legacy code. Do not use that syntax for new work; keep v2 guidance inside this skill.\n\n## When to use this skill\n\n- Writing a new `.flow.ts` or `.flow.yaml`/`.flow.json` for the `flows` CLI (package `@relayflows/sdk`, binary name `flows`).\n- Deciding whether a step needs `run` (shell), `llm` (bare model call), or `agent` (harnessed coding agent in a workspace).\n- Choosing a verification gate that will actually run — a wrong choice here compiles fine and fails at `flows run` time, not authoring time.\n- Wiring up `cli`/`model` for an `agent` or `llm` step, in either language.\n- Debugging a `REFUSED [...]` message from `flows check` or `flows run`.\n- Deciding between running a flow locally, deploying it to listen for tickets, or scheduling it on a cron.\n\n## The ladder\n\nThree step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' | 'llm' | 'agent'`):\n\n1. **`run` / `deterministic`** — a shell command. No model. Implicit gate is `exit_code == 0`.\n2. **`llm` / `llm`** — a bare model call. Prompt in, verified output out. No workspace, no tool use.\n3. **`agent` / `agent`** — a harnessed coding agent in a workspace. Returns `{ summary, artifacts }`, not raw text.\n\nPlus resident verbs that aren't ladder rungs: `human` (durable approval), `dispatch` (a direct child flow declared in `use`), and `done` (typed finish). `dispatch` executes in `2.0.35` and newer; see **Human approval and direct child flows**. The generated **helper** namespace (`f.slack`, `f.github`, `f.linear`, `f.notion`, `f.jira`, and 30+ others — see **Helpers**) and YAML `on`/triggers are separate surfaces.\n\nMost flows only need `run` and `llm`. Climb to `agent` once a step needs hands on a real workspace.\n\n## Two ways to author the same thing\n\n**YAML/JSON** is data: `flows check` or CI can validate it without running anything. **TypeScript** calls the same primitives imperatively as ordinary async code. Both compile to the same journal.\n\n### TypeScript\n\n```ts\nimport { flow } from '@relayflows/surface';\n\nexport default flow('hello', { budget: { wallclock: '10m' } }, async (f) => {\n const greeting = await f.run('echo \"Hello from Relayflows\"');\n console.log(greeting.trim());\n\n const answer = await f.agent('greeter', {\n task: 'Reply with one short hello sentence. Do not use tools or modify files.',\n cli: 'claude',\n model: 'claude-sonnet-4-6',\n });\n console.log(answer.summary);\n\n f.done('success');\n});\n```\n\n### YAML\n\n```yaml\nversion: '0.1.0'\nname: hello\nsteps:\n - id: greeting\n type: deterministic\n command: 'echo \"Hello from Relayflows\"'\n - id: greeter\n type: agent\n dependsOn: [greeting]\n instruction: 'Reply with one short hello sentence. Do not use tools or modify files.'\n cli: claude\n model: claude-sonnet-4-6\n```\n\n**Version note.** The contracts below were checked against the published `@relayflows/surface@2.0.35` / `@relayflows/sdk@2.0.35` declarations. Direct `use`/`f.dispatch` composition requires `2.0.35` or newer. `AgentOptions.permissions` landed in `2.0.17`; predicate `.gate()` and `artifact_exists` landed with [flows#449](https://github.com/AgentWorkforce/flows/pull/449); `f.human` was already executable in `2.0.22`.\n\n## The real `Ctx` contract (TypeScript)\n\nFrom the shipped `@relayflows/surface@2.0.35` `.d.ts` files (`dist/context.d.ts`, `dist/step.d.ts`, `dist/flow.d.ts`, `dist/completion.d.ts`):\n\n```ts\nexport interface AgentResult {\n summary: string;\n artifacts: string[];\n}\n\nexport interface PermissionsSpec { // exported from the package root\n fileGlobs?: string[];\n networkAllowlist?: string[];\n accessPreset?: 'readonly' | 'readwrite';\n}\n\nexport interface AgentOptions {\n task: string;\n workspace?: string;\n permissions?: PermissionsSpec; // since 2.0.17 — declared and journaled, NOT enforced\n cli?: string;\n model?: string;\n cwd?: string; // working dir for the CLI subprocess; defaults to the flow-runner's cwd\n transport?: 'direct' | 'relay'; // see Agent-relay transport, below\n}\n\nexport interface LlmOptions {\n output: Record; // JSON Schema, checked before the journal accepts the result\n cli?: string;\n model?: string;\n}\n\nexport interface DispatchResult {\n name: string;\n completionReason: 'success';\n completionDetail?: string;\n}\n\nexport interface Ctx extends Helpers { // Helpers = f.slack, f.github, f.linear, f.notion, ... — see Helpers\n readonly mcp: Readonly Step>>>>;\n run(command: string, options?: { timeout?: string | number }): Step;\n llm(strings: TemplateStringsArray, ...values: unknown[]): Step;\n llm(prompt: string, options: LlmOptions): Step;\n agent(name: string, options: AgentOptions): Step;\n human(question: string, options: { to: string }): Step;\n dispatch(flow: string, input: unknown): Step;\n done(reason: FlowCompletionReason): void;\n cloud: CloudHelper;\n memory: MemoryHelper;\n}\n```\n\nDo not add fields to `AgentOptions`/`Ctx` that aren't in this list — they don't exist in the shipped SDK. In particular: **no flow-level named-agent selector on `f.agent`, no `recoveryMode`/`surfaces`/`output`** on the TypeScript call site. Those stay YAML/JSON step fields. `FlowHeader` (`dist/flow.d.ts`) allows `use`, `identity`, `memory`, `budget`, `tools`, `workspace` — no bare `agents` map key (TypeScript composes reusable flows via `use: string[]` instead — see **Named agents and flow composition**). An unknown *header* field throws a `TypeError` at authoring time, before anything is journaled; an unknown `AgentOptions` field is caught by `tsc`, not at runtime.\n\n**`permissions` is a real TypeScript option and it does not sandbox anything.** Since `2.0.17` it is on `AgentOptions`, validated by the SDK, lowered to `file_globs`/`network_allowlist`/`access_preset` and journaled with the step — and nothing reads it to gate a file or a network call. `flows check` warns `permissions_unenforced` on any declaration, including `{}`. Enforcement is [flows#442](https://github.com/AgentWorkforce/flows/issues/442). Declare it to record intent; do not rely on it to contain an agent.\n\n`run()`'s second argument is a real, current option: `{ timeout?: string | number }` — a duration string (`\"15m\"`) or milliseconds. Every real cookbook example that runs something slower than a few seconds sets this explicitly.\n\n### `f.done()`'s closed set is six values, not four\n\n```ts\nexport const FLOW_COMPLETION_REASONS = ['success', 'step_failed', 'canceled', 'budget_exceeded', 'needs_human', 'declined'] as const;\n```\n\nAn authored body can meaningfully call `f.done('success')`, `f.done('step_failed')`, `f.done('needs_human')` (parks the run — it's not a kernel cancellation), or `f.done('declined')` (a deliberate decision not to act — also not a cancellation). `'canceled'` and `'budget_exceeded'` exist in the same closed set but are the **kernel's** to record; a body calling them itself is calling the wrong verdict for what actually happened. The verdict for a human declining a durable approval is `f.done('declined')`, not `f.done('canceled')` — a human declining is a decision, not the kernel calling off the run.\n\n`Step` (`dist/step.d.ts`) is a `PromiseLike` with one extra method, `.gate(...)` — see **Verification gates**. Every awaited step must actually be awaited — an unawaited or manually-`.then()`-chained step is refused (`unawaited_step` / a manual `.then()` is not an await), not silently dropped.\n\n## Verification gates\n\nVerification is control flow, not decoration — a gate decides whether a step actually completed, not just whether the process exited cleanly.\n\n### YAML/JSON (`@relayflows/sdk`'s `VerificationSpec`)\n\n```ts\nexport type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate | NamedDataGate;\n// NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate\n// | ArtifactExistsGate\n```\n\n- `exit_code` — implicit default for `deterministic` steps. Not configurable; writing it explicitly is allowed and compiles to the same thing as omitting it.\n- `output_contains` — step output (stdout tail, or the LLM value stringified) contains `value`.\n- `json_schema` — step output validates against a JSON Schema (`boolean | Record`). Used for structured LLM/agent output; `output?: JsonOutputSchema` on an `llm`/`agent` step is sugar that compiles to this.\n- `subprocess_gate` — runs `command` under `/bin/sh`, judged on its exit code. The gate that actually works for \"check a file the agent wrote\": `{ type: 'subprocess_gate', command: 'test -s review.md' }`. If you also need to inspect the step's own output (via `from_output`), the SDK's generated wrapper reads its internal `FLOWS_INPUT` env var, extracts the value, and passes it to *your* command as `$INPUT` — `FLOWS_INPUT` itself is never visible to the author's command, only `$INPUT` is: `{ type: 'subprocess_gate', command: 'echo \"$INPUT\" | grep -q PASSED', from_output: ['summary'] }`.\n- `regex_match` — the step's output (or a value at `in_output_at`) matches `pattern`, evaluated by a non-backtracking RE2 engine (only `i`, `m`, `s` flags).\n- `artifact_exists` — passes when the step's journaled `artifacts` list contains `path` (working-directory-relative POSIX). It reads the journal, not the disk, so it sees exactly what the worker measured at `step.completed`; a path the worker never journaled reads as missing even when the file is there.\n- `word_count_bounds`, `references_input` — narrower named gates; see `packages/sdk/src/named-gates.ts` in the flows repo for the exact contract.\n\n```yaml\n- id: classify\n type: llm\n prompt: 'Classify this ticket as bug, feature, or question: \"the export button does nothing\"'\n cli: claude\n model: claude-sonnet-4-6\n verification:\n type: output_contains\n value: bug\n```\n\n### TypeScript (`Step.gate(...)`) — read this before you write one\n\n`Step` has **two** overloads, and **one `.gate()` per step** — a second throws `unsupported_gate`:\n\n```ts\ngate(config: NamedGate): Step;\n// NamedGate = ReferencesInputNamedGate | SubprocessNamedGate | WordCountBoundsNamedGate\n// | RegexMatchNamedGate | ArtifactExistsNamedGate\n// — the surface-visible subset of NamedDataGate above. Data, not code: `flows check` can prove it.\n\ngate(predicate: (value: T) => boolean, because?: string): Step;\n// Runs since flows#449. The executor calls the closure once, on the journaled value, and\n// journals the VERDICT as a lowered `.gate` step — so resume and replay read the\n// recorded verdict and never re-run the function. A false verdict fails the run as\n// `gate_failed`, carrying `because`.\n```\n\nThe predicate form was refused with `unsupported_gate` through `2.0.16`; on `2.0.22` it runs. **Prefer the config-object form anyway** — `flows check` cannot prove a predicate (docs/SURFACE.md §6), so a named gate is the only kind a preflight can tell you about before you spend a run:\n\n```ts\nconst outdated = await f.run('npm outdated --json 2>/dev/null || true')\n .gate({ type: 'subprocess_gate', command: 'test -n \"$INPUT\"' }); // provable by `flows check`\n\nconst review = await f.agent('review', { task: '…', cli: 'claude' })\n .gate({ type: 'subprocess_gate', command: 'test -s review.md' }); // checks a file the agent wrote, ignores $INPUT\n```\n\n(Verified for real: `.gate({ type: 'subprocess_gate', command: 'test -n \"$INPUT\" && echo \"$INPUT\" | grep -q pkg' })` against a step outputting `{\"pkg\":\"left-pad\"}` passes — `$INPUT`, not `$FLOWS_INPUT`, is what the author's command sees. `FLOWS_INPUT` is an env var the SDK's own generated wrapper reads internally to extract the value before invoking your command; it is never visible to `command` itself.)\n\nAn agent step rarely fails by crashing; it fails by returning something plausible and wrong, which a plain retry-on-error never catches. Validate every `agent`/`llm` result, either directly with a supported gate or in a following deterministic verifier.\n\n**Where to put that check depends on what you want a red result to do.** A config-object or predicate gate directly on the agent/LLM step ends the run when it fails; use it when that failure should be terminal. For repairable failures, the following deterministic verifier must explicitly read the preceding output or workspace, produce its own verification result, and record it for repair. Output-dependent gates inspect the step they guard: moving the same gate unchanged would inspect the verifier's output, not the agent's. Use the record/repair/re-record/assert pattern in `relay-80-100-workflow`, gating the final verification result after repair.\n\n## Helpers: journaled effects, not just Slack\n\n`Ctx extends Helpers`, and `Helpers` is generated from 40+ provider adapters shipped in `@relayflows/surface` — `slack`, `github`, `linear`, `jira`, `notion`, `salesforce`, `postgres`, `s3`, `redis`, `daytona`, `gmail`, `google-calendar`, `hubspot`, and more (`dist/helpers/index.d.ts` lists them all). A helper call — `f.slack.post(channel, text)`, `f.github.comment(...)` — compiles to a journaled effect: the receipt the helper returns *is* the journal record, so a retried step that already posted doesn't double-post; the second attempt is deduped by `(step_id, idempotency_key, surface_path)`.\n\nTwo things every helper call needs:\n\n- **A relayfile mount for that provider**, locally — `flows check` refuses `helper_slack.credential_missing` (or the equivalent for another provider) without one. Cloud provides a mount for every connected provider automatically.\n- **A declared intent** on the flow header: `tools: { slack: true }` in TypeScript, or the equivalent in YAML. Forgetting this is a separate, earlier failure than the mount check.\n\nFor local development without standing up a real provider mount, prefix the run with the provider's mock env var — `RELAYFLOWS_SLACK_MOCK=1 flows run ... --local-agent` records the effect (a real file under the daemon's data dir) instead of sending it. This does not exercise live delivery, and should be called out as such wherever you cite it as verification.\n\n**`tools` means two different things depending on where you write it — do not confuse them:**\n\n- `FlowHeader.tools` (TypeScript) / `FlowSpec` top-level `tools` you're declaring intent on: `Partial> & { relayfile?: string[]; mcp?: string[] }` — e.g. `{ slack: true }`. This is \"which helpers this flow uses.\"\n- `FlowSpec.tools` in the compiled YAML/JSON spec is instead `{ fs?: string | string[] }` — path-scoped **filesystem** grants, mirroring `workspace` for shell/deterministic steps. Not the same field, despite the identical name.\n- `flows.json` (the project config file) has **no** `tools` field at all — see **`flows.json`**, below. Putting a `tools` key there is refused as `config_invalid`; this is an easy, first-try mistake (made once, personally, writing this refresh).\n\n## `cli` / `model`: what a step actually runs on\n\nBoth YAML and TypeScript agent/llm steps can set `cli` and `model` directly. Resolution order for `cli`, identical regardless of authoring language because both compile to the same `StepSpec`:\n\n1. **step** — `step.cli` (or TS's `options.cli`)\n2. **named** — the flow's `agents[step.agent]` entry, if the step selects one via `agent: ` (YAML/JSON only — TypeScript composes reusable flows via `use:` instead, not a per-step named-agent selector; see **Named agents and flow composition**)\n3. **flow** — `FlowSpec.cli` (YAML) — there is no equivalent flow-level `cli` field on the TypeScript `FlowHeader`\n4. **project** — nearest `flows.json`'s `cli`, found by walking up from the flow file's directory\n\nNo resolution found at any level → `REFUSED [cli_unresolved]`, before anything is journaled, e.g.:\n\n```\n$ flows check hello.flow.yaml # agent step, no cli anywhere\nREFUSED [cli_unresolved] Step \"greeter\" has no CLI at step, flow, or project level. No flows.json was found from \"...\" to the filesystem root.\n```\n\n`model` has **no** flow or project default — only step or named-agent. Omitting it just runs whatever model the resolved CLI defaults to.\n\n### `flows.json`\n\nNearest-wins project config, walking from the flow file's directory to the filesystem root. The shipped SDK type (`@relayflows/sdk`'s `FlowsJson`) is exactly:\n\n```ts\ninterface FlowsJson {\n cli?: string;\n executors?: string[];\n models?: string[];\n mcp?: Record;\n deploy?: { bucket: string };\n}\n```\n\n- `cli` — the project-wide CLI default (resolution rung 4 above).\n- `executors` — trigger executors this project has registered. A trigger whose `executor` isn't in this list is `no_executor`; a flow that declares `.on(...)` triggers but is invoked directly (not via a webhook) still needs its provider named here — e.g. `{ \"executors\": [\"github\"] }` for a flow with `.on(github.pull_request(...))` handlers, even when you never hit that code path.\n- `models` — **an allowlist, not a default.** Any model declared on any step or named agent anywhere in the flow must appear here, or `flows check`/`flows run` refuses `model_unknown` / `llm_cli_unresolved`. Setting `models` does not select a model for anything.\n- `mcp` — project-owned MCP server connections.\n- `deploy` — a file-bucket target for `flows deploy @sha256: --to `.\n\n**Verified for real:**\n\n```\n$ flows run hello.flow.ts --local-agent --input '{}' # step declares model \"claude-opus-5\", flows.json has no models[]\nREFUSED [invalid_spec] llm_cli_unresolved: Step \"llm-1\" declares model \"claude-opus-5\" for CLI \"claude\",\nbut it is not listed in project model registry \"…/flows.json\"; add the exact model only after verifying\nthat project is allowed to use it.\n\n$ flows run pr-reviewer.flow.ts --local-agent --input '{...}' # flow declares .on(github....) handlers, no executors[]\nREFUSED [no_executor] webhook trigger \"github\" is not registered in flows.json\n```\n\nAny other top-level key — `tools`, `budget`, `identity`, `agents` — is refused as `config_invalid`. Those are `FlowHeader`/`FlowSpec` fields on the *flow itself*, not on the project config.\n\n## Named agents and flow composition\n\n**YAML/JSON**: a named-agent map declares each `{ cli, model }` pair once; a step's `agent:` selector resolves to it at compile time, and `flows check` flags a named agent nobody selects, or one a step overrides without using.\n\n```yaml\nagents:\n planner: { cli: claude, model: claude-opus-5 }\n implementer: { cli: codex, model: gpt-5.6-codex }\nsteps:\n - id: plan\n type: agent\n agent: planner\n instruction: '…'\n```\n\n**TypeScript** has no equivalent `agents:` map key on `FlowHeader` — the closest idiom is a plain object spread reused across calls:\n\n```ts\nconst planner = { cli: 'claude', model: 'claude-opus-5' };\nawait f.agent('plan', { ...planner, task: '…' });\n```\n\nWhat TypeScript *does* have is `FlowHeader.use?: string[]`: relative `.flow.ts` paths to direct child flows the body may call with `f.dispatch`. This is composition of complete flows, not a named-agent map. It executes in `2.0.35` and newer; see **Human approval and direct child flows**.\n\n## Parallel agents\n\nRunning several agent (or any) steps concurrently is first-class, documented authoring — not a workaround:\n\n> \"…`Promise.resolve`, `Promise.all`, `Promise.allSettled`, `Promise.any` and `Promise.race` over authored steps\" are \"ordinary, supported authoring.\" — `docs/SURFACE.md`, the authored operation lifecycle\n\n```ts\nconst LENSES = ['security', 'correctness', 'performance'] as const;\n\nawait Promise.all(\n LENSES.map((lens) =>\n f.agent(`${lens}-reviewer`, {\n task: `Review this diff for ${lens} issues ONLY. Write findings to review/${lens}.json.`,\n cli: 'claude',\n }).gate({ type: 'subprocess_gate', command: `test -s review/${lens}.json` }),\n ),\n);\n\nconst consensus = await f.agent('consensus', {\n task: 'Read review/*.json. Resolve disagreement between lenses. Write review/consensus.json.',\n cli: 'claude', // TypeScript has no flow-level cli; without it (or a flows.json default), flows run refuses with REFUSED [invalid_spec] and the unresolved CLI in the message\n}).gate({ type: 'subprocess_gate', command: 'test -s review/consensus.json' });\n```\n\nThis is the exact shape `examples/pr-review-pipeline` in `AgentWorkforce/flows` (and the cookbook's would-be equivalent) uses: fan out, each lane writes its own file, a reconciliation step reads them all. **Disclosure worth knowing before you rely on it**: the flows package replaces the global `Promise.all` intrinsic, process-wide, for the lifetime of any authored flow execution, so the kernel can prove every concurrent step was actually awaited (rather than inferring group membership from callback identity). It delegates to the real intrinsic and restores it when the last concurrent flow closes — but any other code sharing that process sees the replacement during that window.\n\n## Agent-relay transport: dispatch, not live chat\n\n`AgentOptions.transport?: 'direct' | 'relay'` (default `'direct'`, a local subprocess). Setting `transport: 'relay'` dispatches the step to Agent Relay's own task infrastructure instead — per the shipped type's own doc comment: *\"posts to agent-relay so the agent registers as a first-class workspace participant that DMs can steer.\"*\n\nWhat this actually is, per `docs/AGENT-RELAY-TRANSPORT.md`: the step calls `POST /v1/actions/task.run/invoke` against relaycast, then waits for a durable final receipt — spawn readiness and invocation acceptance do **not** complete the step, only a terminal receipt does. It needs a pre-provisioned `RELAY_AGENT_TOKEN` (a workspace API key cannot substitute) and the Relay provider running with `AGENT_RELAY_TASK_PROVIDER=1`. The dispatched worker submits its result via an injected `agent_result` tool.\n\nDon't describe this as \"agents talking to each other\" — there's no documented pattern in this skill's scope for two flow steps to hold a live back-and-forth over Relay channels while both are running. What's real: the step's agent becomes a genuine Agent Relay workspace participant while it runs (so a human, or another agent with the right access, can DM it and potentially steer it — per the type's own wording), and the flow still only sees a request-and-durable-receipt shape, not an open channel. Multi-agent *coordination* inside a flow today means sequential handoff (one step's return value feeds the next) or parallel fan-out (**Parallel agents**, above) — not live messaging.\n\n## Human approval and direct child flows\n\n`f.human` parks a root run on a durable approval. `f.dispatch` executes a statically declared direct child in the same durable tree on `2.0.35` and newer. Both must be awaited.\n\n`f.human` parks the run durably on a kernel `wait.human`, keyed `human-` in the order the body asked. A person answers, and `flows resume` continues the body from that line:\n\n```ts\nconst ok = await f.human(`Ship this?\\n${plan.summary}`, { to: 'khaliq' });\nif (!ok) return f.done('declined'); // a person's \"no\" is declined, never canceled\n```\n\n```\n$ flows answer human-1 yes --by khaliq --note 'reviewed the diff'\n$ flows resume \n```\n\n`to` names who is asked and is recorded with the question — **it is not a delivery address**; nothing notifies that person for you. The kernel closes a wait once, so the first answer wins, and it records `attribution: \"client_asserted\"` because the daemon socket, not the kernel, authenticated whoever `--by` names. The answer step takes a `.gate(config)` like any other step. One caveat: `f.human` needs a durable root run to park in — under a runner that has none it refuses with `unsupported_verb`, so run it with `flows run`.\n\nFor a composed local run, declare each direct child in the parent's static `use` header, then dispatch by the child's declared `flow(...)` name—not by its file path:\n\n```ts\n// release.flow.ts\nimport { flow } from '@relayflows/surface';\n\nexport default flow('release', { use: ['./implement.flow.ts'] }, async (f) => {\n await f.run(\"printf '%s' prepare\");\n const child = await f.dispatch('implement', { issue: 123 });\n await f.run(\"printf '%s' publish\");\n f.done(child.completionReason);\n});\n```\n\n```ts\n// implement.flow.ts\nimport { flow } from '@relayflows/surface';\n\nexport default flow('implement', async (f, input: { issue: number }) => {\n // The type is not a runtime check: dispatch input is JSON, so validate it\n // before it reaches a shell command.\n if (!Number.isSafeInteger(input.issue) || input.issue < 1) throw new Error('issue must be a positive safe integer');\n await f.run(`printf '%s' ${input.issue}`);\n f.done('success');\n});\n```\n\nInterpolating step input into `f.run` is shell code: validate it (or quote it) first, because the TypeScript type of `input` is not checked at runtime.\n\n`use` resolves relative `.flow.ts` files before a body runs. Missing or repeated files, duplicate direct-child names, cycles, and undeclared or transitive-only dispatch targets are refused. The child's steps receive qualified IDs such as `dispatch-2--run-1`; the dispatch receipt joins child leaves back to the parent's next step. Parent and child share one root budget and worker-capacity pool, and resume reuses journaled identities rather than repeating completed effects. A successful dispatch returns `{ name, completionReason: 'success', completionDetail? }`; any other child verdict fails the dispatch.\n\nAuthority narrows: a child cannot declare a second budget, require a helper/MCP capability its parent did not grant, or call `f.human`. Static cycles are refused and runtime child depth is capped at three. This is direct authored composition, not arbitrary public-flow invocation.\n\nTo put the **whole local tree** on one Cloud dashboard page, run `flows run --cloud-mirror release.flow.ts --input '{}'` (or affirmatively set `FLOWS_CLOUD_MIRROR=1`). The root, qualified child steps, dispatch receipts, dependency edges, transcripts, source, and output are projected into one connected graph; the local journal remains authoritative. Mirroring uploads that data and is opt-in. A mirror failure cannot fail the local run. The default observer link is separate and does not add the run to Cloud history.\n\nDo not confuse this with **hosted execution**. `flows run --cloud [--sync-code] release.flow.ts --input '{}'` still rejects `use` dependencies with `unsupported_source`: Cloud accepts one self-contained `.flow.ts` source. Use `--cloud-mirror` for the connected child-flow dashboard today; do not promise that Cloud can itself execute a multi-file `use` tree.\n\n**The same \"types accept it, the executor doesn't\" trap applies to `workspace` on an agent step under `--local-agent`.** Neither an annotated (`'acme/api: readonly'`) nor a bare (`'acme/api'`) value works — both fail identically:\n\n```\nREFUSED [invalid_spec] unsupported_workspace_permission: The local agent worker accepts\nstream-only steps. Remove workspace or attach a worker that holds its revision pins.\n```\n\nIt isn't the `: readonly`/`: readwrite` annotation syntax that's rejected — it's that the local-agent worker doesn't hold workspace revision pins at all. Omit `workspace` entirely for a step you intend to run with `--local-agent`; it's a real, working field only against a worker that supports it (Cloud's). Against a worker that does hold pins, the suffix is separately refused, and the remedy the SDK's own message names is `f.agent`'s `permissions` option — which records the scope and does not enforce it.\n\n## Running it: `flows check` / `run` / `deploy` / `schedule`\n\n```\nflows check [--json] [--watch] \nflows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] \nflows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] --input \nflows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] \nflows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--json]\nflows deployments [--json]\nflows undeploy [--json] \nflows schedule --cron \"\" --tz --input \n```\n\n`check` is a pure compile-and-preflight — no daemon, no socket, no data dir. It's the fast, safe way to validate a flow before ever running it, and it does accept `.flow.ts` despite older `--help` text implying only YAML/JSON.\n\n**`--local-agent` is not optional decoration.** Without it, `agent`/`llm` steps have no worker to dispatch to and the run parks indefinitely rather than executing. Every local run in this skill's examples, and every real run in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook), passes it.\n\nA `.flow.ts` run via `flows run` requires `--input ` even when the flow body ignores its input argument.\n\n**`--data-dir` gotcha, learned the hard way.** If a flow does its own git hygiene on the directory you invoke it from — `pr-reviewer`'s and similar examples' `git clean -fdq -e .workforce` when there's nothing to push — that cleanup deletes *anything else untracked in that directory*, including the daemon's own default `.relayflowd/` state if you didn't move it. The symptom is a bizarre `SQLite journal failed: unable to open database file` on the *next* run in the same checkout, because the previous run's own cleanup step deleted the very journal directory the daemon needed. Fix: put the daemon's data outside the checkout:\n\n```bash\nflows run pr-reviewer.flow.ts --local-agent --data-dir /somewhere/outside/the/repo --input '{...}'\n```\n\n**Before `flows deploy` will do anything, two things have to already be true — a correct command still fails without them:**\n\n1. **You (or whoever's driving this) are logged in.** `agent-relay cloud login` once; check with `agent-relay cloud whoami`. There's no separate `flows`-specific login — it shares the `agent-relay` Cloud session.\n2. **The workspace has a GitHub App installation covering `--repo`, and the `--on` provider is actually a connected integration** — not just declared in the command. `flows deploy` looks up the workspace's GitHub installation before it will activate a listener; with none, it refuses `flow_repository_not_connected` rather than deploying a broken one (`prepare-flow-deploy.ts` in `AgentWorkforce/cloud`). Check what's connected with `agent-relay cloud integration connections --workspace ` — a provider showing `degraded` instead of `ready` is not deploy-safe, even though the CLI won't tell you that until you try. This is a separate concern from your own local git push access (a personal git/`gh` credential thing, needed only for testing a flow's own `git push`/`gh pr create` steps locally) — don't conflate the two when debugging a refusal.\n\n**`flows deploy`** creates a persistent listener: each matching ticket (`--on github:labels=agent`, `linear:team=ENG`, `jira:project=OPS`, `shortcut:workspace=…`, `slack:channel=#eng`) launches one Cloud run. `flows deployments` lists what's listening; `flows undeploy ` stops it — verified for real this session (deployed `software-factory` against a real repo with `--on linear:team=…`, got back `{\"status\":\"listening\", ...}`, then cleanly undeployed).\n\n**`flows schedule`** puts a flow on a cron instead of a ticket trigger — newer (shipped after the `2.0.16` release line) and not yet reflected everywhere in older examples that instead show `flows run --cloud` for the same use case.\n\n**Now fixed, was broken through `2.0.16`** ([flows#461](https://github.com/AgentWorkforce/flows/issues/461), closed): the one-shot `flows run --cloud [--sync-code] --input ` form used to misroute authored TypeScript flows into the declarative-spec loader (`invalid_input: Cannot read or compile the declarative flow`) or refuse with a bare `http_error: HTTP 400`. Root cause: Cloud pinned an older `@relayflows/surface` than the CLI authored against, and refused the version mismatch with those opaque errors instead of naming it. Fixed in `2.0.17` (CLI side) — verified for real: `flows run --cloud --wait --input '{}'` now submits successfully and returns a real run ID instead of refusing immediately. If you still hit a bare `HTTP 400`/`invalid_input` on this path, you're likely on a CLI older than `2.0.17` — update first before assuming something else is wrong. (Cloud-side reporting of the exact version mismatch, when one still exists, was tracked as a separate follow-up PR at the time of writing — the CLI's own refusal is fixed either way.)\n\n### Watching a local run: observer link vs `--cloud-mirror`\n\nNew in **`2.0.32`**. A local run has two ways to be watched, and they are not the same thing:\n\n- **The observer link is the default.** Every `flows run` mints a read-only `ot_live_` link scoped to that run's own `wf-` channel and prints `Observer: `. It is free, needs only a workspace key, and carries a step projection. `--no-observer-link` suppresses it.\n- **`--cloud-mirror` additionally puts the run on the Cloud dashboard** — the richer hosted view: the flow source, **every agent step's transcript**, the run graph, the run's own log, and the run sitting in the same history as your hosted ones. `FLOWS_CLOUD_MIRROR=1` turns it on for a whole shell.\n\n```bash\nflows run review.flow.ts --local-agent --input '{}' # observer link only\nflows run --cloud-mirror review.flow.ts --local-agent --input '{}' # ...and the dashboard\n```\n\nThe run prints both, and the dashboard line names Cloud's run id as well as the page:\n\n```\nObserver: https://agentrelay.com/observer?key=ot_live_...\nDashboard: https://.../dashboard/workflow//runner · flows status --cloud --watch \n```\n\n**That second id matters and is easy to get wrong.** The report's own `runId` is the *journal's* ULID (`01M3B9...`), while the two hosted verbs that take an id — `flows status --cloud ` and `flows logs ` — want Cloud's UUID. Do not pass a journal id to either. Under `--json` both ride in the report as `cloudRunId` and `dashboardUrl`, beside `observerUrl`.\n\n`flows runs` is the odd one out and the way *out* of this problem: it takes no id at all (`flows runs [--limit ] [--json]`) and lists the runs the credential can see, newest first, with each one's Cloud UUID — so it is how you find the id the other two want when you no longer have the terminal that printed it.\n\n**Why it is opt-in, not on by default.** Because it is the richer view, it is also the one that *stores* all of that: source, step metadata, agent transcripts, and the CLI's own stderr. Transcripts are whatever the agent printed, including file contents and command output. Everything goes through the same redactor `flows status` uses — but redaction is pattern matching, and pattern matching has a false-negative rate. So the trigger is an explicit request and never the mere presence of a Cloud login. Only an affirmative counts for the env var (`1`/`true`/`on`/`yes`); `0`, empty, and anything nobody meant as a switch all leave the run local.\n\n**What it buys you, concretely:** the three read verbs start answering for *local* runs, which until `2.0.32` only worked for runs Cloud had launched.\n\n```\n$ flows status --cloud \nRUN 27ab5702-... local-mirror-agent-confirmation completed spend 13,088 in / 61 out / $0.01836\n ✓ ask-claude agent 1 attempt 3.3s claude-haiku-4-5-20251001 · 1 turn · $0.01836\n ✓ ask-codex agent 1 attempt 7.9s\n$ flows logs --step ask-claude # that step's transcript, out of Cloud storage\n```\n\nThree behaviours worth knowing before you rely on it:\n\n- **It cannot fail a run.** Every push collapses to a boolean; each poll and the whole finish are bounded. A Cloud outage costs the run its dashboard page and nothing else. If the mirror is refused you get one line naming which switch asked for it, and the run's exit code is untouched.\n- **The dashboard says the run was local.** The row is `dispatchType: \"local\"`, and the run page shows **Ran on: Your machine** instead of a sandbox tile. Cancel is refused for it — Cloud mirrors a local run, it does not control it, so stop it where it is running.\n- **A `--cloud-mirror` resume is a second dashboard row, linked to the first.** Cloud refuses to move a terminal run back to `running` (its own hosted resume mints a fresh id too), so the resumed attempt carries `resumedFromRunId` and the run page links the two — the parked \"Needs review\" row shows what continued it. The mapping lives in `/cloud-runs/`: the Cloud run id and the deployment, no credential, mode 0600, ageing out at 30 days.\n\n**Requires a Cloud login** (`agent-relay cloud login`, or `FLOWS_CLOUD_TOKEN`) — the same credential every other hosted verb uses. Without one, `--cloud-mirror` prints one line saying the run stays local and the run proceeds normally. A local run that joins no workspace is not a defect (RFC-0001 settled decision 7: the journal is the record, the workspace is one view onto it).\n\n### Exit codes and refusal shapes\n\nEvery refusal before a journal write is exit **2**, printed as `REFUSED [] `. The `` differs by *how* the flow was checked, not just *what* was wrong:\n\n- `flows check` on a YAML/JSON spec, or the declarative-compiler path in general → the preflight's own kind directly: `cli_unresolved`, `model_unknown`, `cli_missing`, `cli_unauthenticated`, `no_executor`, `config_invalid`, etc.\n- `flows run` on a **TypeScript** `.flow.ts` whose authored `f.agent`/`f.llm` call can't resolve a CLI or model at runtime → wrapped and printed as `REFUSED [invalid_spec] ` with the specific reason (`llm_cli_unresolved`, etc.) inside the message text, not as the printed `[kind]` itself.\n\nBoth are real, both are exit 2 — the same underlying problem can print a different `[kind]` depending on whether you hit it via `flows check` on YAML or `flows run` on TypeScript.\n\n## Common mistakes\n\n- **Forgetting `version` in a YAML/JSON `FlowSpec`.** Required, not optional.\n- **Attaching two `.gate()` calls to one step.** `unsupported_gate`: a step takes one. Also note a predicate gate is invisible to `flows check` — prefer a config object when preflight should be able to prove it.\n- **Putting `tools`, `budget`, or any other `FlowHeader` field into `flows.json`.** `flows.json` only accepts `cli`, `executors`, `models`, `mcp`, `deploy` — anything else is `config_invalid`.\n- **Assuming `flows.json`'s `models` sets a default model.** It only validates models already declared elsewhere; it never selects one.\n- **Declaring `.on(...)` trigger handlers without registering the provider in `flows.json`'s `executors`.** Even a flow you only ever run directly (never via a real webhook) needs this, or it's refused `no_executor` before your input is even read.\n- **Calling `f.done('canceled')` for a human's \"no.\"** That's `f.done('declined')` — `canceled` is the kernel's verdict, not an authored body's.\n- **Running an agent/llm-bearing flow without `--local-agent`.** The run parks with nothing attached to do the work.\n- **Letting a git-hygiene flow's cleanup step delete your daemon's own data dir.** Pass `--data-dir` outside the checkout for any flow that runs `git clean`/`git checkout --force` on its own working tree.\n- **Not awaiting a step, or manually `.then()`-chaining one.** Refused (`unawaited_step`) rather than silently ignored.\n- **Running a `.flow.ts` without `--input`.** Required even for flows that don't use their input argument.\n- **Assuming `flows run --cloud` for a one-shot TypeScript flow run is still broken.** It was through `2.0.16` ([flows#461](https://github.com/AgentWorkforce/flows/issues/461), now closed) — fixed in `2.0.17`. If you're on an older CLI, update rather than reach for a workaround; `flows deploy` remains the right choice for a persistent listener regardless.\n\n## What this skill does NOT cover\n\n- **Triggers/webhooks** (`.on(github.pull_request(...), ...)`), **memory retrieval**, and the full **helper** catalog's per-provider quirks (`f.mcp`, provider-specific settings) — each is its own surface; see the [Relayflows product docs](https://agentrelay.com/docs/relayflows) and the [flows-cookbook](https://github.com/AgentWorkforce/flows-cookbook) for real, run-verified examples of each.\n- **Named-agent map equivalent in TypeScript beyond `use:`** — `use` composes complete child flows, not named agent configurations.\n- **YAML-only agent step fields with no TypeScript equivalent**: `recoveryMode`, `surfaces`, `output`, the `agent:` named-agent selector, and enforced `workspace` (TypeScript's `workspace` string is accepted by the type but rejected by the local-agent worker — see **Human approval and direct child flows**). `permissions` is **not** on this list — it is a TypeScript option too (see **The real `Ctx` contract**).\n- **Hosted multi-file child-flow execution** — `flows run --cloud` refuses `use` dependencies even with `--sync-code`; `--cloud-mirror` is the supported route for a local child-flow tree on the Cloud dashboard.\n- The **older `@relayflows/core` `WorkflowBuilder`** engine. Do not use it for new work; this skill intentionally provides no legacy authoring route.\n\n## Quick reference\n\n| Verb / field | Language | Notes |\n|---|---|---|\n| `f.run(command, {timeout?})` / `type: deterministic` | both | shell command, implicit `exit_code` gate |\n| `f.llm(...)` / `type: llm` | both | bare model call, no workspace |\n| `f.agent(name, opts)` / `type: agent` | both | harnessed coding agent, returns `{summary, artifacts}` |\n| `f.human(question, {to})` | TS only | parks the run on a durable `wait.human`; `flows answer human- yes\\|no` then `flows resume` |\n| `use: ['./child.flow.ts']` / `f.dispatch(name, input)` | TS only | `2.0.35`+ direct child in one durable tree; dispatch by declared name |\n| `f.done(reason)` | TS / kernel | one of `success \\| step_failed \\| needs_human \\| declined` from an authored body; `canceled \\| budget_exceeded` are kernel-only |\n| `.gate(config)` / `.gate(predicate, because)` | TS | one per step; both run since flows#449, but only a config object is provable by `flows check` |\n| `options.transport: 'relay'` | TS/YAML `agent` step | dispatch to Agent Relay's task infra; request+receipt, not live chat |\n| `Promise.all([...steps])` | TS | first-class supported concurrent fan-out |\n| `flows.json`: `cli, executors, models, mcp, deploy` | project config | exact accepted key set — nothing else |\n| `flows check ` | CLI | pure validate + preflight, no daemon |\n| `flows run --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` |\n| `flows deploy --repo ... --on ...` | CLI | persistent trigger-based listener; needs `agent-relay cloud login` plus a connected GitHub App + `--on` provider first, or `flow_repository_not_connected` |\n| `flows run --cloud --input ...` | CLI | fixed in `2.0.17` ([flows#461](https://github.com/AgentWorkforce/flows/issues/461)); update if you still see `http_error`/`invalid_input` |\n| `flows run --cloud-mirror --local-agent` | CLI | `2.0.32`+; local run also on the Cloud dashboard, transcripts included. Opt-in; `FLOWS_CLOUD_MIRROR=1` for a shell. `--json` gains `cloudRunId`/`dashboardUrl` |\n| `flows run --cloud` with `use` | CLI | unsupported: one self-contained `.flow.ts` source only, even with `--sync-code` |\n| `flows status --cloud ` / `flows logs ` | CLI | take Cloud's **UUID**, not the journal ULID. Answer for mirrored local runs since `2.0.32` |\n| `flows runs [--limit ]` | CLI | takes **no id** — lists runs newest-first, which is how you find the UUID the two above want |\n| `flows schedule --cron ...` | CLI | cron-based cloud run |\n\n## Verified against\n\n**The `Ctx` and child-flow contracts were checked against the published `@relayflows/surface@2.0.35` / `@relayflows/sdk@2.0.35` declarations.** Earlier gate, helper, and agent-option findings below were established on `2.0.22`; the original `--cloud-mirror` verification was on `2.0.32`.\n\nA three-level root → child → grandchild fixture was run locally from published `2.0.35` packages, then mirrored to the production Cloud dashboard. The [mirrored run](https://agentrelay.com/cloud/dashboard/workflow/0d502725-e4c2-4cfd-aed2-ed0dd49bd6ac/runner) completed with `completionReason: success`; its Cloud graph contained seven succeeded work/dispatch nodes, seven runtime dependency edges, both child labels, and no warnings. A separate `flows run --cloud --sync-code` attempt refused the same tree before admission with `unsupported_source`, which is why this skill does not promise hosted child-flow execution. This checks the graph API used by the dashboard, not an authenticated browser screenshot.\n\n**The `--cloud-mirror` section was verified on `2.0.32`, against production, with the published artifact.** `relayflows@2.0.32` was installed from npm into a scratch project — not run from a source tree — and two real local runs were mirrored to `agentrelay.com`:\n\n- a two-step deterministic flow, read back with `flows status --cloud`, `flows logs` (the `runner.log` round-trip) and `flows runs` (the run appearing in history beside hosted ones);\n- a two-agent flow, one `cli: claude` step and one `cli: codex` step, spending a real `$0.01836` — whose **per-step transcripts were fetched back out of Cloud storage** and rendered in each provider's own frame vocabulary. That is the claim worth having evidence for: an echo-only flow exercises none of the transcript path.\n\nTwo things that verification established and that are easy to assume otherwise. **Flows drives exactly two agent CLIs**: `adapters/index.ts` registers `claude` and `codex`, and anything else resolves to `relayflows-wrapper-v1`, which requires the executable to answer `--relayflows-adapter-v1` with a flows-specific token. A real `devin` CLI on `PATH` rejects that flag outright (`error: unexpected argument`), so it cannot be used as a `cli:` for an agent step no matter what is installed — adding one is a new `adapters/.ts` plus a registry entry. And the **observer projection has no retry**: `run-projection.ts` sets `failed = true` on the first error and every later publish is a no-op, so a transient `429 workspace_busy` — observed for real during this verification, while another run was launching in the same workspace — permanently loses the observer view for that run. The dashboard mirror survived the same window because it classifies `429` as transient and retries on its next poll. If an observer link opens an empty channel, that asymmetry is the first thing to check.\n\nThe earlier `2.0.22` refresh settled several contradictions against the published package: `AgentOptions.permissions` exists on the TypeScript call site (added in `2.0.17`, validated and journaled, not enforced — preflight warns `permissions_unenforced`); `run(command, options?)` takes a second `{timeout}` argument; `f.done` takes six `FLOW_COMPLETION_REASONS`; there is no `budget.maxWallclockMs`; predicate `.gate()` and `artifact_exists` ship; and `f.human` executes. Its then-true finding that `f.dispatch` was unsupported was superseded by `2.0.35`. Older real-run recipes are in [`AgentWorkforce/flows-cookbook`](https://github.com/AgentWorkforce/flows-cookbook); check their dates when comparing behavior.\n\nThe `flows.json` schema refusal, the `--data-dir` git-hygiene interaction, and the `flows run --cloud` bug were reproduced while building that cookbook past `v2.0.16` (2026-09-17). The `2.0.22` refresh also checked the `workspace: readonly` annotation and the `subprocess_gate` env var name against the real CLI. Child-flow claims above instead rest on the published `2.0.35` run and its production Cloud graph.\n" };