> ## Documentation Index
> Fetch the complete documentation index at: https://www.agent37.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# SSH

> Open a real shell on an instance with your own SSH keypair: ssh, scp, sftp, port forwarding, and VS Code Remote SSH, on any template image.

`ssh` into any instance your workspace owns, with the keypair you already have. Once your public key is registered, the whole OpenSSH toolchain works: interactive shells with a PTY, `scp` and `sftp` for files, port forwarding with `-L`, and **VS Code Remote SSH** for editing straight on the box.

Nothing is installed in your image. The platform injects the SSH server into the sandbox at runtime, so SSH works on `agent37-hermes`, `agent37-openclaw`, `agent37-claude-code`, `agent37-codex`, `agent37-grok`, `agent37-opencode`, `agent37-n8n`, and every [custom image](/docs/agents-api/custom-image) you build, including images that ship no `sshd` and no shell tooling of their own.

## Quickstart

```bash theme={null}
export AGENT37_API_KEY=sk_live_...   # https://www.agent37.com/dashboard/cloud/api-keys
npx agent37 ssh setup
ssh ab12cd34ef.agent37.app
```

`ssh setup` is a one-time step and safe to re-run. It saves your API key to `~/.config/agent37/config.json` (prompting for it if `AGENT37_API_KEY` is not set), registers `~/.ssh/id_ed25519.pub` with your workspace under your machine's hostname (generating the keypair if you do not have one, and treating an already-registered key as done), and writes a managed block into `~/.ssh/config`:

```text ~/.ssh/config theme={null}
# >>> agent37 >>>
Host *.agent37.app
  ProxyCommand agent37 tunnel %h
  User root
  ServerAliveInterval 60
  StrictHostKeyChecking no
  UserKnownHostsFile /dev/null
  IdentityFile ~/.ssh/id_ed25519
# <<< agent37 <<<
```

The block goes at the top of the file, because `ssh` keeps the first value it finds for each setting and a `Host *` stanza further down would otherwise override it. `IdentityFile` names the key `setup` registered, so the connection still works if your existing config sets `IdentitiesOnly yes`.

That block is the whole client-side configuration. `ProxyCommand` is what carries the SSH stream (there is no port to dial directly), and `setup` writes it as the absolute path of the CLI that wrote it, so it works without a global install. Run under `npx` that path is inside the npx cache, so `npm install -g agent37` is the durable form; if `ssh` ever reports the `ProxyCommand` is missing, re-run `agent37 ssh setup`. `User root` is the default login and works on any image. `ssh node@<instance-id>.agent37.app` logs in as the user the agent runs as instead, so the agent's CLI, config, and files are exactly where the session lands. Use it (or set the `User` accordingly in your tool) when the point of the session is to drive the agent itself. `ServerAliveInterval` keeps an idle session from being dropped. Host-key checking is off because an instance regenerates its host key when the platform relocates it, and the tunnel is already TLS plus your API key, so a pinned known-hosts entry would warn on a legitimate move.

You do not have to touch `~/.ssh/config` at all. `agent37 ssh` connects with the same options inline, and takes plain `ssh` arguments after `--`:

```bash theme={null}
npx agent37 ssh ab12cd34ef
npx agent37 ssh ab12cd34ef -- -L 8080:localhost:3737
```

## How it works

`ssh` runs `agent37 tunnel` as its `ProxyCommand`, which pipes the SSH byte stream over a WebSocket to `https://{instanceId}-22022.agent37.app`, carrying your `sk_live_` key in the `X-Agent37-Key` header. The Agent37 edge authenticates that key, checks your workspace owns the instance, and hands the stream to the instance's SSH server, which then authenticates your SSH public key. The instance has no public IP and no listening port on the internet.

So there are two independent layers, and a connection needs both:

1. **Your workspace API key**, checked at the edge. Revoking the key closes the door for every SSH client using it.
2. **Your SSH private key**, checked by the SSH server inside the instance. The platform never sees it; only the public half is registered.

## Manage keys

`ssh setup` calls these for you. Use them directly to enroll a teammate's key, rotate keys from CI, or audit what is registered. The dashboard lists the same keys under **API keys** at [dashboard/cloud/api-keys](https://www.agent37.com/dashboard/cloud/api-keys).

