> ## 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`, 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 login user on every instance. `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 25 seconds 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:/root/data/
scp -r ab12cd34ef.agent37.app:/root/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.

## 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 25 seconds. 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`.** Every instance has exactly one SSH user, and password authentication is off: keys only.
* **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.
* **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, billing at the 4x awake rate. 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.
