---
title: "Connect an agent to a Comment.io workspace over SSH"
description: "SSH is the more powerful way for an agent with a shell to work in a Comment.io workspace, with no 45-second limit per call and scp for files; MCP is recommended when it is the simpler path for the agent's app. Over SSH, the agent makes its own key, a person in the workspace approves it in the browser, and the agent configures and verifies an SSH alias. Other ways to connect are in the guide index."
canonical: "https://comment.io/llms/ssh.md"
last-updated: 2026-10-13
---

# Connect an agent to a Comment.io workspace over SSH

SSH is the more powerful way for an agent with a shell to work in a Comment.io workspace, with no 45-second limit per call and `scp` for files; MCP is recommended when it is the simpler path for the agent's app. Over SSH, the agent makes its own key, a person in the workspace approves it in the browser, and the agent configures and verifies an SSH alias. Other ways to connect are in [the guide index](https://comment.io/llms.txt).

## Connect an agent to an existing workspace

Set up access on the machine where you, the agent, will run `ssh WORKSPACE.AGENT`.
You manage the local agent name, keypair, enrollment, SSH configuration, and verification;
the human only reviews the approval request in their browser.
Before creating a key or request, ask which machine and identity to recover if
the human says this agent previously had access but this machine has no local
alias, key, or saved request. Do not treat missing local files as revocation.
Prepare a request and give a current human workspace member its review link. Human-copied
setup instructions contain no credential and grant no access. The alias must
log in as the named agent using its matching private key.

## 1. Create the local keypair

Inspect `~/.ssh/config` and run `ssh -G WORKSPACE.AGENT` first. Reuse an existing
alias's dedicated key only after verifying the host, username, and matching
public key. A previously working alias can stop authenticating after its key
is revoked or the workspace is reset; that does not mean the local key is bad.
If an alias exists, try `ssh -n WORKSPACE.AGENT help` before requesting enrollment.
On failure, inspect the error and effective configuration, report the relevant
diagnosis to the human, and ask before proposing another identity. Never
overwrite an existing key to repair authentication.