| Method   | Path                | Returns                                  |
| -------- | ------------------- | ---------------------------------------- |
| `POST`   | `/v1/ssh-keys`      | `201` with the key object                |
| `GET`    | `/v1/ssh-keys`      | `200` `{ "data": [...] }`, newest first  |
| `DELETE` | `/v1/ssh-keys/{id}` | `200` `{ "id": "...", "deleted": true }` |

### Register a key

<ParamField body="public_key" type="string" required>
  One OpenSSH public key line, the contents of a `.pub` file: `ssh-ed25519 AAAA... you@laptop`. Accepted types are `ssh-ed25519`, `ssh-rsa`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, `ecdsa-sha2-nistp521`, `sk-ssh-ed25519@openssh.com`, and `sk-ecdsa-sha2-nistp256@openssh.com`. A private key, an unsupported type, or a malformed line returns `400 invalid_request`.
</ParamField>

<ParamField body="name" type="string">
  A label for the key, such as `laptop`. Optional: omit it and the key's trailing comment is used.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.agent37.com/v1/ssh-keys \
    -H "Authorization: Bearer sk_live_..." \
    -H "Content-Type: application/json" \
    -d "{ \"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"name\": \"laptop\" }"
  ```

  ```python python theme={null}
  import pathlib
  import requests

  resp = requests.post(
      "https://api.agent37.com/v1/ssh-keys",
      headers={"Authorization": "Bearer sk_live_..."},
      json={
          "public_key": pathlib.Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
          "name": "laptop",
      },
  )
  print(resp.json()["fingerprint"])
  ```

  ```javascript node theme={null}
  import { readFileSync } from "node:fs";
  import { homedir } from "node:os";

  const resp = await fetch("https://api.agent37.com/v1/ssh-keys", {
    method: "POST",
    headers: { Authorization: "Bearer sk_live_...", "Content-Type": "application/json" },
    body: JSON.stringify({
      public_key: readFileSync(`${homedir()}/.ssh/id_ed25519.pub`, "utf8").trim(),
      name: "laptop",
    }),
  });
  console.log((await resp.json()).fingerprint);
  ```

  ```json response theme={null}
  {
    "id": "9f2c1a7b4e0d6538ac91b2f4",
    "name": "laptop",
    "fingerprint": "SHA256:FO8faHNFPBZFVHE/8suuP499UfSYL1FkJUeEz8Ca0ms",
    "type": "ssh-ed25519",
    "created": 1781222420
  }
  ```
</CodeGroup>

<ResponseField name="id" type="string">
  The key's id. Pass it to `DELETE /v1/ssh-keys/{id}` to revoke.
</ResponseField>

<ResponseField name="name" type="string | null">
  The label you sent, the key's trailing comment when you omitted `name`, or `null` when the key carries no comment either.
</ResponseField>

<ResponseField name="fingerprint" type="string">
  The key's SHA256 fingerprint, the same string `ssh-keygen -lf ~/.ssh/id_ed25519.pub` prints. Compare it locally to confirm you registered the key you meant to.
</ResponseField>

<ResponseField name="type" type="string">
  The key type, for example `ssh-ed25519`.
</ResponseField>

<ResponseField name="created" type="integer | null">
  When the key was registered, in epoch seconds.
</ResponseField>

A fingerprint registers once per workspace: sending a key that is already registered returns `409 ssh_key_exists`. A workspace holds at most 20 keys; the 21st returns `400 invalid_request`.

### List keys

```bash curl theme={null}
curl https://api.agent37.com/v1/ssh-keys \
  -H "Authorization: Bearer sk_live_..."
```

```json response theme={null}
{
  "data": [
    {
      "id": "9f2c1a7b4e0d6538ac91b2f4",
      "name": "laptop",
      "fingerprint": "SHA256:FO8faHNFPBZFVHE/8suuP499UfSYL1FkJUeEz8Ca0ms",
      "type": "ssh-ed25519",
      "created": 1781222420
    }
  ]
}
```

Only your workspace's keys are listed, and the public key blob is not echoed back. Match on `fingerprint`.

### Delete a key

```bash curl theme={null}
curl -X DELETE https://api.agent37.com/v1/ssh-keys/9f2c1a7b4e0d6538ac91b2f4 \
  -H "Authorization: Bearer sk_live_..."
