diff --git a/.claude/skills/setting-up-agent-relay-desktop/SKILL.md b/.claude/skills/setting-up-agent-relay-desktop/SKILL.md new file mode 100644 index 00000000..a1b0bc34 --- /dev/null +++ b/.claude/skills/setting-up-agent-relay-desktop/SKILL.md @@ -0,0 +1,967 @@ +--- +name: setting-up-agent-relay-desktop +description: Set up or repair Agent Relay Desktop end-to-end from a running Codex or Claude session on macOS or Linux, including verified installation, headless systemd service, device sign-in, new-session uploads, self-registration, direct delivery, webhooks, GitHub subscriptions, and delivery verification. Use when a human asks an agent to install, configure, or finish desktop relay setup without walking through the app UI. +--- + +# Set Up Agent Relay Desktop + +Complete the setup through the private session socket. Do not ask the human to +click through the desktop app. The only normal human step is approving one +device-login link when no reusable `agent-relay` CLI login exists. + +Prefer setting this up with the Agent Relay agent at +, which checks your current state and confirms +each step. Fetch its instructions with +`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`. + +The finished state is: + +- the current release is installed and running; +- sign-in is complete; +- sharing mode is `new` (new sessions upload automatically), unless an existing + install already uses another mode and the human chose to keep it (section 4); +- auto-activate is on, with every existing live session and every future session on the relay; +- Claude Code direct delivery is on (`crossSessionInbound` is `accept`); +- this Codex or Claude session is registered and accepts direct delivery; +- the requested webhook and integration subscriptions work; +- the human receives the session's `agent@machine` address and verification evidence. + +Never put a Cloud token, workspace key, agent token, or webhook secret in a +Relay message. The webhook secret is returned once to this local calling +session; keep it in a mode-0600 temporary file and delete the file after the +test. + +## Install this skill for Codex and Claude Code + +When the task includes installing this skill, install its canonical Claude +package, then place that unchanged `SKILL.md` where Codex actually loads global +skills: + +```sh +npx prpm install @agent-relay/setting-up-agent-relay-desktop --as claude --global -y +codex_skill="$HOME/.codex/skills/setting-up-agent-relay-desktop" +mkdir -p "$codex_skill" +install -m 0644 \ + "$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md" \ + "$codex_skill/SKILL.md" +cmp \ + "$HOME/.claude/skills/setting-up-agent-relay-desktop/SKILL.md" \ + "$codex_skill/SKILL.md" +``` + +Do not use prpm's `--as codex` conversion for this package. It currently writes +under `~/.agents/skills`, which Codex does not load as its global skill folder, +and its conversion drops explanatory paragraphs from this Markdown workflow. +Confirm both final paths explicitly before continuing. + +## 1. Detect the host and current session + +Run setup commands from this agent's own shell tool. Do not run them from an +unrelated terminal: the app identifies the caller from kernel credentials and +the Codex/Claude process ancestry. + +```sh +uname -s +uname -m +test -n "${TMUX:-}" && printf 'session=tmux\n' || true +test -n "${SSH_CONNECTION:-}" && printf 'transport=ssh\n' || true +if test "$(uname -s)" = Linux; then + if command -v dpkg-query >/dev/null; then + printf 'linux_install=deb\n' + else + printf 'linux_install=per-user-tarball\n' + fi +fi +command -v systemctl >/dev/null && systemctl --user is-system-running || true +``` + +On Ubuntu with `dpkg`, prefer the `.deb` path below. On Arch-based and other +systemd Linux hosts without `dpkg`, use the checksum-verified per-user tarball +path; do not try to install the `.deb`. The app scans same-user process trees +and TTYs; it does not require the service itself to share the tmux session. + +`systemctl --user is-system-running` may report `degraded` because an unrelated +user unit failed. That is not a Relay failure when +`systemctl --user is-active agent-relay.service` reports `active` and the Relay +socket passes its status check. + +Find an existing socket without printing private files: + +```sh +pointer="$HOME/.agentworkforce/desktop/relay-socket" +if test -r "$pointer"; then relay_socket=$(sed -n '1p' "$pointer"); fi +if test -n "${relay_socket:-}" && ! test -S "$relay_socket"; then unset relay_socket; fi +if test -z "${relay_socket:-}" && test -n "${XDG_RUNTIME_DIR:-}"; then + relay_socket="$XDG_RUNTIME_DIR/agent-relay/relay.sock" +fi +if ! test -S "${relay_socket:-/nonexistent}" && test "$(uname -s)" = Linux; then + relay_socket="/run/user/$(id -u)/agent-relay/relay.sock" +fi +c="$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock" +if ! test -S "${relay_socket:-/nonexistent}" && test "$(uname -s)" = Darwin && test -S "$c"; then # pointer file missing: use the app's own socket + relay_socket=$c +fi +test -S "${relay_socket:-/nonexistent}" && printf 'socket=%s\n' "$relay_socket" +``` + +If the socket exists, inspect it before installing anything: + +```sh +curl -sS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | jq +``` + +**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can +take about 30 seconds on a busy Desktop (tracked in +AgentWorkforce/relay-desktop#333). Give each socket call a 60-second timeout +(`curl --max-time 60`), and do not treat a slow reply as a failure. The probe's +`relay socket-request` can give up sooner with "Probe could not finish. Check +your connection and run setup again"; on these paths that is the same latency, +not a setup problem. Retry the call once before reporting a blocker, and do not +start a second install or sign-in because of it. + +Preserve a working newer install. When the socket is absent, check for an +installed-but-stopped copy before downloading: use `dpkg-query -W agent-relay` +when `dpkg-query` exists and `systemctl --user status agent-relay.service` on +Linux, or test `/Applications/Agent Relay.app` on macOS. Start it and retry +status first. +Update an older install only when it lacks the setup contract or reports an +older version. A `404` from `/setup/status` means the installed build is too +old for agent-driven setup and should be updated. + +## 2. Install or update with checksum verification + +Use a private temporary directory. Never skip the published SHA-256 check. + +### Ubuntu LTS, headless `.deb` (preferred) + +Select the release architecture and install: + +This path needs passwordless sudo or one human-approved sudo prompt for the +package install and `loginctl enable-linger`. Explain that prerequisite before +starting; never capture or relay a sudo password. + +```sh +case "$(uname -m)" in + x86_64|amd64) relay_arch=x64 ;; + aarch64|arm64) relay_arch=arm64 ;; + *) printf 'Unsupported Linux architecture: %s\n' "$(uname -m)" >&2; exit 2 ;; +esac +relay_tmp=$(mktemp -d) +chmod 700 "$relay_tmp" +release=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download +curl -fL "$release/AgentRelay-Linux-$relay_arch.deb" -o "$relay_tmp/AgentRelay.deb" +curl -fL "$release/AgentRelay-Linux-$relay_arch.deb.sha256" -o "$relay_tmp/AgentRelay.deb.sha256" +if ! (cd "$relay_tmp" && sed "s#AgentRelay-Linux-$relay_arch.deb#AgentRelay.deb#" AgentRelay.deb.sha256 | sha256sum --check -); then + printf 'Agent Relay checksum verification failed; refusing installation.\n' >&2 + exit 1 +fi +sudo apt-get install -y "$relay_tmp/AgentRelay.deb" +sudo loginctl enable-linger "$USER" +systemctl --user daemon-reload +systemctl --user enable agent-relay.service +systemctl --user restart agent-relay.service +systemctl --user is-active agent-relay.service +loginctl show-user "$USER" -p Linger +``` + +The unit runs the bundled core headless: no display, X server, or GTK +window is needed. This session's own GitHub subscriptions (section 6) go +through the Desktop and need no CLI. The separate Agent Relay CLI is needed +only to subscribe a fleet-spawned or named agent; record whether it is present +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 + 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 + relay_cli=$candidate + break + fi + done +fi +if test -n "$relay_cli"; then + "$relay_cli" --version + "$relay_cli" integration subscribe --help >/dev/null +else + printf 'No Agent Relay CLI found; Desktop subscriptions do not need one.\n' +fi +``` + +On Linux without `dpkg`, use this per-user tarball installation under a +versioned user directory. It is also the fallback on Ubuntu when `.deb` +installation is unavailable and the human approves a per-user install. The +archive contains a unit whose `/usr/bin` path is correct for packages, so +rewrite only that `ExecStart` in the user copy: + +```sh +case "$(uname -m)" in + x86_64|amd64) relay_arch=x64 ;; + aarch64|arm64) relay_arch=arm64 ;; + *) printf 'Unsupported Linux architecture: %s\n' "$(uname -m)" >&2; exit 2 ;; +esac +relay_tmp=$(mktemp -d) +chmod 700 "$relay_tmp" +release=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download +curl -fL "$release/AgentRelay-Linux-$relay_arch.tar.gz" -o "$relay_tmp/AgentRelay.tar.gz" +curl -fL "$release/AgentRelay-Linux-$relay_arch.tar.gz.sha256" -o "$relay_tmp/AgentRelay.tar.gz.sha256" +if ! (cd "$relay_tmp" && sed "s#AgentRelay-Linux-$relay_arch.tar.gz#AgentRelay.tar.gz#" AgentRelay.tar.gz.sha256 | sha256sum --check -); then + printf 'Agent Relay checksum verification failed; refusing installation.\n' >&2 + exit 1 +fi +mkdir "$relay_tmp/extracted" +tar -xzf "$relay_tmp/AgentRelay.tar.gz" -C "$relay_tmp/extracted" +relay_tree="$relay_tmp/extracted/AgentRelay-linux-$relay_arch" +test -x "$relay_tree/usr/bin/agent-relay" +relay_version=$(sed -n 's/^__version__ = "\([0-9A-Za-z._-]*\)"$/\1/p' \ + "$relay_tree/usr/lib/agent-relay/agent_relay/__init__.py") +case "$relay_version" in + ''|*[!0-9A-Za-z._-]*) printf 'Invalid Agent Relay version in archive.\n' >&2; exit 1 ;; +esac +relay_unit="$HOME/.config/systemd/user/agent-relay.service" +relay_installed_version= +if test -S "${relay_socket:-/nonexistent}"; then + relay_installed_version=$(curl -fsS --max-time 60 --unix-socket "$relay_socket" \ + http://relay/setup/status 2>/dev/null | jq -r '.data.version // empty' || true) +fi +if test -z "$relay_installed_version" && test -r "$relay_unit"; then + relay_installed_version=$(sed -n \ + 's#^ExecStart=.*/\.local/lib/agent-relay/\([^/ ]*\)/.*#\1#p' \ + "$relay_unit") +fi +case "$relay_installed_version" in + ''|*[!0-9A-Za-z._-]*) relay_installed_version= ;; +esac +if test -n "$relay_installed_version" && \ + test "$relay_installed_version" != "$relay_version" && \ + test "$(printf '%s\n%s\n' "$relay_version" "$relay_installed_version" | sort -V | tail -n 1)" = "$relay_installed_version"; then + printf 'Installed Agent Relay %s is newer than archive %s; refusing downgrade.\n' \ + "$relay_installed_version" "$relay_version" >&2 + exit 1 +fi +relay_prefix="$HOME/.local/lib/agent-relay/$relay_version" +mkdir -p "$relay_prefix" +cp -a "$relay_tree/usr" "$relay_prefix/" +mkdir -p "$(dirname "$relay_unit")" +sed "s#^ExecStart=/usr/#ExecStart=$relay_prefix/usr/#" \ + "$relay_tree/usr/lib/systemd/user/agent-relay.service" >"$relay_unit.tmp" +chmod 0644 "$relay_unit.tmp" +mv "$relay_unit.tmp" "$relay_unit" +grep -F "ExecStart=$relay_prefix/usr/" "$relay_unit" | grep -q -- ' --headless$' +if ! loginctl show-user "$USER" -p Linger | grep -qx 'Linger=yes'; then + sudo loginctl enable-linger "$USER" +fi +systemd-analyze --user verify "$relay_unit" +systemctl --user daemon-reload +systemctl --user enable agent-relay.service +systemctl --user restart agent-relay.service +systemctl --user is-active agent-relay.service +loginctl show-user "$USER" -p Linger +# List the app in the desktop's app launcher (no-op on a headless server). +# Releases before v2026.10.13 lack agent-relay-desktop; the app then adds the +# entry the first time it is opened. +if test -x "$relay_prefix/usr/bin/agent-relay-desktop"; then + "$relay_prefix/usr/bin/agent-relay-desktop" --install-launcher || true +fi +``` + +The unit's `ExecStart` names the archive's own path under `/usr/`, either the +launcher or the bundled `agent-relay-probe`; the rewrite moves whichever it is +under `$relay_prefix`. The launcher entry runs `agent-relay-desktop` by its +full path, never the bare `agent-relay`, which is also the CLI's name. + +Do not improvise Alpine or RHEL distribution packages. On a compatible +systemd host without `dpkg`, use only the verified per-user tarball path above; +non-systemd hosts remain unsupported by this Linux workflow. + +### macOS DMG + +```sh +case "$(uname -m)" in + arm64) relay_arch=arm64 ;; + x86_64) relay_arch=x64 ;; + *) printf 'Unsupported Mac architecture: %s\n' "$(uname -m)" >&2; exit 2 ;; +esac +relay_tmp=$(mktemp -d) +chmod 700 "$relay_tmp" +release=https://github.com/AgentWorkforce/relay-desktop-releases/releases/latest/download +curl -fL "$release/AgentRelay-macOS-$relay_arch.dmg" -o "$relay_tmp/AgentRelay.dmg" +curl -fL "$release/AgentRelay-macOS-$relay_arch.dmg.sha256" -o "$relay_tmp/AgentRelay.dmg.sha256" +if ! (cd "$relay_tmp" && sed "s#AgentRelay-macOS-$relay_arch.dmg#AgentRelay.dmg#" AgentRelay.dmg.sha256 | shasum -a 256 --check -); then + printf 'Agent Relay checksum verification failed; refusing installation.\n' >&2 + exit 1 +fi +mount_point=$(hdiutil attach -nobrowse -readonly "$relay_tmp/AgentRelay.dmg" | awk '/\/Volumes\// {sub(/^.*\/Volumes\//,"/Volumes/"); print; exit}') +incoming_id=$(defaults read "$mount_point/Agent Relay.app/Contents/Info" CFBundleIdentifier 2>/dev/null || true) +if test "$incoming_id" != com.agentrelay.desktop; then + hdiutil detach "$mount_point" >/dev/null 2>&1 || true + printf 'Downloaded app has unexpected bundle id %s; refusing installation.\n' "$incoming_id" >&2 + exit 1 +fi +if test -d "/Applications/Agent Relay.app"; then + existing_id=$(defaults read "/Applications/Agent Relay.app/Contents/Info" CFBundleIdentifier 2>/dev/null || true) + if test "$existing_id" != com.agentrelay.desktop; then + hdiutil detach "$mount_point" >/dev/null 2>&1 || true + printf 'Existing Agent Relay.app has unexpected bundle id %s; refusing overwrite.\n' "$existing_id" >&2 + exit 1 + fi +fi +osascript -e 'tell application "Agent Relay" to quit' 2>/dev/null || true +ditto "$mount_point/Agent Relay.app" "/Applications/Agent Relay.app" +hdiutil detach "$mount_point" +open -a "Agent Relay" +``` + +If `/Applications` needs administrator access, use `sudo ditto` only after the +human has authorized installation. Do not remove or overwrite another app with +an unexpected bundle identity. + +After either install, wait up to 30 seconds for the pointer: + +```sh +app_sock="$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock" +for _ in $(seq 1 30); do + test -r "$HOME/.agentworkforce/desktop/relay-socket" && break + test "$(uname -s)" = Darwin && test -S "$app_sock" && break # the app is already listening + sleep 1 +done +relay_socket=$(sed -n '1p' "$HOME/.agentworkforce/desktop/relay-socket" 2>/dev/null) +if ! test -S "$relay_socket" && test "$(uname -s)" = Darwin && test -S "$app_sock"; then + relay_socket=$app_sock +fi +if ! test -S "$relay_socket" && test "$(uname -s)" = Linux; then + relay_socket="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/agent-relay/relay.sock" +fi +test -S "$relay_socket" +curl -fsS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | jq -e '.ok and (.data.version | length > 0)' +``` + +## 3. Sign in by device approval + +Before choosing a workspace, check what the human already has. These commands +only read state. The CLI checks run only when section 2 found a `relay_cli`; +never call the `.deb`'s `/usr/bin/agent-relay` launcher for them: + +```sh +curl -sS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | \ + jq '{sign_in: .data.sign_in, workspace: .data.workspace}' +if test -n "${relay_cli:-}"; 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 + "$relay_cli" status # workspace, cloud login and local broker +fi +``` + +Sign in to the workspace they already use. Pick a different or new workspace +only when the human explicitly asks for it. + +For a multi-server rollout, obtain the existing enterprise workspace UUID from +the human and pass it explicitly on every server. Do not guess it and do not +use `{}` when the target workspace is known: that could select a default/free +workspace and split the fleet. Use `{}` only for an individual setup where the +human has confirmed that the reusable login's current workspace is intended. + +```sh +relay_workspace_uuid='HUMAN-PROVIDED-WORKSPACE-UUID' +sign_in_payload=$(jq -nc --arg workspace "$relay_workspace_uuid" '{workspace:$workspace}') +sign_in=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + -H 'Content-Type: application/json' \ + -d "$sign_in_payload" http://relay/setup/sign-in) +printf '%s\n' "$sign_in" | jq \ + '{ok, data: { + status: .data.status, + reused_login: .data.reused_login, + workspace: .data.workspace + }, error}' +``` + +If sign-in reports that older upload schedules could not be inspected, first +inspect the user's legacy cron, launchd, and user-service upload schedules. +After completing that safety check, retry the original request with the +acknowledgement added; never use it merely to suppress the error: + +```sh +sign_in_payload=$(printf '%s\n' "$sign_in_payload" | \ + jq '. + {acknowledge_uninspected_schedules:true}') +sign_in=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + -H 'Content-Type: application/json' \ + -d "$sign_in_payload" http://relay/setup/sign-in) +printf '%s\n' "$sign_in" | jq \ + '{ok, data: { + status: .data.status, + reused_login: .data.reused_login, + workspace: .data.workspace + }, error}' +``` + +Print a device link and code only when the response actually contains a code: + +```sh +user_code=$(printf '%s\n' "$sign_in" | jq -r '.data.user_code // empty') +if test -n "$user_code"; then + verification_url_complete=$(printf '%s\n' "$sign_in" | \ + jq -r '.data.verification_url_complete // empty') + if test -n "$verification_url_complete"; then + printf 'Open %s and approve code %s\n' "$verification_url_complete" "$user_code" + else + verification_url=$(printf '%s\n' "$sign_in" | \ + jq -r '.data.verification_url // empty') + printf 'Open %s and enter code %s\n' "$verification_url" "$user_code" + fi +fi +``` + +If the response is `signed_in` with `reused_login:true`, the app reused an +eligible same-origin CLI or desktop login and intentionally issued no device +code. Confirm the returned workspace id and name with the human rather than +waiting for a code, then continue only if it is the intended workspace. + +For `pending_approval`, the human can approve from any phone or browser. Do not +create another code while this one is pending. A `preparing` response is also +normal: poll `/setup/status` at the reported interval. Preparing local history +can take about a minute when the app reuses an existing `agent-relay` CLI login. +Poll both states until sign-in finishes. The reason for a failure is in +`.data.sign_in_message`, not in `.data.sign_in` (which only says `error`), so +print it, and map the known messages to their fix instead of dumping raw state: + +```sh +while :; do + state=$(curl -sS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status) + phase=$(printf '%s' "$state" | jq -r '.data.sign_in // .error.code') + case "$phase" in + signed_in) + printf '%s\n' "$state" | jq \ + '{sign_in: .data.sign_in, workspace: .data.workspace}' + break + ;; + preparing|pending_approval) ;; + 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 + case "$msg" in + *"older managed history uploader"*) + printf 'See "Sign-in blocked by a legacy uploader" under Recovery.\n' >&2 ;; + *"older upload schedules"*) + printf 'Inspect legacy schedules, then retry with acknowledge_uninspected_schedules (see above).\n' >&2 ;; + *) printf '%s\n' "$state" | jq '.data' >&2 ;; + esac + exit 1 + ;; + esac + sleep "$(printf '%s' "$sign_in" | jq -r '.data.interval // 5')" +done +``` + +## 4. Apply the agent-led defaults and register this session + +Read the current sharing mode before changing it. `/setup/status` reports it as +`.data.sharing_mode` (`new`, `all`, or `selected`; `new` when nothing is +uploading yet). The probe reports the same field, `sharing_mode`, in +`agent-relay-probe status --account --workspace --json`. + +```sh +relay_prior_mode=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + http://relay/setup/status | jq -r '.data.sharing_mode // empty') +case "$relay_prior_mode" in + new|all|selected) printf 'sharing_mode=%s\n' "$relay_prior_mode" ;; + *) printf 'Could not read the sharing mode; ask the human before changing sharing.\n' >&2 + exit 1 ;; +esac +``` + +Continue only with a verified `new`, `all`, or `selected`. If the status read +fails or reports no mode, retry once; if it is still unknown, stop and ask the +human how to proceed rather than sending any sharing request. + +On a fresh install, or when it already reports `new`, keep `new`. When an +existing install reports `all` (which also uploads past sessions, so it is +broader than this skill's default) or `selected`, tell the human the current +mode and ask whether to keep it or change it to `new`. Never change it silently. +Only when the human explicitly chooses a different mode, set +`relay_sharing_mode` to that answer at the top of the next block; left unset, +the block keeps the mode the app reports, so nothing changes without an answer. +Remember the chosen mode: section 8 checks it again, and shell variables do not +survive between separate tool calls. + +Then set and verify all three agent-led defaults even when the app's `/setup/*` +bootstrap already applied them. Sharing mode `new` uploads every new session; +auto-activation puts every existing live session and every future session on +the relay; direct delivery sets Claude Code `crossSessionInbound` to `accept`: + +```sh +# Unset keeps the current mode; set it only to the human's explicit answer. +if test -z "${relay_sharing_mode:-}"; then + relay_sharing_mode=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + http://relay/setup/status | jq -r '.data.sharing_mode // empty') +fi +case "$relay_sharing_mode" in + new|all|selected) ;; + *) printf 'Sharing mode unknown; ask the human before changing sharing.\n' >&2 + exit 1 ;; +esac +direct_delivery=$(curl -sS --max-time 60 --unix-socket "$relay_socket" -H 'Content-Type: application/json' \ + -d '{"enabled":true}' http://relay/setup/direct-delivery) +printf '%s\n' "$direct_delivery" | jq +if ! printf '%s\n' "$direct_delivery" | jq -e '.ok and .data.direct_delivery'; then + if printf '%s\n' "$direct_delivery" | jq -e '.error.code == "managed_policy"' >/dev/null; then + printf 'Organization-managed Claude settings forbid direct delivery.\n' >&2 + fi + exit 1 +fi +curl -sS --max-time 60 --unix-socket "$relay_socket" -H 'Content-Type: application/json' \ + -d "$(jq -nc --arg mode "$relay_sharing_mode" '{mode:$mode}')" \ + http://relay/setup/sharing | jq +curl -sS --max-time 60 --unix-socket "$relay_socket" -H 'Content-Type: application/json' \ + -d '{"enabled":true}' \ + http://relay/setup/auto-activate | jq +register=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + -H 'Content-Type: application/json' \ + -d '{}' http://relay/register) +printf '%s\n' "$register" | jq +registration_status=$(curl -sS --max-time 60 --unix-socket "$relay_socket" \ + http://relay/setup/status) +printf '%s\n' "$registration_status" | \ + jq '{session: .data.session, direct_delivery: .data.direct_delivery, error}' +printf '%s\n' "$registration_status" | jq -e \ + '.ok and (.data.session.id | type == "string" and length > 0) and + .data.session.registered == true and .data.session.direct_delivery == true' +``` + +**Codex on macOS.** The system `curl` hides a Codex session's identity from the +socket there, so `/register` is refused `not_a_relay_session` (section 6 says +the same for subscriptions). In a Codex session on macOS, find `relay_probe` +with section 6's first block and send the registration through it instead of +the `curl` line above, then read `/setup/status` as above: + +```sh +register=$(printf '{}' | "$relay_probe" relay socket-request \ + --socket "$relay_socket" --method POST --path /register) +``` + +If `/setup/sign-in` is refused the same way, send its payload through the probe +the same way. Without a probe, stop and report it rather than registering +another process. + +Direct delivery is checked first so a managed-policy refusal stops setup +before auto-activation or upload settings are changed. Report that refusal +clearly; never claim the three-default setup completed. + +Sharing mode and auto-activate are different settings: the former +controls upload eligibility and the latter controls Relay registration. The +setup flow requires both. + +`POST /register` discovers the nearest live Codex or Claude ancestor. A session +inside tmux over SSH is valid. Never pass a session id or token in the body. +The default name includes a 128-bit session-derived suffix, so dozens of +sessions in the same checkout receive distinct `name@direct` addresses. Save +and report the address from `/setup/status` for each session; never reuse one +session's address for another. `/register` may omit the session id or +direct-delivery state, so its response alone is not verification. Require the +status response to show this session's non-empty id, `registered:true`, and +`session.direct_delivery:true` as above. An app fix is expected to add those +fields to `/register`, but the status check remains authoritative. + +## 5. Create and test the webhook + +Perform this section only when the human requested a webhook (or explicitly +asked for the full end-to-end setup). Otherwise skip it; webhook creation is a +persistent external mutation, not an install prerequisite. + +Capture the one-time secret without echoing it: + +```sh +umask 077 +hook_file=$(mktemp) +curl -sS --max-time 60 --unix-socket "$relay_socket" -H 'Content-Type: application/json' \ + -d '{}' http://relay/webhooks >"$hook_file" +jq '{ok, created: .data.created, webhook_id: .data.webhook_id, url: .data.url, error}' "$hook_file" +``` + +When `created:true`, send a unique marker. Initialize every temporary path so +cleanup can name and delete exact files without `-f` or globs: + +```sh +hook_config= +marker= +webhook_post_ok=false +webhook_post_status=0 +if test "$(jq -r '.data.created // false' "$hook_file")" = true && \ + test "$(jq -r '.data.secret // empty' "$hook_file")" != ''; then + hook_url=$(jq -r '.data.url' "$hook_file") + hook_secret=$(jq -r '.data.secret' "$hook_file") + hook_config=$(mktemp) + chmod 600 "$hook_config" + printf 'header = "Authorization: Bearer %s"\n' "$hook_secret" >"$hook_config" + marker="agent-relay-webhook-test-$(date +%s)" + if curl -fsS --config "$hook_config" -X POST "$hook_url" \ + -H 'Content-Type: application/json' \ + -d "{\"text\":\"$marker\",\"source\":\"agent-driven-setup\"}"; then + webhook_post_ok=true + else + webhook_post_status=$? + fi +else + printf 'Webhook already exists; its original secret is required to repeat delivery verification.\n' >&2 +fi +if test -n "$hook_config" && test -e "$hook_config"; then + rm -- "$hook_config" +fi +if test -e "$hook_file"; then + rm -- "$hook_file" +fi +unset hook_secret +if test "$webhook_post_ok" = true; then + printf 'Webhook test sent; end this turn and wait for marker: %s\n' "$marker" +elif test -n "$marker"; then + printf 'Webhook test POST failed with curl status %s; do not wait for delivery.\n' \ + "$webhook_post_status" >&2 + exit "$webhook_post_status" +fi +``` + +Do not claim success merely because the POST returned 2xx, and do not sleep or +poll for the injected message during the same turn. Relay injects only after +the session becomes idle between turns. After the POST and exact-file cleanup, +end the current turn by saying that setup is waiting for the exact marker. On +the next turn, confirm that the injected body contains that marker and its +Agent Relay header contains a full message id; record that id in the final +evidence. If a polling consumer is part of the setup, also observe that same id +there before declaring the webhook test complete. If the webhook already +existed, its secret is correctly returned as null; do not revoke or recreate it +without human authorization. Ask for the existing secret or report that the +delivery test could not be repeated. + +## 6. Subscribe requested integrations + +Require the provider and repository from the human. A GitHub pull request needs +one resource glob, its number (`pulls/NUMBER/**`). Relayfile matches the PR's +reviews, review comments, conversation comments and check runs to that glob by +the PR they reference, so no other glob is needed: nothing is written under +`pulls/NUMBER/reviews/**`, `pulls/NUMBER/status/**` or +`issues/NUMBER/comments/**`, and subscriptions on those never deliver. This +works on every released Desktop and is the path to use today. If a review or +comment then never arrives, read it back in relayfile at its own path (the +table in `subscribing-relay-webhooks-and-writebacks`): a 404 means the event +never reached relayfile +([relayfile-cloud#317](https://github.com/AgentWorkforce/relayfile-cloud/issues/317)), +not that the subscription is wrong. + +First find the Desktop's `agent-relay-probe`. macOS needs it for Codex sessions +(below) and it is the client for the one-command form once that ships: + +```sh +relay_probe= +for candidate in \ + "$HOME/.local/bin/agent-relay-probe" \ + /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \ + "/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe" +do + if test -x "$candidate"; then relay_probe=$candidate; break; fi +done +if test -z "$relay_probe" && test -d "$HOME/.local/lib/agent-relay"; then + relay_probe=$(find "$HOME/.local/lib/agent-relay" -type f -perm -u+x \ + -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null) +fi +printf 'relay_probe=%s\n' "${relay_probe:-none}" +``` + +A running Desktop keeps `~/.local/bin/agent-relay-probe` at its own version; the +other candidates are the copies bundled by the `.deb`, the macOS app, and the +per-user tarball. Then subscribe through the socket. On macOS use the probe +client when there is one: the system `curl` hides a Codex session's identity +there and is refused `not_a_relay_session`, so a Codex session with no probe +stops instead. `curl` is correct for Claude Code and on Linux: + +```sh +# One client for every socket request in this section and in undo: the probe +# on macOS (system curl hides a Codex session there), curl elsewhere. +relay_req() { # METHOD PATH [JSON body] + if test "$(uname -s)" = Darwin && test -n "${relay_probe:-}"; then + printf '%s' "${3:-}" | "$relay_probe" relay socket-request \ + --socket "$relay_socket" --method "$1" --path "$2" + elif test "$(uname -s)" = Darwin && test -n "${CODEX_THREAD_ID:-}"; then + printf 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.\n' >&2 + return 1 + elif test -n "${3:-}"; then + curl -sS --max-time 60 --unix-socket "$relay_socket" -X "$1" \ + -H 'Content-Type: application/json' -d "$3" "http://relay$2" + else + curl -sS --max-time 60 --unix-socket "$relay_socket" -X "$1" "http://relay$2" + fi +} + +# This PR's subscriptions, in whichever form they were made. Fails, rather than +# answering "none", when the status cannot be read: both callers stop then. +pr_url='https://github.com/OWNER/REPO/pull/NUMBER' +pr_resources() { + relay_status=$(relay_req GET /setup/status) || return 1 + # -s reads the whole body as one list, so an empty body is an error too. + printf '%s\n' "$relay_status" | jq -rs --arg url "$pr_url" \ + --arg short 'OWNER/REPO#NUMBER' \ + --arg pulls '/github/repos/OWNER/REPO/pulls/NUMBER/' \ + --arg issues '/github/repos/OWNER/REPO/issues/NUMBER/' ' + if length == 1 and .[0].ok == true + and (.[0].data.integrations | type) == "array" then + .[0].data.integrations[] | select(.provider == "github") | .resource + | select(. == $url or . == $short or startswith($url + "/") + or startswith($pulls) or startswith($issues)) + else + error("Agent Relay status is unreadable; no subscription was changed.") + end' +} + +held=$(pr_resources) || exit 1 +if printf '%s\n' "$held" | grep -Fqx -e "$pr_url" -e 'OWNER/REPO#NUMBER'; then + # The one-command subscription is a single complete request: nothing to add. + printf 'Already subscribed:\n%s\n' "$held" +elif test -z "$held" && test -n "${relay_probe:-}" && \ + "$relay_probe" relay --help 2>&1 | grep -q '^ subscribe'; then + # The one-command form (below), only for a PR with no subscription yet and + # only when the installed probe has it. + "$relay_probe" relay subscribe "$pr_url" | jq +else + relay_req POST /integrations/subscribe \ + "$(jq -nc --arg resource '/github/repos/OWNER/REPO/pulls/NUMBER/**' \ + '{provider:"github",resource:$resource}')" | jq +fi +relay_req GET /setup/status | jq -c '.data.integrations' +``` + +Require `.ok` and `.data.subscribed` to be `true`, and the entry in the status +list to be `ready: true`. A repeated request returns HTTP 409 +`conflict` ("already subscribed"). That is success only for a binding made on +or after 2026-09-19. Earlier bindings predate PR-identity matching +(relayfile-cloud#237, relaycast#447), match paths literally and miss reviews +and comments. If the binding may be older, or you cannot tell, recreate it +once: `relay_req DELETE /integrations/subscribe` with the same body, then +subscribe again. Verified on +Desktop 2026.10.6 with both the probe client and `curl`. + +**One-command form (not yet released).** `relay-desktop#216` adds +`agent-relay-probe relay subscribe 'https://github.com/OWNER/REPO/pull/NUMBER'` +(or `OWNER/REPO#NUMBER`), which makes one numeric `pulls/NUMBER/**` subscription +that the Desktop correlates with the PR's reviews, comments, and checks. It is +absent from Desktop 2026.10.6, where it fails with `unrecognized subcommand +'subscribe'`, so do not lead with it. Use it only when the installed probe +advertises it. The block above makes that choice, and first checks the +session's existing subscriptions. A pull request already subscribed with the +one-command form is left alone. A pull request already holding +the number glob answers 409 `conflict` on a repeat; recreate that binding once +if it may predate 2026-09-19 (see above). Extra `reviews`, `status` or +`issues/NUMBER/comments` globs from an earlier version of this skill never +deliver; unsubscribe them. + +Once released, re-test it before relying on its details; the behavior described +in that PR (a repeat answering `"already_subscribed":true`, `--remove` to end it, +and a numeric subscription covering reviews, comments and checks) has not been verified here. + +Refusals and what they mean: + +- `not_a_relay_session`, "Register this session before subscribing it": run + section 4's `POST /register` first. +- `not_a_relay_session`, "already managed by this computer's relay" from + `/register`: this session was started by the Agent Relay fleet broker, not + the Desktop. Use the workspace CLI below instead. +- `refused` or `busy`: an earlier subscription is still being made or cleaned + up; repeat the command shortly. + +A fleet-spawned session, or an operator subscribing a named agent, uses the +`agent-relay` CLI with `--to @AGENT_NAME` (a spawned worker's name is in +`RELAY_AGENT_NAME`). **`agent-relay` 13.0.0 is the minimum.** Older CLIs let +the worker's ambient `RELAY_AGENT_TOKEN` displace the workspace key and fail +with `Workspace key required (rk_live_...)` even though `RELAY_WORKSPACE_KEY` +is set: + +```sh +pull_glob='/github/repos/OWNER/REPO/pulls/NUMBER/**' +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') +fi +if test -n "$relay_cli_major" && test "$relay_cli_major" -ge 13; then + "$relay_cli" integration subscribe github \ + --resource "$pull_glob" --to '@AGENT_NAME' --no-input +elif test -n "$relay_cli_major"; then + # 12.x and older: keep the ambient agent token from displacing the key. + env -u RELAY_AGENT_TOKEN "$relay_cli" integration subscribe github \ + --resource "$pull_glob" --to '@AGENT_NAME' --no-input +else + # No usable installed CLI: run the fixed release without installing it. + npx -y agent-relay@13 integration subscribe github \ + --resource "$pull_glob" --to '@AGENT_NAME' --no-input +fi +``` + +Use the `relay_cli` found in section 2, 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. + +The CLI path follows `orchestrating-agent-relay`, which subscribes the same +single `pulls/NUMBER/**` glob. Never put the workspace key in argv. + +Make the subscribe step a required post-create step in the agent's PR +workflow: whenever it authors a PR, it subscribes its own live session +immediately, without waiting for an operator to name the PR. This is the +authored-PR pattern from `orchestrating-agent-relay`; it covers every future PR +the agent authors without subscribing unrelated repository traffic. The +Desktop removes the subscription itself after the PR closes or merges. +For real GitHub verification, use a harmless event the human authorized +on that PR (for example, a test conversation comment and a requested bot +review). After causing the event, do not wait or poll within the active turn: +end the turn saying which event marker is expected. Confirm the injected event +and record its full message id on the next turn after Relay has delivered it. +Do not create a comment, review, or rerun without authorization. A successful +subscribe response alone does not prove end-to-end delivery. + +## 7. Coexist with a REST-polling MCP + +Do not disable an existing polling consumer. Desktop injection acknowledges +only this registered agent's delivery queue and does not advance a separate +workspace consumer's cursor. A polling MCP should use its own identity/cursor, +not this session agent's `/v1/deliveries` token. + +Injected text contains both the full Relaycast message id and a short `ref`. +When polling and injection can surface the same workspace message, deduplicate +on the full message id before acting. During verification, leave the poller +running and observe the test message there. Then end the current turn so Relay +can inject it, and on the next turn confirm the same full id arrived while the +agent acted only once. + +## 8. Final verification and report + +Read status again: + +```sh +curl -sS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | \ + 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, defaults_error: .data.defaults_error, uploader: .data.uploader, session: .data.session, webhook: .data.webhook, integrations: .data.integrations}' +``` + +Require all three defaults before declaring setup complete. Set +`relay_sharing_mode` again to the mode chosen in section 4 (`new`, or the +existing mode the human kept); the check refuses to guess it: + +```sh +: "${relay_sharing_mode:?set relay_sharing_mode to the mode chosen in section 4}" +curl -fsS --max-time 60 --unix-socket "$relay_socket" http://relay/setup/status | \ + jq -e --arg mode "$relay_sharing_mode" '.ok and .data.sharing_mode == $mode and .data.auto_activate == true and .data.direct_delivery == true' +``` + +Report the exact version, `signed_in`, signed-in workspace id and name, sharing +mode (`new`, or the existing mode the human kept), auto-activate `true`, direct-delivery `true`, +uploader health, session address, direct-delivery state, webhook test marker, +subscriptions, real GitHub event evidence, and polling-coexistence result. +Separate verified facts from steps that still require a human or external +event. + +## Recovery and undo + +- **Socket missing or slow:** check `systemctl --user status agent-relay.service` + on Linux or `open -a "Agent Relay"` on macOS. Confirm the pointer names a + socket. Never delete a live socket; restart the owning service/app. A reply + that takes up to about 30 seconds is the known latency in section 1 + (relay-desktop#333), not a fault: allow 60 seconds and retry once. +- **`not_a_relay_session`:** the command is not a descendant of a supported + live Codex/Claude session, or the session id/process start no longer matches. + Run it through this agent's shell tool. In tmux, confirm the agent process and + shell share the pane's process tree. +- **Sign-in blocked by a legacy uploader** (`sign_in: error`, message "An older + managed history uploader is active. Stop it explicitly before enabling this + probe."): the probe counts any of these legacy `ai-hist push` registrations as + active, even when idle: the file + `~/Library/LaunchAgents/com.ai-hist.push.plist` (even if unloaded), a loaded + launchd label `com.ai-hist.push`, or a crontab line containing + `# ai-hist push (managed)`. Check each, then with the human's approval unload + and move aside (do not delete) what exists. Create the backup directory first, + use a unique no-clobber destination, and confirm the move before retrying: + + ```sh + # bootout exits non-zero when the label was never loaded; that is fine, so + # judge success by whether the label is still loaded, not by its exit code. + launchctl bootout "gui/$(id -u)/com.ai-hist.push" 2>/dev/null || true + if launchctl list | awk '{print $NF}' | grep -qx com.ai-hist.push; then + echo 'com.ai-hist.push is still loaded; do not retry sign-in' >&2 + exit 1 + fi + plist="$HOME/Library/LaunchAgents/com.ai-hist.push.plist" + if test -e "$plist"; then + backup_dir="$HOME/.agentworkforce/backup" + mkdir -p "$backup_dir" + mv -n "$plist" "$backup_dir/com.ai-hist.push.plist.bak-$(date +%Y%m%d%H%M%S)" + test ! -e "$plist" || { echo 'plist was not moved; do not retry sign-in' >&2; exit 1; } + fi + ``` + + If instead the marked cron line is the cause, back up the full crontab first + and remove only that line, so the change can be reversed with + `crontab `: + + ```sh + mkdir -p "$HOME/.agentworkforce/backup" + cron_backup="$HOME/.agentworkforce/backup/crontab.bak-$(date +%Y%m%d%H%M%S)" + crontab -l > "$cron_backup" && test -s "$cron_backup" || { echo 'crontab backup failed; do not edit it' >&2; exit 1; } + grep -vF '# ai-hist push (managed)' "$cron_backup" | crontab - + ! crontab -l | grep -qF '# ai-hist push (managed)' || { echo 'cron line still present; do not retry sign-in' >&2; exit 1; } + ``` + + Leave `com.ai-hist.sync` alone; it is not checked. Then re-run + `/setup/sign-in` with the same payload. +- **`not_signed_in`:** run `/setup/sign-in`; do not paste tokens into the + request. Restart after `expired` or `denied` to obtain a new code. +- **Older schedules could not be inspected:** inspect the user's legacy cron, + launchd, or user-service upload schedules first. Only after that inspection, + retry `/setup/sign-in` with the original payload plus + `"acknowledge_uninspected_schedules":true`. This flag records a completed + safety check; never send it merely to silence an error. +- **`not_allowed` from `/register`:** enable the app's self-registration + setting. Do not bypass it with workspace credentials. +- **Managed Claude policy:** if direct delivery reports `managed_policy`, the + organization controls `crossSessionInbound`; report the policy block rather + than modifying managed settings. +- **Undo registration:** `curl -sS --max-time 60 --unix-socket "$relay_socket" -X DELETE http://relay/register | jq`. +- **Undo an integration:** with section 6's `relay_probe`, `relay_socket`, + `relay_req`, and `pr_resources` defined, remove every subscription the + session holds for the pull request, in whichever form it was made. On macOS + `relay_req` uses the probe, and a Codex session without one stops instead of + sending a `curl` request that cannot be identified: + + ```sh + held=$(pr_resources) || exit 1 + printf '%s\n' "$held" | while IFS= read -r resource; do + test -n "$resource" || continue + relay_req DELETE /integrations/subscribe \ + "$(jq -nc --arg resource "$resource" '{provider:"github",resource:$resource}')" | jq + done + relay_req GET /setup/status | jq -c '.data.integrations' + ``` + + Require the final list to hold none of that pull request's resources. +- **Undo a webhook:** `curl -sS --max-time 60 --unix-socket "$relay_socket" -X DELETE http://relay/webhooks | jq`. +- **Undo direct delivery:** POST `{"enabled":false}` to + `/setup/direct-delivery`. On Claude this restores the local opt-out; managed + organization policy still wins. +- **Undo sharing:** POST the human's prior mode (`selected`, `new`, or `all`) to + `/setup/sharing`. Ask before changing it when the earlier value is unknown. +- **Auto-activate accidentally enabled:** POST `{"enabled":false}` to + `/setup/auto-activate`. This does not remove existing registrations. +- **Uninstall:** stop/disable the user service before removing the Linux + package, or quit the Mac app before removing it. Do not delete app data or + revoke webhooks unless the human explicitly asks for data removal. diff --git a/.claude/skills/setting-up-agent-relay-flows/SKILL.md b/.claude/skills/setting-up-agent-relay-flows/SKILL.md new file mode 100644 index 00000000..de28d222 --- /dev/null +++ b/.claude/skills/setting-up-agent-relay-flows/SKILL.md @@ -0,0 +1,232 @@ +--- +name: setting-up-agent-relay-flows +description: Set up and activate an Agent Relay Flow end-to-end from a running agent session through the Cloud APIs, with no dashboard clicks - choose a catalog flow and repository, connect the GitHub (or other trigger) tools and any coding-agent credentials, activate the listener through the direct-source deploy API, and verify it is listening. Use when a human asks an agent to set up Flows, activate a recommended flow such as Software Garden on a repository, or finish Flows onboarding. +--- + +# Set Up Agent Relay Flows + +Drive the setup through the documented Cloud APIs and the official CLI. Do not +use computer use, browser automation, or the dashboard UI. The human approves +sign-in, tool consent, and provider logins on their own approval pages; open +those links with the OS URL opener (or give them the link), then verify by +polling the API, never by inspecting the browser. + +Paths below are relative to the Cloud API base, `https://agentrelay.com/cloud`, +including its `/cloud` prefix; the site is `https://agentrelay.com`. The flow +catalog is the one exception: it is a site API, served from the site origin +without `/cloud` (`https://agentrelay.com/api/v1/flows/catalog`). When a +guide names a different site and Cloud base (for example a local development +stack), use those exact origins throughout, including for the catalog, the +CLI's `--api-url` and the dashboard links, and never fall back to production. +Flows can be configured from any machine with HTTPS and Node.js 22+ for the CLI. + +## Requires + +A signed-in Cloud session: an access token and the `user.id` and +`currentWorkspace.id` from whoami. Get them with `signing-in-to-agent-relay-cloud` +first. Send `Authorization: Bearer ` only to the Cloud API +endpoints below. Never send it to the site's flow catalog or to a catalog +`source.rawUrl`; fetch those without credentials. + +## API map + +- Flow catalog (site origin, not under `/cloud`): + `GET https://agentrelay.com/api/v1/flows/catalog` and `/`. +- Tool consent links: `POST /api/v1/integrations/connect-link`; poll + `GET /api/v1/workspaces//integrations//status`. +- Coding-agent credentials: the official CLI's `cloud connect` (section 3); + `GET /api/v1/cloud-agents` inspects existing connections. +- Activation: `POST /api/v1/flows/deploy` with the body in section 4. +- Custom-flow prompt generator (optional): `GET /api/v1/flows/prompt/generate` + reports whether it is available. +- Verification: `GET /api/v1/flows/listeners/`. + +## 1. Prebuilt or custom? + +Ask the human first, unless their request already says: a **prebuilt** flow +from the Agent Relay catalogue (https://agentrelay.com/flows), or a **custom** +flow of their own? In an agent-driven signup, ask through that guide's +web-input protocol on the signup page, not in chat. + +- Prebuilt: continue with section 2. +- Custom: follow **Custom flow: deploy journey** below. A custom flow is + activated by the same `POST /api/v1/flows/deploy` API as a prebuilt one and + as the deploy builder at https://agentrelay.com/cloud/flows/deploy; there is + no separate custom API. + +## 2. Choose the flow and repository + +Ask the human for missing product choices: repository, desired +workflow/trigger, and approver. In an agent-driven signup, ask through that +guide's web-input protocol on the signup page, not in chat. Ask for the approver's +GitHub username in plain language (for example, "octocat" or "@octocat"), +not an internal provider-address format or their Google email. Normalize the +answer to github:@handle when constructing the deploy API request. Human-gate +replies are matched to that provider identity. Infer choices from the user's request and current +repository where clear. +Do not invent a repository or enable automation on an unrelated project. + +GET https://agentrelay.com/api/v1/flows/catalog and select a matching entry from flows. +GET https://agentrelay.com/api/v1/flows/catalog/ for its full contract. Use the catalog's +kind before doing anything executable: only an entry with kind: flow (or a +legacy entry with kind omitted) is deployable through the direct-source API +below. An entry with kind: extension is discoverable metadata, not a standalone +listener. Do not download or deploy its source, even when it declares trigger +and input fields. Inspect baseFlowId and extension.activation instead. If the +extension is blocked, report the unmet dependencies and that it can only be +added to its base flow after a supported extension activation path is available; +never substitute a standalone flow deployment. + +For a deployable flow, use the catalog's +supportedRepositoryHosts, defaultTrigger, inputs.required, inputs.defaults, and +inputs.allowedAgents. Name a model per harness in inputs.models when the house +default is wrong (for example {"claude": "claude-sonnet-4"}); omit it to fund +the house default for each declared agent. Download source.rawUrl, verify its bytes against +source.sha256, and use that source text unchanged for a recommended flow. +The source is TypeScript, not the source URL. Do not guess a template or hash. + +## Custom flow: deploy journey + +There is no journey API. A `journey_id` (for example in a +`next=/flows/deploy?journey_id=...` link) is only an analytics tag: pass it +through unchanged where you received it, and never call an endpoint with it or +wait on it. A custom flow is activated by the same `POST /api/v1/flows/deploy` +as a prebuilt one; only where the source comes from differs. + +1. Author the flow with `writing-relayflows` (the Relayflows v2 engine, + `@relayflows/surface` / `@relayflows/sdk`, CLI `flows`). Defer to that + skill for the flow's shape and checks; Flows are always v2. Cloud's + prompt generator is an optional shortcut: use it only when + `GET /api/v1/flows/prompt/generate` returns `available: true`. If it + reports unavailable, refuses your session (403 `session_required`), or + fails, author with `writing-relayflows` instead; do not retry into it. +2. Choose its repository, trigger and approver as in section 2. +3. Continue with sections 3 to 5 unchanged. In section 4, `source` is the + custom TypeScript text, `workflow` is its label, and `promptSpec` is sent + only when the prompt generator produced one. `base` and `extensions` are + optional and only for a flow that names them. + +The human-only steps are the same for both paths: Google sign-in and device +approval, GitHub and trigger-app OAuth including the repository grants, +model-provider login when section 3 calls for one (not for an ordinary first +activation), and confirming the repository and trigger before activation. Do every other step yourself through the APIs. + +## 3. Connect the required tools and coding agents + +Use bearer-authenticated POST /api/v1/integrations/connect-link: + +```json +{"provider":"github","workspaceId":""} +``` + +Open the returned connectUrl for the user to approve. Keep token/sessionToken +private. Connect only the repository and tools the chosen flow requires. +For GitHub the user must grant repository access. Repeat with the chosen trigger +provider if different. Reuse existing ready connections rather than relinking. +Check GET /api/v1/workspaces//integrations//status +until oauth.connected is true (poll with backoff and a bounded timeout); a +returned connect link or a closed popup alone does not prove the connection. +For the GitHub-triggered Software Garden flow, activation separately checks +that the connected GitHub App covers the selected repository. Its trigger +does not require background data indexing, so do not block solely because +the broader status.ready is false from queued syncs. +If a different flow declares Relayfile data that requires synced records, +verify that readiness separately before activating it. + +Do not connect a Claude or Codex subscription yet. The first three runs use +Cloud's own model key, so no provider login is needed to activate. If the chosen +flow declares more than one coding agent (the catalog's `inputs.defaults.agents`, +which becomes `inputs.agents` in the deploy body), included Cloud runs +can fund only one of them per run — connect your own subscription for at least +one declared agent before activating a multi-agent flow. After the included +runs, activation and launches will ask for your own subscription; only then use +the official Relay CLI with the private credential environment from +`signing-in-to-agent-relay-cloud` (its section 5) and a PTY: + +This promotion depends on Cloud's internal house-key proxy and account +enrollment. If local activation returns flow_credentials_unavailable or +flow_model_not_connected before the three promotional runs, treat it as an +internal configuration or eligibility issue. Do not ask the user to connect +Claude/Codex, provide an API key, or choose an inactive draft as a workaround. +Report the blocker (in a signup, through its Progress API) and have an internal developer +verify the proxy, promotion flag, provider readiness, and enrollment. Never +copy a house key into the agent environment or expose it in this guide. + +```sh +npx --yes agent-relay@13 cloud connect anthropic --api-url 'https://agentrelay.com/cloud' +``` + +Use anthropic for Claude or openai for Codex, according to the selected flow. +The command drives provider login; open its authorization URL for the user, +and keep the process alive until it confirms the credential is connected. +Google approval does not grant GitHub or model-provider access: those services +may require their own consent. Never fabricate credentials or claim consent +happened. GET /api/v1/cloud-agents lets you inspect the account's credential +state without reconnecting. + +## 4. Activate through the same API as web onboarding + +POST /api/v1/flows/deploy with Content-Type: application/json and the bearer +session. The token must be the device-flow session from +`signing-in-to-agent-relay-cloud` (scope `cli:auth`) or a token with +`flows:listeners:write`, and `workspaceId` must be that token's workspace. This is the direct-source listener API used by flows deploy, not the +browser onboarding handoff: source is TypeScript text, repository is singular, +and sources contains provider/settings objects. The catalog supplies the source +reference and defaults; it is not itself a deploy request. The current endpoint +does not accept a flowId/repositories-only catalog activation request or fetch +the source for you. For multiple repositories, submit one deployment per +repository with a distinct name and handoffId. + +For the catalog's Software Garden entry (id `software-factory`), construct this body, substituting the +workspace, verified source, repository, GitHub approver and a new UUID: + +```json +{ + "workspaceId": "", + "name": "Software Garden", + "workflow": "software-factory", + "source": "", + "handoffId": "", + "inputs": {"approver": "github:@octocat", "agents": ["claude"]}, + "mode": "activate", + "repository": {"owner": "acme", "name": "api"}, + "sources": [{"provider": "github", "settings": {"repository": "acme/api"}}] +} +``` + +For another deployable kind: flow catalog entry use its id as workflow, allowed agents, and +defaultTrigger for sources, then apply the user's trigger settings. Scope a +GitHub issue trigger with settings.repository set to the chosen owner/name; +for GitLab use settings.project. Cloud does not derive this filter from the +deployment repository. An empty filter can trigger on other repositories in +the workspace. Only use a different trigger scope when explicitly requested. +Give each repository its own trigger filter for multi-repository setup. Do not send +the example acme repository or octocat approver unchanged. The workflow field +is a label, not a source lookup; keep the verified source in the request. +For GitLab set repository.host to gitlab and use the namespace path +as owner. Reuse the handoffId on retry. Before retrying an ambiguous network +failure, GET /api/v1/flows/listeners and check whether the flow already exists; +do not create a new ID/name on every retry. Activation subscribes to matching +future events and can run work; confirm the intended repository and trigger +with the user if they have not specified them. + +HTTP 201 must contain agentId and status: listening. A draft is not completion. +For a 409 workspace_mismatch, verify the active workspace; for connection +preflight failures, fix the indicated connection before retrying. If the user +wants to save incomplete work, use mode: draft explicitly and report that it +is inactive. Never mask activation failures by silently falling back to draft. + +## 5. Verify + +GET /api/v1/flows/listeners/. Require listener.status: listening and +verify its repository and sources match the request. Open +https://agentrelay.com/cloud/dashboard/workflows/listeners/ for the user. Report the flow name, +workspace, repository, trigger, and verified listening state. This proves +activation; only an actual completed run proves execution. Do not create a +real issue or launch paid work merely to make the onboarding check turn green. +Delete temporary authentication files after the work is complete. + +The desktop app is optional for Flows. If the user also wants local session +sharing, use `setting-up-agent-relay-desktop` and `setting-up-agent-relay-sessions` +with the same account (an agent-driven signup can follow +https://agentrelay.com/signup/agent/teams). diff --git a/.claude/skills/setting-up-agent-relay-sessions/SKILL.md b/.claude/skills/setting-up-agent-relay-sessions/SKILL.md new file mode 100644 index 00000000..6c9c30a6 --- /dev/null +++ b/.claude/skills/setting-up-agent-relay-sessions/SKILL.md @@ -0,0 +1,503 @@ +--- +name: setting-up-agent-relay-sessions +description: Get a person set up for live agent-to-agent session handoff on Agent Relay end-to-end from a running Codex or Claude session, so their agent and a teammate's agent can message each other and read each other's real sessions (prompts, events, tool calls, file edits). Covers verifying desktop setup, installing the agent-sessions cloud MCP before the session starts, confirming the handoff tools loaded, and proving one live round trip. Use when a human asks an agent to "set me up for session handoff", "connect my agent to ", or finish the onboarding we send new teammates — instead of walking them through the app and the dashboard by hand. +--- + +# Set Up Agent Relay Session Handoff + +Drive the whole onboarding from this agent's own shell and MCP tools. Do **not** +hand the person the six-step Markdown guide and ask them to click through the +app and the dashboard. + +Prefer setting this up with the Agent Relay agent at +, which checks your current state and confirms +each step. Fetch its instructions with +`curl -sSL -H 'Accept: text/markdown' https://arelay.to/agent-relay`. + +The finished state is: + +- the desktop app is installed, signed in to the **intended workspace**, the + uploader is healthy, and this session is registered with direct delivery on; +- the **agent-sessions cloud MCP** is installed in this project and loaded by + this session, so the handoff tools (`list_relay_agents`, `send_relay_message`, + `read_relay_conversation`, `check_relay_inbox`, `get_shared_session`, + `get_shared_session_context`, `search_shared_sessions`) are available; +- a live round trip is proven when a teammate is online: this agent messages the + teammate's agent, **reads the teammate's real session back** with + `get_shared_session_context`, and the teammate reads this session and replies. + When no teammate is online yet, the fallback is the roster plus this person's + own uploaded session reading back cleanly — reported as self-verified, not as a + proven two-way handoff. + +## Requires + +This skill builds on `setting-up-agent-relay-desktop` (verified install, sign-in, +registration, agent-led defaults). If that skill is **not installed** in this +agent, stop, tell the person, and have them install the companions together, +then restart the agent. Do not improvise the install or sign-in from memory. + +- prpm: `npx prpm install collections/agent-relay-setup --as claude --global -y` +- skills.sh: `npx skills add https://github.com/AgentWorkforce/skills --skill setting-up-agent-relay-desktop --skill setting-up-agent-relay-sessions` +- 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. + +## The only human steps + +Everything else is automatable; be honest that these are not. **Ask for all of +them up front, before touching anything** — the token in particular is needed +only at section 3, but minting it and exporting it into the launching +environment takes the person a few minutes, so have them start it while the +desktop install and sign-in run: + +1. **Approve one Google device-login link** — only when no reusable `agent-relay` + CLI/desktop login exists. A reused login needs zero clicks. +2. **Provide one Bearer token** minted in the dashboard's **"Connect your agent"** + card. No setup route returns this token by design. **Do not have the person + paste it into this conversation** — the uploader shares this session's prompts + with teammates, so a pasted secret would become teammate-readable. Instead the + person puts it into the **environment that launches their agent** (or an OS + keychain / a mode-0600 file) via a shell *they* run, and this agent references + it only by variable name — never by value. See **Token handling** below. +3. **Same workspace, both sides.** The teammate (or workspace admin) must have + invited this person's email to the **shared** workspace — two agents can only + reach each other inside one workspace. Confirm the workspace id matches on + both machines; do not guess it. + +One structural note so expectations are right: there is **no automatic ack** to +the sender when a handoff is picked up, and **no partial session sharing** — a +session is on-or-off on the relay. Confirm a handoff by activity, not a push. + +## How this composes with `setting-up-agent-relay-desktop` + +This skill is a thin layer on a working desktop. The desktop skill owns install, +sign-in, defaults and self-registration; this one owns only the agent-sessions +cloud MCP and the live handoff. Section 2 checks the desktop prerequisite and, +if it is not met, invokes `/setting-up-agent-relay-desktop` to completion and +returns here. Keep the `/setup/*` details in that one skill so a contract change +updates it once. + +## Install this skill (and its dependency) for Codex and Claude Code + +When the task includes installing this skill, install its canonical Claude +package **and** the desktop skill it depends on, then mirror both unchanged +`SKILL.md` files where Codex loads global skills: + +```sh +for pkg in setting-up-agent-relay-sessions setting-up-agent-relay-desktop; do + npx prpm install "@agent-relay/$pkg" --as claude --global -y + codex_skill="$HOME/.codex/skills/$pkg" + mkdir -p "$codex_skill" + install -m 0644 "$HOME/.claude/skills/$pkg/SKILL.md" "$codex_skill/SKILL.md" + cmp "$HOME/.claude/skills/$pkg/SKILL.md" "$codex_skill/SKILL.md" +done +``` + +With 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. + +Do not use prpm's `--as codex` conversion for these packages: it writes under +`~/.agents/skills`, which Codex does not load as its global skill folder, and it +drops explanatory paragraphs from the Markdown workflow. Confirm both final +paths explicitly before continuing. + +## Why ordering matters + +MCPs load at agent **startup**. The agent-sessions MCP must be installed +**before** the session that will use it starts. A session already running when +the MCP was added has no handoff tools and must be **restarted**. Install the +MCP (section 3) before relying on the tools (sections 4–5); if this very session +predates the MCP, say so and restart it rather than reporting missing tools as a +failure. + +## Token handling + +The token must never land in the agent conversation, in a Relay message, in +process argv, or in the repo. Three places leak it, and this skill avoids all +three: + +- **The chat** — this session is uploaded and teammate-readable, so a token + pasted to the agent leaks to the workspace. The person sets it themselves (see + below); the agent only ever sees the variable **name**. +- **Process argv** — a token interpolated into `claude mcp add --header + "... Bearer $TOKEN"` is visible to `ps` and gets written literally into + `~/.claude.json`. Pass the unexpanded placeholder `${AGENT_RELAY_SESSIONS_TOKEN}` + instead; Claude Code expands `${VAR}` in MCP headers from the environment at + launch, so the stored config holds only a reference and argv holds only the + literal `${…}`. **This expansion exists only in Claude Code ≥ 2.1.119** — an + older client sends the placeholder literally and auth fails, so check the + version first (section 3) and update Claude rather than inlining the token. +- **The launching environment** — both CLIs read the token from an environment + variable (`${VAR}` for Claude, `--bearer-token-env-var` for Codex), so the + variable must exist in the environment that **launches the agent**, not in a + transient shell-tool subprocess (which cannot alter its parent or a later + process). + +Use the single variable name `AGENT_RELAY_SESSIONS_TOKEN` throughout. Keep the +one-time entry separate from durable supply: + +- **One-time, into the current shell** (non-echoing, run by the person, never + the agent): `read -rs AGENT_RELAY_SESSIONS_TOKEN && export AGENT_RELAY_SESSIONS_TOKEN`, + then launch the agent from *that* shell. Do **not** add this `read` to a shell + profile — every shell that sources the profile would block waiting for input. +- **Durable across restarts:** store the token in the OS keychain or a mode-0600 + file, and have the launcher export it **non-interactively**, e.g. + `export AGENT_RELAY_SESSIONS_TOKEN="$(security find-generic-password -s agent-relay-sessions -w)"` + (macOS keychain) or from a 0600 file. Put that line in a dedicated launcher the + person starts the agent from — not an interactive prompt in the profile. + +The agent never prints the value; verify presence only with +`test -n "${AGENT_RELAY_SESSIONS_TOKEN:-}"`. + +## 0. Preflight: collect the human inputs first + +Before section 1, tell the person what they need and ask for it in one message: + +1. the **shared workspace id** (or the teammate/admin who invited them); +2. a **Bearer token** minted in the dashboard's "Connect your agent" card for + that workspace, stored by *them* as `AGENT_RELAY_SESSIONS_TOKEN` in the + environment that launches their agent (see **Token handling** — never pasted + into this chat); +3. the **teammate's relay address** for the round trip, if one will be online. + +Before sending that message, check what this machine already has. If an Agent +Relay CLI 13.x is installed (a user-level npm, mise or nvm `agent-relay`, not +the `.deb`'s `/usr/bin/agent-relay` Desktop launcher), run these read-only +checks. Without one, section 2 reads the signed-in workspace from +`/setup/status` instead. The CLI is optional for this skill: detect it as +below, which skips the Desktop launcher that `command -v agent-relay` finds on +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 +if test -n "$relay_cli"; then + "$relay_cli" --version + "$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 + "$relay_cli" status # workspace, cloud login and local broker +fi +``` + +If the active workspace is already the shared one, confirm it with the person +instead of asking them to look it up. Never switch to or create another +workspace unless they explicitly ask. + +Check presence without printing the value: + +```sh +test -n "${AGENT_RELAY_SESSIONS_TOKEN:-}" && echo token=present || echo token=missing +``` + +A missing token does **not** block sections 1–3: the MCP config stores only a +`${AGENT_RELAY_SESSIONS_TOKEN}` reference, never the value, so it can be added +now. Do the desktop work and add the MCP, then have the person restart the agent +**once**, from an environment that exports the token, so that single restart both +loads the MCP and supplies the credential. Sections 4–5 are gated on that +restart. If the person would rather not defer, they can export the token and +restart first, which then costs a second restart only if the MCP was added after. + +## 1. Confirm the host and this session + +Run every command from this agent's own shell tool, and only from a **genuine +interactive** Claude Code or Codex session. The app identifies the caller from +kernel credentials and resolves its process ancestry against discovered live +sessions, so a command from an unrelated terminal — or from a non-interactive +`codex exec` / `claude -p` run — is rejected with `not_a_relay_session`. (This +is verified: a plain SSH shell and a `codex exec` invocation are both refused; +an interactive session is admitted.) + +```sh +uname -s; uname -m +test -n "${SSH_CONNECTION:-}" && printf 'transport=ssh\n' || true +test -n "${TMUX:-}" && printf 'session=tmux\n' || true +``` + +Define the request helper once per shell. It finds the private setup socket +without printing private files and uses the desktop's `agent-relay-probe` on +macOS (the system `curl` hides a Codex session's identity there and is refused +`not_a_relay_session`), `curl` elsewhere, and stops with a message for a macOS +Codex session that has no probe: + +```sh +pointer="$HOME/.agentworkforce/desktop/relay-socket" +if test -r "$pointer"; then relay_socket=$(sed -n '1p' "$pointer"); fi +if test -n "${relay_socket:-}" && ! test -S "$relay_socket"; then unset relay_socket; fi +if test -z "${relay_socket:-}" && test "$(uname -s)" = Linux; then + for c in "${XDG_RUNTIME_DIR:-/nonexistent}/agent-relay/relay.sock" "/run/user/$(id -u)/agent-relay/relay.sock"; do + if test -S "$c"; then relay_socket=$c; break; fi + done +fi +if test -z "${relay_socket:-}" && test "$(uname -s)" = Darwin; then # pointer file missing: use the app's own socket + c="$HOME/Library/Application Support/com.agentrelay.desktop/run/relay.sock" + if test -S "$c"; then relay_socket=$c; fi +fi +relay_probe= +for c in "$HOME/.local/bin/agent-relay-probe" \ + /usr/lib/agent-relay/agent_relay/helpers/agent-relay-probe \ + "/Applications/Agent Relay.app/Contents/Helpers/agent-relay-probe"; do + if test -x "$c"; then relay_probe=$c; break; fi +done +if test -z "$relay_probe" && test -d "$HOME/.local/lib/agent-relay"; then # per-user tarball + relay_probe=$(find "$HOME/.local/lib/agent-relay" -type f -perm -u+x \ + -path '*/agent_relay/helpers/agent-relay-probe' -print -quit 2>/dev/null) +fi +# relay_req METHOD PATH [JSON-BODY] +relay_req() { + if ! test -S "${relay_socket:-/nonexistent}"; then + echo 'No Agent Relay socket found; start Agent Relay (the desktop) and retry.' >&2 + return 1 + fi + if test "$(uname -s)" = Darwin && test -n "$relay_probe"; then + printf '%s' "${3:-}" | "$relay_probe" relay socket-request \ + --socket "$relay_socket" --method "$1" --path "$2" + elif test "$(uname -s)" = Darwin && test -n "${CODEX_THREAD_ID:-}"; then + echo 'A Codex session on macOS needs agent-relay-probe; start Agent Relay and retry.' >&2 + return 1 + elif test -n "${3:-}"; then + # body on stdin: curl treats a -d/--data-binary value starting with @ as a filename + printf '%s' "$3" | curl -sS --max-time 60 --unix-socket "$relay_socket" -X "$1" \ + -H 'Content-Type: application/json' --data-binary @- "http://relay$2" + else + curl -sS --max-time 60 --unix-socket "$relay_socket" -X "$1" "http://relay$2" + fi +} +``` + +**Slow socket replies are known.** `GET /setup/status` and `GET /agents` can +take about 30 seconds on a busy Desktop (tracked in +AgentWorkforce/relay-desktop#333). Allow 60 seconds per call (add +`--max-time 60` to the `curl` lines above), and do not treat a slow reply as a +failure. The probe's `relay socket-request` can give up sooner with "Probe +could not finish. Check your connection and run setup again"; on these paths +that is the same latency, not a setup problem. Retry once before reporting a +blocker, and do not rerun desktop setup because of it. + +## 2. Make sure the desktop is set up first + +Session handoff needs a signed-in, healthy desktop underneath it. If the socket +is missing, or `/setup/status` returns `404`, or sign-in/uploader/registration +are not in the finished state below, **invoke the `setting-up-agent-relay-desktop` +skill** (`/setting-up-agent-relay-desktop`; if it is not installed, see **Requires**), let it run to completion, and then +return to section 2's verification. Do not re-implement its install, sign-in, or +`/setup/*` steps here — that skill is the single source of truth for them, and +this skill resumes once it reports a registered, signed-in session. + +Verify the desktop state this skill depends on: + +```sh +relay_req GET /setup/status | jq '{ + version: .data.version, + sign_in: .data.sign_in, + workspace: .data.workspace, + sharing_mode: .data.sharing_mode, + uploader: .data.uploader, + direct_delivery: .data.direct_delivery, + session: .data.session, + defaults_error: .data.defaults_error +}' +``` + +Require all of these before continuing, and stop with a clear message if any +fails. `/setup/status` reports sign-in as the string `sign_in` (match the desktop +skill), and the flag that actually governs whether **this** session's handoff +replies arrive live is the session-level `session.direct_delivery`, not the +top-level default — require both: + +```sh +relay_req GET /setup/status | jq -e ' + .ok + and .data.sign_in == "signed_in" + and (.data.workspace.id | type == "string" and length > 0) + and .data.uploader.healthy == true + and .data.direct_delivery == true + and (.data.session.id | type == "string" and length > 0) + and .data.session.registered == true + and .data.session.direct_delivery == true' +``` + +Confirm `workspace.id` is the **shared** workspace both sides agreed on. A +healthy uploader is the one non-obvious prerequisite: for a teammate's agent to +read **this** person's session, this machine must have uploaded it. If +`uploader.healthy` is false (paused, or a failed cycle), fix it before promising +anyone can read this session — this is the single most common reason a live +handoff silently fails. + +`sharing_mode` decides which sessions upload at all, and a healthy uploader +does not prove that **this** session is among them. `all` includes it. `new` +includes sessions that started after `new` was enabled, so a session that was +already running when the desktop was set up may not be uploaded. Under +`selected`, only the sessions the person chose upload. Unless the mode is +`all`, tell the person that this session may not be shared yet, and ask them to +select it in the app or to continue from a new session; do not change the +sharing mode or select sessions on their behalf. Section 5's round trip is the +proof: if the teammate cannot read this session there, this is the first +thing to check. + +## 3. Install the agent-sessions cloud MCP (before the session uses it) + +Check whether the token is present in the environment **without printing it** +(see **Token handling**). It is not required to *add* the MCP, since only a +reference is stored, but it is required for the tools to authenticate after the +restart. If it is missing, add the MCP anyway, tell the person plainly that the +tools will fail auth until they restart the agent from an environment that +exports `AGENT_RELAY_SESSIONS_TOKEN`, and do not report setup complete: + +```sh +if ! test -n "${AGENT_RELAY_SESSIONS_TOKEN:-}"; then + 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 +fi +``` + +Detect which agent CLI this session runs and install accordingly. + +Claude Code — pass the **unexpanded** placeholder so the token stays out of argv +and out of `~/.claude.json`; Claude expands `${VAR}` in MCP headers from the +environment at launch. This expansion requires **Claude Code ≥ 2.1.119**, so +verify the version first and update rather than inlining the token on an older +client. Single-quote the header so the shell does not expand it here. Default +scope is `local` (this project only), which is what you want: + +```sh +claude_ver=$(claude --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +if test -z "$claude_ver" || test "$(printf '%s\n2.1.119\n' "$claude_ver" | sort -V | head -1)" != 2.1.119; then + echo "Claude Code $claude_ver does not expand \${VAR} in MCP headers (need >= 2.1.119); update Claude Code before adding this server." >&2 + exit 1 +fi +claude mcp add --transport http agent-relay-sessions \ + https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \ + --header 'Authorization: Bearer ${AGENT_RELAY_SESSIONS_TOKEN}' +claude mcp list | grep -F agent-relay-sessions +``` + +Codex — `codex mcp add` records the **variable name**, not the value, and writes +to user-level `~/.codex/config.toml`, so the server is enabled for **all** Codex +projects (Codex has no project-scoped MCP). Confirm the person is OK with +user-wide access before adding it: + +```sh +codex mcp add agent-relay-sessions \ + --url https://agentrelay.com/cloud/api/v1/mcp/shared-sessions \ + --bearer-token-env-var AGENT_RELAY_SESSIONS_TOKEN +codex mcp list | grep -F agent-relay-sessions +``` + +Because MCPs load at startup, the running session must be **restarted** to pick +up the server — and because both CLIs read the token from the environment at +launch, the restarted process must **inherit** `AGENT_RELAY_SESSIONS_TOKEN`. +That is why the variable belongs in the shell profile / launcher, not a transient +shell-tool subprocess: an `export` in this agent's shell tool cannot reach the +parent or a freshly launched session. After the restart, re-verify presence with +`test -n "${AGENT_RELAY_SESSIONS_TOKEN:-}"` before using the tools, and do not +report the tools as missing without first confirming the session started *after* +the MCP was added and with the variable set. + +## 4. Confirm the handoff tools loaded + +In the session that started after the MCP was added, confirm the tools are +present before using them. List this session's MCP tools and check for the +agent-sessions set; the authoritative check is that the tools actually appear to +this agent: + +```sh +# Claude Code: the tools surface as mcp__agent-relay-sessions__* +claude mcp list | grep -F agent-relay-sessions +``` + +Then call `list_relay_agents` through the MCP (an MCP tool call, not a shell +command). A successful call that returns the workspace roster proves the MCP is +loaded, the token is valid, and this session can see the shared workspace. If +the call errors with auth, the token is wrong or for another workspace — have +the person re-mint it from the correct workspace's dashboard. + +## 5. Prove one live round trip + +The real proof is agent-to-agent, so it needs a counterpart. Pick the path that +matches what is available: + +**A teammate's agent is online (full round trip).** Ask the person for the +teammate's relay address (e.g. `@manav`), then: + +1. `list_relay_agents` — confirm the teammate's address is listed. +2. **Prove the reverse read path first:** call `get_shared_session_context` on + the teammate's session and confirm it returns their **real** content (prompts/ + events + a dashboard citation link). If it errors or is empty, their uploader + is unhealthy — stop and have them fix it; do not declare GREEN, because this + side cannot actually read their work yet. +3. `send_relay_message` — send a short, uniquely-marked message to that address. +4. The teammate's agent reads this session with `get_shared_session_context` and + continues; have them reply through the relay. +5. GREEN requires **both directions**: this agent read the teammate's real + content (step 2) **and** the teammate's reply lands here. There is no + automatic ack — confirm by the reply/activity, not a push. Because Relay + injects an inbound message only when the session is idle between turns, **end + the turn** after sending and confirm the reply on the next turn; do not sleep + or poll inside one turn. + +**No teammate online yet (self-verify the read path).** Prove the machinery +without a second person: + +1. `list_relay_agents` returns the roster — MCP + token + workspace are good. +2. `search_shared_sessions` / `get_shared_session_context` return this person's + **own** recently uploaded session (real prompts/events plus a dashboard + citation link) — the uploader→read path works. +3. Record that the two-party round trip still needs the teammate online, and + name it as the remaining step rather than claiming the handoff is proven. + Setup is not complete until the full round trip succeeds. + +Never paste the Bearer token or any session token into a `send_relay_message` +body or any artifact. + +## 6. Report + +Separate verified facts from what still needs a human or a second machine: + +- app version, `signed_in`, the signed-in **workspace id and name**; +- uploader `healthy`, this session's `agent@machine` address, direct delivery on; +- agent-sessions MCP installed and its tools confirmed loaded in this session; +- `list_relay_agents` roster seen; and either the full round-trip evidence (the + teammate's real content + reply) or the self-verified read path plus the named + remaining step (teammate online). + +Do not call the setup complete on a 2xx from `claude mcp add` alone, and do not +count a roster listing as a proven handoff. + +## Recovery + +- **`not_a_relay_session` from `/setup/*`:** the caller is not inside a discovered + interactive session. Run it from this agent's own shell inside an **interactive** + Claude Code or Codex session — a non-interactive `codex exec` or `claude -p` run + is not admitted, and neither is an unrelated terminal. In tmux/SSH, confirm the + agent process and shell share the pane's process tree. +- **No handoff tools in the session:** the MCP was added after the session + started, or at the wrong scope. Reinstall at project scope (section 3) and + **restart** the session; MCPs load only at startup. +- **`list_relay_agents` auth error:** the token is wrong, expired, or from a + different workspace. Because the MCP config stores only a **reference** to + `AGENT_RELAY_SESSIONS_TOKEN`, fixing it means updating the **value in the + launch environment** (keychain / 0600 file) and **restarting** the agent so it + re-reads it — re-running `claude mcp add` / `codex mcp add` alone changes + nothing. Re-mint from the intended workspace's "Connect your agent" card if the + token itself is bad. On Claude, also confirm the client is ≥ 2.1.119 (older + clients send the `${VAR}` placeholder literally). +- **Teammate's address missing from the roster:** they are not on the **same** + workspace, or their session is not on the relay. Have them register their + session and confirm the shared workspace id. +- **Can't read the teammate's session:** their **uploader** is not healthy + (paused or a failed cycle). Have them check `/setup/status` shows + `uploader.healthy == true`, not "Sync paused/offline". +- **Message arrives held, not live:** direct delivery is off for the recipient — + POST `{"enabled":true}` to `/setup/direct-delivery` on their machine (Codex + delivers directly by default). A managed Claude policy can block this; report + it rather than editing managed settings. +- **Remove the MCP:** `claude mcp remove agent-relay-sessions` / + `codex mcp remove agent-relay-sessions`. This removes only the handoff tools; + it does not sign the desktop out or stop uploads. + +## Relationship to the other skills + +- **`setting-up-agent-relay-desktop`** — the layer below this one: install, + sign-in, agent-led defaults, self-registration. Use it when section 2 is not + yet satisfied. +- **`relay-connect`** — a lighter, account-free, time-boxed way for two agents to + share one session via a hand-over link. Prefer it for an ad-hoc pairing; + prefer this skill for durable teammate-to-teammate handoff inside a shared + workspace. diff --git a/.claude/skills/signing-in-to-agent-relay-cloud/SKILL.md b/.claude/skills/signing-in-to-agent-relay-cloud/SKILL.md new file mode 100644 index 00000000..735fdeb5 --- /dev/null +++ b/.claude/skills/signing-in-to-agent-relay-cloud/SKILL.md @@ -0,0 +1,118 @@ +--- +name: signing-in-to-agent-relay-cloud +description: Sign a person in to Agent Relay Cloud from a running agent session through the OAuth device flow, with no API key or dashboard wizard - start the device grant, let the human approve it, poll for tokens, refresh them, read the account and workspace from whoami, and hand the session to the official CLI without printing a token. Use when an agent needs a Cloud access token or the person's user and workspace ids, including as the first step of an Agent Relay agent-driven signup. +--- + +# Sign In to Agent Relay Cloud + +Use the existing OAuth device flow. No API key, invitation, dashboard wizard, +or pre-existing Agent Relay account is required. Only the human approves the +sign-in; never approve access on their behalf, ask for their password, or +operate the approval page yourself. + +Paths below are relative to the Cloud API base, `https://agentrelay.com/cloud`, +including its `/cloud` prefix. When a guide names a different Cloud base (for +example a local development stack), use that exact base throughout and never +fall back to production. + +## HTTP client + +Use `curl` or `fetch`. Avoid Python's `urllib` or `requests` with their +default User-Agent: the device-token endpoint answers them with HTTP 403 and no +JSON body (tracked in AgentWorkforce/cloud#4254). Never print a raw auth +response while debugging; on success it contains tokens. Send JSON request +bodies with `Content-Type: application/json`, set a 30-second request timeout, +and check every response status before proceeding. + +## 1. Start the device grant + +`POST /api/v1/auth/device/start` with: + +```json +{"client_name":"My agent setup"} +``` + +An agent-driven signup guide also passes its product as `signup_source` +(`"teams"` or `"flows"`); keep the value the guide names. Omit it otherwise. + +Expect HTTP 201 with `device_code`, `user_code`, `verification_uri_complete`, +`verification_uri`, `interval` (seconds), and `expires_in` (seconds). Keep +`device_code` private. Open `verification_uri_complete` in the person's +browser with the OS URL opener (for example macOS `open`, with the URL passed as +a separate argument, never interpolated into shell code), or give them the link +if no opener is available. Show the `user_code` so they can compare it. The page +lets them sign in with Google, review the requesting device, and Approve or +Deny. A signup marker in the returned URL creates the right account type; +preserve it through sign-in. Do not call `/auth/device/approve` yourself. + +For a fresh signup, you can open `/api/auth/google/start?next=` +on the same Cloud base first (in production, +`https://agentrelay.com/cloud/api/auth/google/start?next=`), where `encoded-return-path` is the URL-encoded pathname plus query of +`verification_uri_complete`. This opens Google immediately and returns to the +same device approval with its code and signup marker intact. Never open the +production URL for a non-production base: that would sign the human in to +production instead. + +## 2. Poll for tokens + +While the browser is open, wait `interval` seconds between POSTs to +`/api/v1/auth/device/token` with this JSON (substitute the private +`device_code`): + +```json +{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":""} +``` + +- `authorization_pending`: keep waiting; respect a returned interval. +- `slow_down`: increase the interval by at least 5 seconds. +- HTTP 429: respect `Retry-After` and increase the interval. +- HTTP 5xx or request timeout: retry with backoff, bounded by `expires_in`. +- `access_denied`: stop. `expired_token` or `invalid_grant`: explain and start + a new grant only if the person still wants to continue. Never poll past + expiry. + +HTTP 200 returns `access_token`, `refresh_token`, `access_token_expires_at`, +`refresh_token_expires_at`, `api_url` and `token_type`. Keep credentials in +memory or a private file (directory 0700, file 0600) outside repositories. +Never echo tokens, put them in chat or URLs, or dump full authentication +responses. Use `Authorization: Bearer ` for subsequent Cloud +requests. Reject an `api_url` pointing at another origin; keep using the Cloud +base above. + +## 3. Refresh before expiry + +Before expiry, `POST /api/v1/auth/token/refresh` with +`{"refreshToken":""}`. The response uses camelCase: +`accessToken`, `refreshToken`, `accessTokenExpiresAt`, `refreshTokenExpiresAt`, +`apiUrl`. Replace both stored tokens atomically. Serialize refreshes: the +refresh token rotates and must not be shared between machines. An +invalid/expired refresh requires a new device login, not an endless retry. + +## 4. Read the account and workspace + +`GET /api/v1/auth/whoami`. Require `authenticated: true` and read `user.id`, +`user.email`, `currentWorkspace.id`, and `currentOrganization.id`. New signups +create a workspace automatically. Reuse it; do not create duplicate +accounts/workspaces. If `currentWorkspace` is missing, or an existing account +is in the wrong workspace, resolve that with the person before connecting or +activating anything. Never silently replace an existing connection to another +account. + +## 5. Hand the session to the official CLI + +When running a child process, pass these through its environment from your +private session object (never interpolate their values into logged commands): + +```text +CLOUD_API_URL= +CLOUD_API_ACCESS_TOKEN= +CLOUD_API_REFRESH_TOKEN= +CLOUD_API_ACCESS_TOKEN_EXPIRES_AT= +CLOUD_API_REFRESH_TOKEN_EXPIRES_AT= +``` + +The official CLI consumes this session. Refresh the parent session before +starting a long command; do not concurrently refresh it from parent and child +processes. Do not overwrite an existing CLI auth file or copy a session to +another machine. Delete temporary authentication files after the work is +complete. diff --git a/.claude/skills/writing-relayflows/SKILL.md b/.claude/skills/writing-relayflows/SKILL.md new file mode 100644 index 00000000..4c027076 --- /dev/null +++ b/.claude/skills/writing-relayflows/SKILL.md @@ -0,0 +1,544 @@ +--- +name: writing-relayflows +description: Use when authoring a Relayflows flow (@relayflows/surface / @relayflows/sdk, the journal-based v2 engine — the CLI is `flows`, package versions 2.0.x) in TypeScript or YAML/JSON. Covers the run/llm/agent ladder, direct child flows with use/dispatch, verification gates, cli/model selection, flows.json, parallel agents, Cloud dashboard mirroring, and `flows check`/`run`/`deploy`/`schedule` refusal shapes. Not for the older, unrelated `@relayflows/core` WorkflowBuilder engine (`.pattern('dag')`/.agent()/.step() chains) covered by `writing-agent-relay-workflows` and `migrating-persona-to-relayflow`. +--- + +# Writing Relayflows + +## Overview + +Relayflows 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. + +**Name collision warning.** This repo also has skills for an older, unrelated engine that is *also* casually called "Relayflow" (singular) — `@relayflows/core`'s `WorkflowBuilder`, a chained builder (`workflow('name').pattern('dag').agent(...).step(...).run()`). That's `writing-agent-relay-workflows` and `migrating-persona-to-relayflow`'s territory. 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're in the other engine — stop and use one of those skills instead. + +## When to use this skill + +- Writing a new `.flow.ts` or `.flow.yaml`/`.flow.json` for the `flows` CLI (package `@relayflows/sdk`, binary name `flows`). +- Deciding whether a step needs `run` (shell), `llm` (bare model call), or `agent` (harnessed coding agent in a workspace). +- Choosing a verification gate that will actually run — a wrong choice here compiles fine and fails at `flows run` time, not authoring time. +- Wiring up `cli`/`model` for an `agent` or `llm` step, in either language. +- Debugging a `REFUSED [...]` message from `flows check` or `flows run`. +- Deciding between running a flow locally, deploying it to listen for tickets, or scheduling it on a cron. + +## The ladder + +Three step verbs, one per rung (`@relayflows/sdk`'s `StepType = 'deterministic' | 'llm' | 'agent'`): + +1. **`run` / `deterministic`** — a shell command. No model. Implicit gate is `exit_code == 0`. +2. **`llm` / `llm`** — a bare model call. Prompt in, verified output out. No workspace, no tool use. +3. **`agent` / `agent`** — a harnessed coding agent in a workspace. Returns `{ summary, artifacts }`, not raw text. + +Plus 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. + +Most flows only need `run` and `llm`. Climb to `agent` once a step needs hands on a real workspace. + +## Two ways to author the same thing + +**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. + +### TypeScript + +```ts +import { flow } from '@relayflows/surface'; + +export default flow('hello', { budget: { wallclock: '10m' } }, async (f) => { + const greeting = await f.run('echo "Hello from Relayflows"'); + console.log(greeting.trim()); + + const answer = await f.agent('greeter', { + task: 'Reply with one short hello sentence. Do not use tools or modify files.', + cli: 'claude', + model: 'claude-sonnet-4-6', + }); + console.log(answer.summary); + + f.done('success'); +}); +``` + +### YAML + +```yaml +version: '0.1.0' +name: hello +steps: + - id: greeting + type: deterministic + command: 'echo "Hello from Relayflows"' + - id: greeter + type: agent + dependsOn: [greeting] + instruction: 'Reply with one short hello sentence. Do not use tools or modify files.' + cli: claude + model: claude-sonnet-4-6 +``` + +**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`. + +## The real `Ctx` contract (TypeScript) + +From 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`): + +```ts +export interface AgentResult { + summary: string; + artifacts: string[]; +} + +export interface PermissionsSpec { // exported from the package root + fileGlobs?: string[]; + networkAllowlist?: string[]; + accessPreset?: 'readonly' | 'readwrite'; +} + +export interface AgentOptions { + task: string; + workspace?: string; + permissions?: PermissionsSpec; // since 2.0.17 — declared and journaled, NOT enforced + cli?: string; + model?: string; + cwd?: string; // working dir for the CLI subprocess; defaults to the flow-runner's cwd + transport?: 'direct' | 'relay'; // see Agent-relay transport, below +} + +export interface LlmOptions { + output: Record; // JSON Schema, checked before the journal accepts the result + cli?: string; + model?: string; +} + +export interface DispatchResult { + name: string; + completionReason: 'success'; + completionDetail?: string; +} + +export interface Ctx extends Helpers { // Helpers = f.slack, f.github, f.linear, f.notion, ... — see Helpers + readonly mcp: Readonly Step>>>>; + run(command: string, options?: { timeout?: string | number }): Step; + llm(strings: TemplateStringsArray, ...values: unknown[]): Step; + llm(prompt: string, options: LlmOptions): Step; + agent(name: string, options: AgentOptions): Step; + human(question: string, options: { to: string }): Step; + dispatch(flow: string, input: unknown): Step; + done(reason: FlowCompletionReason): void; + cloud: CloudHelper; + memory: MemoryHelper; +} +``` + +Do 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. + +**`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. + +`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. + +### `f.done()`'s closed set is six values, not four + +```ts +export const FLOW_COMPLETION_REASONS = ['success', 'step_failed', 'canceled', 'budget_exceeded', 'needs_human', 'declined'] as const; +``` + +An 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. + +`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. + +## Verification gates + +Verification is control flow, not decoration — a gate decides whether a step actually completed, not just whether the process exited cleanly. + +### YAML/JSON (`@relayflows/sdk`'s `VerificationSpec`) + +```ts +export type VerificationSpec = ExitCodeGate | OutputContainsGate | JsonSchemaGate | NamedDataGate; +// NamedDataGate = ReferencesInputGate | SubprocessGate | WordCountBoundsGate | RegexMatchGate +// | ArtifactExistsGate +``` + +- `exit_code` — implicit default for `deterministic` steps. Not configurable; writing it explicitly is allowed and compiles to the same thing as omitting it. +- `output_contains` — step output (stdout tail, or the LLM value stringified) contains `value`. +- `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. +- `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'] }`. +- `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). +- `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. +- `word_count_bounds`, `references_input` — narrower named gates; see `packages/sdk/src/named-gates.ts` in the flows repo for the exact contract. + +```yaml +- id: classify + type: llm + prompt: 'Classify this ticket as bug, feature, or question: "the export button does nothing"' + cli: claude + model: claude-sonnet-4-6 + verification: + type: output_contains + value: bug +``` + +### TypeScript (`Step.gate(...)`) — read this before you write one + +`Step` has **two** overloads, and **one `.gate()` per step** — a second throws `unsupported_gate`: + +```ts +gate(config: NamedGate): Step; +// NamedGate = ReferencesInputNamedGate | SubprocessNamedGate | WordCountBoundsNamedGate +// | RegexMatchNamedGate | ArtifactExistsNamedGate +// — the surface-visible subset of NamedDataGate above. Data, not code: `flows check` can prove it. + +gate(predicate: (value: T) => boolean, because?: string): Step; +// Runs since flows#449. The executor calls the closure once, on the journaled value, and +// journals the VERDICT as a lowered `.gate` step — so resume and replay read the +// recorded verdict and never re-run the function. A false verdict fails the run as +// `gate_failed`, carrying `because`. +``` + +The 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: + +```ts +const outdated = await f.run('npm outdated --json 2>/dev/null || true') + .gate({ type: 'subprocess_gate', command: 'test -n "$INPUT"' }); // provable by `flows check` + +const review = await f.agent('review', { task: '…', cli: 'claude' }) + .gate({ type: 'subprocess_gate', command: 'test -s review.md' }); // checks a file the agent wrote, ignores $INPUT +``` + +(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.) + +An 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. + +**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. + +## Helpers: journaled effects, not just Slack + +`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)`. + +Two things every helper call needs: + +- **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. +- **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. + +For 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. + +**`tools` means two different things depending on where you write it — do not confuse them:** + +- `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." +- `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. +- `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). + +## `cli` / `model`: what a step actually runs on + +Both 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`: + +1. **step** — `step.cli` (or TS's `options.cli`) +2. **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**) +3. **flow** — `FlowSpec.cli` (YAML) — there is no equivalent flow-level `cli` field on the TypeScript `FlowHeader` +4. **project** — nearest `flows.json`'s `cli`, found by walking up from the flow file's directory + +No resolution found at any level → `REFUSED [cli_unresolved]`, before anything is journaled, e.g.: + +``` +$ flows check hello.flow.yaml # agent step, no cli anywhere +REFUSED [cli_unresolved] Step "greeter" has no CLI at step, flow, or project level. No flows.json was found from "..." to the filesystem root. +``` + +`model` has **no** flow or project default — only step or named-agent. Omitting it just runs whatever model the resolved CLI defaults to. + +### `flows.json` + +Nearest-wins project config, walking from the flow file's directory to the filesystem root. The shipped SDK type (`@relayflows/sdk`'s `FlowsJson`) is exactly: + +```ts +interface FlowsJson { + cli?: string; + executors?: string[]; + models?: string[]; + mcp?: Record; + deploy?: { bucket: string }; +} +``` + +- `cli` — the project-wide CLI default (resolution rung 4 above). +- `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. +- `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. +- `mcp` — project-owned MCP server connections. +- `deploy` — a file-bucket target for `flows deploy @sha256: --to `. + +**Verified for real:** + +``` +$ flows run hello.flow.ts --local-agent --input '{}' # step declares model "claude-opus-5", flows.json has no models[] +REFUSED [invalid_spec] llm_cli_unresolved: Step "llm-1" declares model "claude-opus-5" for CLI "claude", +but it is not listed in project model registry "…/flows.json"; add the exact model only after verifying +that project is allowed to use it. + +$ flows run pr-reviewer.flow.ts --local-agent --input '{...}' # flow declares .on(github....) handlers, no executors[] +REFUSED [no_executor] webhook trigger "github" is not registered in flows.json +``` + +Any 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. + +## Named agents and flow composition + +**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. + +```yaml +agents: + planner: { cli: claude, model: claude-opus-5 } + implementer: { cli: codex, model: gpt-5.6-codex } +steps: + - id: plan + type: agent + agent: planner + instruction: '…' +``` + +**TypeScript** has no equivalent `agents:` map key on `FlowHeader` — the closest idiom is a plain object spread reused across calls: + +```ts +const planner = { cli: 'claude', model: 'claude-opus-5' }; +await f.agent('plan', { ...planner, task: '…' }); +``` + +What 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**. + +## Parallel agents + +Running several agent (or any) steps concurrently is first-class, documented authoring — not a workaround: + +> "…`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 + +```ts +const LENSES = ['security', 'correctness', 'performance'] as const; + +await Promise.all( + LENSES.map((lens) => + f.agent(`${lens}-reviewer`, { + task: `Review this diff for ${lens} issues ONLY. Write findings to review/${lens}.json.`, + cli: 'claude', + }).gate({ type: 'subprocess_gate', command: `test -s review/${lens}.json` }), + ), +); + +const consensus = await f.agent('consensus', { + task: 'Read review/*.json. Resolve disagreement between lenses. Write review/consensus.json.', +}).gate({ type: 'subprocess_gate', command: 'test -s review/consensus.json' }); +``` + +This 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. + +## Agent-relay transport: dispatch, not live chat + +`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."* + +What 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. + +Don'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. + +## Human approval and direct child flows + +`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. + +`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: + +```ts +const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' }); +if (!ok) return f.done('declined'); // a person's "no" is declined, never canceled +``` + +``` +$ flows answer human-1 yes --by khaliq --note 'reviewed the diff' +$ flows resume +``` + +`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`. + +For 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: + +```ts +// release.flow.ts +import { flow } from '@relayflows/surface'; + +export default flow('release', { use: ['./implement.flow.ts'] }, async (f) => { + await f.run("printf '%s' prepare"); + const child = await f.dispatch('implement', { issue: 123 }); + await f.run("printf '%s' publish"); + f.done(child.completionReason); +}); +``` + +```ts +// implement.flow.ts +import { flow } from '@relayflows/surface'; + +export default flow('implement', async (f, input: { issue: number }) => { + await f.run(`printf '%s' ${input.issue}`); + f.done('success'); +}); +``` + +`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. + +Authority 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. + +To 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. + +Do 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. + +**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: + +``` +REFUSED [invalid_spec] unsupported_workspace_permission: The local agent worker accepts +stream-only steps. Remove workspace or attach a worker that holds its revision pins. +``` + +It 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. + +## Running it: `flows check` / `run` / `deploy` / `schedule` + +``` +flows check [--json] [--watch] +flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] +flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] --input +flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--data-dir ] [--local-agent] +flows deploy --repo --on [:key=value,...] [--on ...] --approver [--agents claude[,codex]] [--name ] [--draft] [--json] +flows deployments [--json] +flows undeploy [--json] +flows schedule --cron "" --tz --input +``` + +`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. + +**`--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. + +A `.flow.ts` run via `flows run` requires `--input ` even when the flow body ignores its input argument. + +**`--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: + +```bash +flows run pr-reviewer.flow.ts --local-agent --data-dir /somewhere/outside/the/repo --input '{...}' +``` + +**Before `flows deploy` will do anything, two things have to already be true — a correct command still fails without them:** + +1. **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. +2. **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. + +**`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). + +**`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. + +**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.) + +### Watching a local run: observer link vs `--cloud-mirror` + +New in **`2.0.32`**. A local run has two ways to be watched, and they are not the same thing: + +- **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. +- **`--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. + +```bash +flows run review.flow.ts --local-agent --input '{}' # observer link only +flows run --cloud-mirror review.flow.ts --local-agent --input '{}' # ...and the dashboard +``` + +The run prints both, and the dashboard line names Cloud's run id as well as the page: + +``` +Observer: https://agentrelay.com/observer?key=ot_live_... +Dashboard: https://.../dashboard/workflow//runner · flows status --cloud --watch +``` + +**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`. + +`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. + +**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. + +**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. + +``` +$ flows status --cloud +RUN 27ab5702-... local-mirror-agent-confirmation completed spend 13,088 in / 61 out / $0.01836 + ✓ ask-claude agent 1 attempt 3.3s claude-haiku-4-5-20251001 · 1 turn · $0.01836 + ✓ ask-codex agent 1 attempt 7.9s +$ flows logs --step ask-claude # that step's transcript, out of Cloud storage +``` + +Three behaviours worth knowing before you rely on it: + +- **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. +- **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. +- **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. + +**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). + +### Exit codes and refusal shapes + +Every refusal before a journal write is exit **2**, printed as `REFUSED [] `. The `` differs by *how* the flow was checked, not just *what* was wrong: + +- `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. +- `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. + +Both 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. + +## Common mistakes + +- **Forgetting `version` in a YAML/JSON `FlowSpec`.** Required, not optional. +- **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. +- **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`. +- **Assuming `flows.json`'s `models` sets a default model.** It only validates models already declared elsewhere; it never selects one. +- **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. +- **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. +- **Running an agent/llm-bearing flow without `--local-agent`.** The run parks with nothing attached to do the work. +- **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. +- **Not awaiting a step, or manually `.then()`-chaining one.** Refused (`unawaited_step`) rather than silently ignored. +- **Running a `.flow.ts` without `--input`.** Required even for flows that don't use their input argument. +- **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. + +## What this skill does NOT cover + +- **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. +- **Named-agent map equivalent in TypeScript beyond `use:`** — `use` composes complete child flows, not named agent configurations. +- **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**). +- **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. +- The **older `@relayflows/core` `WorkflowBuilder`** engine — see `writing-agent-relay-workflows` and `migrating-persona-to-relayflow` in this repo. + +## Quick reference + +| Verb / field | Language | Notes | +|---|---|---| +| `f.run(command, {timeout?})` / `type: deterministic` | both | shell command, implicit `exit_code` gate | +| `f.llm(...)` / `type: llm` | both | bare model call, no workspace | +| `f.agent(name, opts)` / `type: agent` | both | harnessed coding agent, returns `{summary, artifacts}` | +| `f.human(question, {to})` | TS only | parks the run on a durable `wait.human`; `flows answer human- yes\|no` then `flows resume` | +| `use: ['./child.flow.ts']` / `f.dispatch(name, input)` | TS only | `2.0.35`+ direct child in one durable tree; dispatch by declared name | +| `f.done(reason)` | TS / kernel | one of `success \| step_failed \| needs_human \| declined` from an authored body; `canceled \| budget_exceeded` are kernel-only | +| `.gate(config)` / `.gate(predicate, because)` | TS | one per step; both run since flows#449, but only a config object is provable by `flows check` | +| `options.transport: 'relay'` | TS/YAML `agent` step | dispatch to Agent Relay's task infra; request+receipt, not live chat | +| `Promise.all([...steps])` | TS | first-class supported concurrent fan-out | +| `flows.json`: `cli, executors, models, mcp, deploy` | project config | exact accepted key set — nothing else | +| `flows check ` | CLI | pure validate + preflight, no daemon | +| `flows run --local-agent [--input ...]` | CLI | actually executes; `.flow.ts` needs `--input`, agent/llm steps need `--local-agent` | +| `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` | +| `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` | +| `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` | +| `flows run --cloud` with `use` | CLI | unsupported: one self-contained `.flow.ts` source only, even with `--sync-code` | +| `flows status --cloud ` / `flows logs ` | CLI | take Cloud's **UUID**, not the journal ULID. Answer for mirrored local runs since `2.0.32` | +| `flows runs [--limit ]` | CLI | takes **no id** — lists runs newest-first, which is how you find the UUID the two above want | +| `flows schedule --cron ...` | CLI | cron-based cloud run | + +## Verified against + +**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`. + +A 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. + +**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`: + +- 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); +- 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. + +Two 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. + +The 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. + +The `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. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b63b7d9..f644e9fb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,17 @@ jobs: - name: Verify recommended-flow release pins run: npm run verify:recommended-flows + # The setup skills the signup guides render come from prpm, pinned in + # prpm.lock. Reinstalling from the lock must not change the committed + # copies (the lock's own "generated" timestamp aside), and the bundled + # module must match them. + - name: Verify setup skills are current + run: | + npx --yes prpm@2.1.40 install --frozen-lockfile -y + git diff --exit-code -- .claude/skills + git diff --exit-code -I '"generated"' -- prpm.lock + npm run verify:setup-skills + # Root script fans out to every workspace (web + router), so a new # workspace is covered automatically. - name: Run tests diff --git a/package.json b/package.json index 7722a8aa..8ea91ba4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test": "npm --workspaces run test", "verify:catalog-gates": "node web/scripts/verify-catalog-gates.mjs", "verify:recommended-flows": "npm run verify:catalog-gates && node web/scripts/verify-recommended-flow-catalog.mjs", + "skills:generate": "node web/scripts/generate-setup-skills.mjs", + "verify:setup-skills": "node web/scripts/generate-setup-skills.mjs --check", "preview": "npm --workspace web run preview", "dev:teams": "CLOUD_DEV_ORIGIN=http://127.0.0.1:3101 npm --workspace web run dev -- --hostname 127.0.0.1 --port 3100" } diff --git a/prpm.lock b/prpm.lock new file mode 100644 index 00000000..44e4b2ea --- /dev/null +++ b/prpm.lock @@ -0,0 +1,57 @@ +{ + "version": "1.0.0", + "lockfileVersion": 1, + "packages": { + "@agent-relay/setting-up-agent-relay-desktop#claude": { + "version": "1.1.5", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-desktop/1.1.5.tar.gz", + "integrity": "sha256-977560daebe00574ab9af8964f0004e0535f62f94a65f53a7f9cbb5de592fd35", + "format": "claude", + "subtype": "skill", + "sourceFormat": "claude", + "sourceSubtype": "skill", + "installedPath": ".claude/skills/setting-up-agent-relay-desktop/SKILL.md" + }, + "@agent-relay/setting-up-agent-relay-sessions#claude": { + "version": "0.2.5", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-sessions/0.2.5.tar.gz", + "integrity": "sha256-4e6234a7f970d6d653ca3cb50381a0959c5dd49171d64a81e743dff56613e1df", + "format": "claude", + "subtype": "skill", + "sourceFormat": "claude", + "sourceSubtype": "skill", + "installedPath": ".claude/skills/setting-up-agent-relay-sessions/SKILL.md" + }, + "@agent-relay/writing-relayflows#claude": { + "version": "1.6.0", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fwriting-relayflows/1.6.0.tar.gz", + "integrity": "sha256-437a96cbc7fba1e9d0640d08dcedd5182fd83905a859417bea4e46f622a58639", + "format": "claude", + "subtype": "skill", + "sourceFormat": "claude", + "sourceSubtype": "skill", + "installedPath": ".claude/skills/writing-relayflows/SKILL.md" + }, + "@agent-relay/signing-in-to-agent-relay-cloud#claude": { + "version": "0.1.0", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsigning-in-to-agent-relay-cloud/0.1.0.tar.gz", + "integrity": "sha256-6d1413b9793546ce39d686e10c2ac288963919ad5a58b61864a53b75cb154a82", + "format": "claude", + "subtype": "skill", + "sourceFormat": "claude", + "sourceSubtype": "skill", + "installedPath": ".claude/skills/signing-in-to-agent-relay-cloud/SKILL.md" + }, + "@agent-relay/setting-up-agent-relay-flows#claude": { + "version": "0.1.0", + "resolved": "https://registry.prpm.dev/api/v1/packages/%40agent-relay%2Fsetting-up-agent-relay-flows/0.1.0.tar.gz", + "integrity": "sha256-39fe2bbe69f92b0fb1120e40e49263e84c4163bbca11c15b1262d2badf55a206", + "format": "claude", + "subtype": "skill", + "sourceFormat": "claude", + "sourceSubtype": "skill", + "installedPath": ".claude/skills/setting-up-agent-relay-flows/SKILL.md" + } + }, + "generated": "2026-10-08T07:02:02.207Z" +} \ No newline at end of file diff --git a/web/app/flows/flow-examples.ts b/web/app/flows/flow-examples.ts index 06d18b22..32a74b52 100644 --- a/web/app/flows/flow-examples.ts +++ b/web/app/flows/flow-examples.ts @@ -1,7 +1,7 @@ export const flowExamples = [ { "id": "software-factory", - "label": "Software factory", + "label": "Software Garden", "title": "Turn a ticket into a pull request.", "description": "Plan the change, write the code, and run the tests before opening a PR.", "filename": "software-factory.flow.ts", diff --git a/web/app/flows/onboarding/FactoryBuilder.tsx b/web/app/flows/onboarding/FactoryBuilder.tsx index c61ca24f..ef3e216b 100644 --- a/web/app/flows/onboarding/FactoryBuilder.tsx +++ b/web/app/flows/onboarding/FactoryBuilder.tsx @@ -225,8 +225,8 @@ export function FactoryBuilder() {