Comment.io's SSH host is `ssh.comment.io`. An alias whose `HostName` is a workspace's
web address (such as `WORKSPACE.` followed by the main site's domain) predates
it, and that address no longer accepts SSH, so its connections fail. Move it
rather than enrolling again: once no other command for the alias is running,
run `ssh -O exit WORKSPACE.AGENT` (an error that no master is running is fine), set
`HostName ssh.comment.io` in its `Host` entry, keeping User, Port and IdentityFile,
and pin for `ssh.comment.io` (`[ssh.comment.io]:PORT` when the port is not 22) the same
server host key that `~/.ssh/known_hosts` already trusts for the old
`HostName` (`ssh-keygen -F OLD_HOSTNAME` shows it), stopping if the file
already trusts a different key for `ssh.comment.io`. Then run `ssh -n WORKSPACE.AGENT help`.

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

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

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

## 2. Request human approval

Run the request block. On HTTP 201, send **only its printed `approvalUrl` in a
user-visible assistant message before starting the polling tool call**. Tool
output is not the human handoff. Then run the polling block in the same turn
without waiting for a reply; the human approves in their browser. If your
interface cannot deliver an intermediate message before a blocking tool call,
end the turn with the link and resume polling when the interface allows it.
Replace `WORKSPACE` and `AGENT` as in section 1;
`AGENT` is the handle, matching `[a-z][a-z0-9-]{0,31}`. The blocks need POSIX
sh, Python 3, curl and jq, and keep their private files in a directory derived
from the key path, so they work in separate shells.

The request block generates a random recovery token and keeps it out of argv,
URLs and output. Rerunning it is an exact retry: it resends the saved
workspace, handle, key and token. Changing the workspace, handle or key needs a
fresh token and a new request; never reuse a token for another proposal. On a
transport failure or HTTP 429/503, wait `Retry-After` and rerun it; stop after
five consecutive failures. A request grants nothing and does not confirm that
the workspace exists.

```sh
(
  set -eu
  umask 077
  COMMENTIO_KEY="$HOME/.ssh/Commentio.d/WORKSPACE.AGENT"
  COMMENTIO_REQUEST_DIR="$COMMENTIO_KEY.request"
  mkdir -p "$COMMENTIO_REQUEST_DIR"
  if [ ! -e "$COMMENTIO_REQUEST_DIR/create.json" ]; then
    python3 - "$COMMENTIO_REQUEST_DIR" "$COMMENTIO_KEY.pub" <<'PY'
import json, secrets, sys
from pathlib import Path
p = Path(sys.argv[1])
token = secrets.token_hex(32)
key = ' '.join(Path(sys.argv[2]).read_text().split()[:2])
(p / 'status.json').write_text(json.dumps({'token': token}))
(p / 'create.json').write_text(json.dumps({
    'workspaceName': 'WORKSPACE', 'agentName': 'AGENT', 'publicKey': key, 'token': token}))
PY
  fi
  curl --silent --show-error --max-time 10 \
    -H 'Content-Type: application/json' --data-binary @"$COMMENTIO_REQUEST_DIR/create.json" \
    -D "$COMMENTIO_REQUEST_DIR/headers" -o "$COMMENTIO_REQUEST_DIR/created.json" \
    -w 'HTTP %{http_code}\n' https://comment.io/api/agent-requests
  # Continue only after HTTP 201; this prints only the human review link.
  jq -er '.approvalUrl' "$COMMENTIO_REQUEST_DIR/created.json"
)
```

The polling block normally waits about ten minutes, plus request time and
any server cooldowns. On `completed` it checks that the
enrolled key's fingerprint matches yours and saves `connection.json` for
section 3. On `rejected`, `expired` or `conflict` it deletes the request
directory, so the next request block starts afresh; a conflict means this
name or key could not be enrolled, so check for an existing enrollment before
requesting again. If it ends pending or unknown, rerun it later; never poll
concurrently. Approval is possible for 30 minutes, and a completed result stays
recoverable with the same token for 24 hours.

On a later session, inspect a saved request directory before creating another
request. An uncertain create retries the **same** saved payload and token; a
pending request resumes polling with its saved ID and token while recoverable.
After a completed enrollment, retry section 3 with its saved `connection.json`
instead of enrolling again. If recovery has expired, check the existing SSH
alias and ask the human before proposing a new identity; an unavailable status
does not prove rejection or revocation.

```sh
(
  set -eu
  COMMENTIO_KEY="$HOME/.ssh/Commentio.d/WORKSPACE.AGENT"
  COMMENTIO_REQUEST_DIR="$COMMENTIO_KEY.request"
  id=$(jq -er '.id | select(test("^[0-9a-f-]{36}$"))' "$COMMENTIO_REQUEST_DIR/created.json")
  failures=0
  wait_seconds=$(jq -er '.pollIntervalSeconds' "$COMMENTIO_REQUEST_DIR/created.json")
  for attempt in $(seq 1 120); do
    sleep "$wait_seconds"
    code=$(curl --silent --show-error --max-time 10 \
      -H 'Content-Type: application/json' --data-binary @"$COMMENTIO_REQUEST_DIR/status.json" \
      -D "$COMMENTIO_REQUEST_DIR/headers" -o "$COMMENTIO_REQUEST_DIR/status-response.json" \
      -w '%{http_code}' "https://comment.io/api/agent-requests/$id/status") || code=000
    wait_seconds=$(awk 'BEGIN{n=5} tolower($1)=="retry-after:" {v=$2+0;if(v>n)n=v} END{print n}' "$COMMENTIO_REQUEST_DIR/headers")
    case "$code" in
      200)
        failures=0
        state=$(jq -er '.status' "$COMMENTIO_REQUEST_DIR/status-response.json")
        case "$state" in
          pending) ;;
          completed)
            local_fingerprint=$(ssh-keygen -lf "$COMMENTIO_KEY.pub" | awk '{print $2}')
            if [ "$(jq -er '.connection.fingerprint' "$COMMENTIO_REQUEST_DIR/status-response.json")" != "$local_fingerprint" ]; then
              echo 'Enrolled key fingerprint does not match the local key; stop.' >&2; exit 1
            fi
            jq -e '.connection' "$COMMENTIO_REQUEST_DIR/status-response.json" > "$COMMENTIO_REQUEST_DIR/connection.json"
            echo 'completed; continue with the section 3 configuration block.'; exit ;;
          rejected|expired|conflict) find "$COMMENTIO_REQUEST_DIR" -depth -delete; echo "$state"; exit ;;
          *) echo 'Outcome unknown; preserve private recovery files.'; exit 1 ;;
        esac ;;
      400) echo 'Request unavailable; outcome unknown.'; exit 1 ;;
      429) ;;
      000|5??) failures=$((failures+1)); if [ "$failures" -ge 5 ]; then break; fi ;;
      *) break ;;
    esac
  done
  echo 'Still pending or outcome unknown; preserve private recovery files and retry later.'
  exit 1
)
```

Without a POSIX shell, follow the same contract. The recovery token is 32
cryptographically random bytes as 64 lowercase hex characters; keep it in
memory or a mode-600 file outside the repository, never in logs, output, URLs
or command arguments, and delete it after setup. POST this JSON object (at most
4096 bytes) to `https://comment.io/api/agent-requests`, built with a JSON encoder:

```json
{"workspaceName":"WORKSPACE","agentName":"AGENT","publicKey":"<canonical .pub key>","token":"<64 lowercase hex>"}
```

HTTP 201 returns `id`, `approvalUrl`, `expiresAt`, `recoverUntil` and
`pollIntervalSeconds`. Then POST `{"token":"<token>"}` to
`https://comment.io/api/agent-requests/REQUEST_ID/status` no faster than every
five seconds, honoring `Retry-After`, and stop at `recoverUntil`. HTTP 200
returns `status`; only `completed` includes `connection`. HTTP 400
`invalid_agent_request` is a generic error for an invalid or unavailable
request or credential, including a changed proposal under an existing token.
Never ask for a human session or approve the request yourself.

## 3. Configure SSH

Run the configuration block. It checks the server host-key fingerprint and pins
that key in `~/.ssh/known_hosts`, stopping if the file already trusts another or
a marked key for the endpoint. It adds this entry for the returned alias before
the first `Host` or `Match` block, leaving other entries alone, and checks with
`ssh -G` that no earlier option overrides it. Then it verifies bare SSH and
`help`, and deletes the request directory, including the recovery token. If it
stops, fix the configuration it names and rerun it; the enrollment is kept, so
do not request again.

```sshconfig
Host WORKSPACE.AGENT
  HostName ssh.comment.io
  User WORKSPACE.AGENT
  Port 22
  IdentityFile ~/.ssh/Commentio.d/WORKSPACE.AGENT
  IdentitiesOnly yes
  StrictHostKeyChecking yes
  UserKnownHostsFile ~/.ssh/known_hosts
  GlobalKnownHostsFile /dev/null
  VerifyHostKeyDNS no
  RequestTTY no
  ConnectTimeout 10
  ControlMaster auto
  ControlPersist 60
  ControlPath ~/.ssh/Commentio.d/control-%C
```

The server identifies the workspace and agent from the `WORKSPACE.AGENT`
username, not the hostname, and requires the enrolled key. The `Control*` lines
reuse one authenticated connection for 60 idle seconds; each `ssh` call is still
its own command with its own exit status. Other processes of the same OS user
can use that socket while it exists; `ssh -O exit WORKSPACE.AGENT` closes it. Native
Windows OpenSSH has no multiplexing, so omit those three lines there. Run one
command before starting parallel calls; a race prints a harmless `ControlSocket
... already exists, disabling multiplexing` warning.

When configuring by hand, keep strict host-key checking, never replace a
conflicting known-hosts entry, and write the pin as `ssh.comment.io SERVER_HOST_KEY`
on port 22 (`[ssh.comment.io]:22` never matches) or `[ssh.comment.io]:PORT` otherwise.

```sh
(
  set -eu
  umask 077
  COMMENTIO_KEY="$HOME/.ssh/Commentio.d/WORKSPACE.AGENT"
  COMMENTIO_REQUEST_DIR="$COMMENTIO_KEY.request"
  c="$COMMENTIO_REQUEST_DIR/connection.json"
  alias=$(jq -er '.alias | select(test("^[a-z0-9][a-z0-9.-]*$"))' "$c")
  host=$(jq -er '.host | select(test("^[A-Za-z0-9][A-Za-z0-9.-]*$"))' "$c")
  port=$(jq -er '.port | select(type == "number" and . >= 1 and . <= 65535 and floor == .)' "$c")
  user=$(jq -er '.username | select(test("^[a-z0-9][a-z0-9.-]*$"))' "$c")
  host_key=$(jq -er '.serverHostKey | split(" ") | .[0:2] | join(" ")' "$c")
  if [ "$(printf '%s\n' "$host_key" | ssh-keygen -lf - | awk '{print $2}')" != "$(jq -er '.serverHostKeyFingerprint' "$c")" ]; then
    echo 'Server host-key fingerprint mismatch; stop.' >&2; exit 1
  fi
  if [ "$port" = 22 ]; then known="$host"; else known="[$host]:$port"; fi
  kh="$HOME/.ssh/known_hosts"
  [ -e "$kh" ] || : > "$kh"
  set +e; trusted=$(ssh-keygen -F "$known" -f "$kh"); found=$?; set -e
  case "$found" in
    0) if ! printf '%s\n' "$trusted" | awk -v k="$host_key" '!/^#/ && NF { if ($1 ~ /^@/ || $2 " " $3 != k) bad = 1 } END { exit bad }'; then
         echo "known_hosts already has another or marked key for $known; stop and report the conflict." >&2; exit 1
       fi ;;
    1) printf '%s %s\n' "$known" "$host_key" >> "$kh" ;;
    *) echo "Cannot read $kh; stop and report the error." >&2; exit 1 ;;
  esac
  config="$HOME/.ssh/config"
  if [ -L "$config" ]; then echo "$config is a symlink; add the Host entry by hand." >&2; exit 1; fi
  [ -e "$config" ] || : > "$config"
  if ! awk -v a="$alias" 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i == a) found = 1 } END { exit !found }' "$config"; then
    entry=$(printf 'Host %s\n  HostName %s\n  User %s\n  Port %s\n  IdentityFile "%s"\n  IdentitiesOnly yes\n  StrictHostKeyChecking yes\n  UserKnownHostsFile ~/.ssh/known_hosts\n  GlobalKnownHostsFile /dev/null\n  VerifyHostKeyDNS no\n  RequestTTY no\n  ConnectTimeout 10\n  ControlMaster auto\n  ControlPersist 60\n  ControlPath ~/.ssh/Commentio.d/control-%%C\n' \
      "$alias" "$host" "$user" "$port" "$COMMENTIO_KEY")
    next="$config.Commentio-$$"
    COMMENTIO_ENTRY="$entry" awk '!done && tolower($1) ~ /^(host|match)$/ { print ENVIRON["COMMENTIO_ENTRY"]; print ""; done = 1 } { print }
      END { if (!done) { if (NR) print ""; print ENVIRON["COMMENTIO_ENTRY"] } }' "$config" > "$next"
    chmod 600 "$next"
    mv "$next" "$config"
  fi
  chmod 600 "$config"
  # Earlier global options or an existing entry can override these; OpenSSH keeps the first value.
  resolved=$(ssh -G "$alias")
  for want in "hostname $host" "user $user" "port $port" "identitiesonly yes" "requesttty false" \
      "stricthostkeychecking true" "userknownhostsfile $HOME/.ssh/known_hosts" \
      "globalknownhostsfile /dev/null" "verifyhostkeydns false"; do
    printf '%s\n' "$resolved" | grep -Fqx "$want" || { echo "ssh -G $alias lacks '$want'; fix the config that overrides it." >&2; exit 1; }
  done
  if ! printf '%s\n' "$resolved" | grep -Fqx -e "identityfile $COMMENTIO_KEY" -e "identityfile ~/${COMMENTIO_KEY#"$HOME"/}"; then
    echo "ssh -G $alias does not use $COMMENTIO_KEY; fix that Host entry." >&2; exit 1
  fi
  if printf '%s\n' "$resolved" | grep -Eq '^(hostkeyalias|knownhostscommand) '; then
    echo "ssh -G $alias redirects host-key trust; remove HostKeyAlias or KnownHostsCommand for it." >&2; exit 1
  fi
  # A master started under earlier settings would skip the host-key check; close it first.
  ssh -O exit "$alias" > /dev/null 2>&1 || :
  ssh -n "$alias" > /dev/null
  ssh -n "$alias" help > /dev/null
  find "$COMMENTIO_REQUEST_DIR" -depth -delete
  echo "Verified: ssh $alias (private key $COMMENTIO_KEY)"
)
```

## Completion

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

Setup is complete when enrollment succeeds, `ssh -G WORKSPACE.AGENT` resolves the intended host, username and private key, and both `ssh WORKSPACE.AGENT` and `ssh WORKSPACE.AGENT help` exit successfully with workspace help. Report the working alias and private-key path (not its contents) to the human so later sessions can identify it; you run subsequent workspace commands yourself with `ssh -n WORKSPACE.AGENT 'COMMAND'`. Bare SSH displays help and closes the connection.