```

```json response theme={null}
{ "id": "9f2c1a7b4e0d6538ac91b2f4", "deleted": true }
```

The delete acts once: repeating it returns `404 not_found`, and another workspace's key id returns the same `404`.

## Keys are workspace-wide

A registered key opens **every** instance your workspace owns, current and future. There are no per-instance keys.

Key changes reach running instances automatically, within about two minutes of the API call. An instance you create after registering a key has it from the moment it boots, with nothing to wait for.

## Copy files

`scp` and `sftp` work as they do anywhere, recursive copies included:

```bash theme={null}
scp ./report.csv ab12cd34ef.agent37.app:data/
scp -r ab12cd34ef.agent37.app:output ./output
sftp ab12cd34ef.agent37.app
```

For programmatic file movement from a backend, the instance's own [files endpoints](/docs/agents-api/files) are usually the better fit: they need no SSH key and no CLI.

## VS Code Remote SSH

With the `~/.ssh/config` block in place, the instance is an ordinary SSH host to VS Code. Install the **Remote - SSH** extension, run **Remote-SSH: Connect to Host**, and enter `ab12cd34ef.agent37.app`. The instance shows up in the host list on later connections.

VS Code installs its remote server into the instance on the first connect, so that connect is the slow one and later ones come up fast. Any editor that speaks plain SSH works the same way.

The Hermes Desktop app rides the same access: its **Connect via SSH** mode starts Hermes on the instance and tunnels it back to the app. [Connect Hermes Desktop](/docs/agents-api/hermes-desktop) walks through it.

## Revoke

Two ways, neither on a timer. There are no TTLs and no expiring sessions, so access lasts until you take it away.

* **Delete the SSH key** (`DELETE /v1/ssh-keys/{id}`) to stop that keypair from logging in anywhere in the workspace.
* **Revoke the `sk_live_` API key** to stop every SSH client configured with it, whatever keypair it holds.

Revocation reaches running instances within about two minutes. It stops new logins; a session that is already open keeps running until it exits, so [restart](/docs/agents-api/instances#restart) or [stop](/docs/agents-api/instances#stop) the instance if you need live sessions cut immediately.

## Rules and limits

* **You log in as `root` by default; `node` is also accepted.** `ssh node@<id>.agent37.app` lands as the user the agent runs as, with its config and files in place. Prefer it when the session is about the agent, since files a root session writes into the agent's home can wedge the agent. On a [custom template](/docs/agents-api/custom-image), a non-root login works when your image has that account unlocked (`hermes-base` children do; otherwise add `usermod -p '*' node`, since a Debian-locked account is refused even for key auth); `root` works on any image. Password authentication is off: keys only.
* **An instance on an image published before 9 August 2026 accepts `root` only.** Its `node` account predates the unlock above, so [update](/docs/agents-api/instances#update) the instance once and `node` login works from then on.
* **An instance created before 3 August 2026 needs one restart.** The SSH server is injected when the sandbox is built, and a sandbox that is already running cannot be given one, so [restart](/docs/agents-api/instances#restart) that instance once (or [start](/docs/agents-api/instances#start) it, if it is stopped). Its disk is preserved; anything held in memory is not. Instances created since then have SSH from first boot.
* **A stopped instance refuses connections.** [Start](/docs/agents-api/instances#start) it first. Connecting to a sleeping instance [wakes it](/docs/agents-api/urls#sleeping-instances-wake-on-request), the same as any other request to its URLs. `ssh` waits at connect while the wake runs; a wake that has to rebuild the instance boots it fresh, so `tmux` and `screen` sessions from before the sleep are gone and files on disk are not.
* **An open session counts as activity.** Keepalives keep bytes moving, so an [auto-sleep](/docs/agents-api/instances#auto-sleep) instance stays awake for as long as you are connected. Close the session when you are done.
* **Port `22022` can never be exposed without a credential.** It is a reserved platform port: no [public port](/docs/agents-api/public-ports) and no [signed URL](/docs/agents-api/urls#browser-access-with-signed-urls) can be minted for it, so a WebSocket carrying an authenticated `sk_live_` key is the only way in.
* **20 SSH keys per workspace**, one entry per fingerprint.
* SSH traffic is ordinary instance traffic and is not metered separately.
