{"title":"SSH Access via Proxy","slug":"ssh-proxy-access","category":"llm-cookbook","summary":"How an LLM agent runs commands on remote machines safely through an SSH-Proxy API instead of raw SSH keys.","audience":["llm"],"tags":["cookbook","ssh","proxy","infrastructure","remote-execution"],"difficulty":"intermediate","updated":"2026-09-26","word_count":375,"read_minutes":2,"llm_context":"NEVER hold raw SSH private keys as an LLM. Use an SSH-Proxy HTTP API:\n1) POST /connect?target=<name> → session_id (persistent, survives hours).\n2) POST /<session_id>/exec {command, timeout} → exec_id.\n3) GET /<session_id>/exec/<exec_id> → output when done.\n4) Use SFTP endpoints for file up/download instead of cat/heredoc.\nTargets are pre-registered names (e.g. \"vps1\"), not IPs — the proxy is the\nonly place where credentials live.\n","lang":"en","translated":true,"requested_lang":"en","content_markdown":"\n# SSH Access via Proxy\n\nAgents need to run commands on remote machines — deploy a container, read a\nlog, fix a config. Handing raw SSH private keys to an LLM is a bad idea:\nkeys end up in context windows, logs and transcripts. An SSH-Proxy solves\nthis: it owns the credentials, the agent only speaks HTTP.\n\n## The Model\n\n```\nLLM ──HTTPS──► SSH-Proxy ──SSH──► target machines\n                (owns keys,\n                 audit log,\n                 rate limits)\n```\n\nThe proxy exposes persistent sessions with a simple REST surface:\n\n| Endpoint | Purpose |\n|---|---|\n| `POST /connect?target=vps1` | open a session → `session_id` |\n| `POST /<sid>/exec` `{command, timeout}` | run a command (async) → `exec_id` |\n| `GET /<sid>/exec/<exec_id>` | poll for output |\n| SFTP endpoints | file up-/download, `ls`, `mkdir`, `rm` |\n\nAuth is a Bearer token — the same pattern as every other API you use.\n\n## The Execution Pattern\n\n```bash\n# 1. connect once, reuse the session for the whole task\nSID=$(curl -s -H \"Authorization: Bearer $KEY\" \\\n  \"https://ssh-proxy.example/connect?target=vps1\" | jq -r .session_id)\n\n# 2. run a command\nEID=$(curl -s -H \"Authorization: Bearer $KEY\" -H \"Content-Type: application/json\" \\\n  -X POST \"https://ssh-proxy.example/$SID/exec\" \\\n  -d '{\"command\": \"docker ps --format \\\"{{.Names}}\\\"\", \"timeout\": 120}' | jq -r .exec_id)\n\n# 3. poll the result\ncurl -s -H \"Authorization: Bearer $KEY\" \"https://ssh-proxy.example/$SID/exec/$EID\"\n```\n\n## Rules of Engagement\n\n1. **Reuse sessions.** Connecting per command wastes resources and trips\n   rate limits. Store the `session_id` (e.g. `/tmp/vps1_sid`) and heartbeat\n   it before reuse.\n2. **Name targets, don't discover them.** Targets are pre-registered\n   (`vps1`, `pi`, …). The proxy is the single source of truth — never hardcode\n   IP addresses in agent code.\n3. **Prefer SFTP over shell cat/heredoc for files.** Escaping binary data\n   through `exec` corrupts payloads. Use the dedicated upload/download\n   endpoints.\n4. **Quote your commands.** The JSON body is parsed twice (HTTP + shell).\n   Wrap remote commands in single quotes and keep them one-liners.\n5. **Assume auditing.** Every exec lands in the proxy's audit log — behave\n   as if the human reads everything (they can).\n\n## When NOT to use exec\n\n- Reading structured state that has an API (Docker, databases, the Synapse\n  Mind API) — always prefer the API over parsing CLI output.\n- Long-running interactive processes. `exec` is fire-and-forget with a\n  timeout; use `nohup` + a status file on the target instead.\n- Anything destructive without an explicit instruction. The proxy gives you\n  the power — the human gives you the permission.\n\n## Error Recovery\n\n| Symptom | Fix |\n|---|---|\n| 401 on connect | token expired — re-read it from memory/settings, never from a transcript |\n| session 404 | session expired (24h cap) — reconnect |\n| exec timeout | raise `timeout` or split the command; check load on the target |\n| garbled file content | you used `exec` instead of SFTP — switch to the SFTP endpoints |\n","content_html":"<h1>SSH Access via Proxy</h1>\n<p>Agents need to run commands on remote machines — deploy a container, read a\nlog, fix a config. Handing raw SSH private keys to an LLM is a bad idea:\nkeys end up in context windows, logs and transcripts. An SSH-Proxy solves\nthis: it owns the credentials, the agent only speaks HTTP.</p>\n<h2>The Model</h2>\n<pre><code class=\"hljs language-plaintext\">LLM ──HTTPS──► SSH-Proxy ──SSH──► target machines\n                (owns keys,\n                 audit log,\n                 rate limits)</code></pre><p>The proxy exposes persistent sessions with a simple REST surface:</p>\n<table>\n<thead>\n<tr>\n<th>Endpoint</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>POST /connect?target=vps1</code></td>\n<td>open a session → <code>session_id</code></td>\n</tr>\n<tr>\n<td><code>POST /&lt;sid&gt;/exec</code> <code>{command, timeout}</code></td>\n<td>run a command (async) → <code>exec_id</code></td>\n</tr>\n<tr>\n<td><code>GET /&lt;sid&gt;/exec/&lt;exec_id&gt;</code></td>\n<td>poll for output</td>\n</tr>\n<tr>\n<td>SFTP endpoints</td>\n<td>file up-/download, <code>ls</code>, <code>mkdir</code>, <code>rm</code></td>\n</tr>\n</tbody></table>\n<p>Auth is a Bearer token — the same pattern as every other API you use.</p>\n<h2>The Execution Pattern</h2>\n<pre><code class=\"hljs language-bash\"><span class=\"hljs-comment\"># 1. connect once, reuse the session for the whole task</span>\nSID=$(curl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> \\\n  <span class=\"hljs-string\">&quot;https://ssh-proxy.example/connect?target=vps1&quot;</span> | jq -r .session_id)\n\n<span class=\"hljs-comment\"># 2. run a command</span>\nEID=$(curl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> -H <span class=\"hljs-string\">&quot;Content-Type: application/json&quot;</span> \\\n  -X POST <span class=\"hljs-string\">&quot;https://ssh-proxy.example/<span class=\"hljs-variable\">$SID</span>/exec&quot;</span> \\\n  -d <span class=\"hljs-string\">&#x27;{&quot;command&quot;: &quot;docker ps --format \\&quot;{{.Names}}\\&quot;&quot;, &quot;timeout&quot;: 120}&#x27;</span> | jq -r .exec_id)\n\n<span class=\"hljs-comment\"># 3. poll the result</span>\ncurl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> <span class=\"hljs-string\">&quot;https://ssh-proxy.example/<span class=\"hljs-variable\">$SID</span>/exec/<span class=\"hljs-variable\">$EID</span>&quot;</span></code></pre><h2>Rules of Engagement</h2>\n<ol>\n<li><strong>Reuse sessions.</strong> Connecting per command wastes resources and trips\nrate limits. Store the <code>session_id</code> (e.g. <code>/tmp/vps1_sid</code>) and heartbeat\nit before reuse.</li>\n<li><strong>Name targets, don&#39;t discover them.</strong> Targets are pre-registered\n(<code>vps1</code>, <code>pi</code>, …). The proxy is the single source of truth — never hardcode\nIP addresses in agent code.</li>\n<li><strong>Prefer SFTP over shell cat/heredoc for files.</strong> Escaping binary data\nthrough <code>exec</code> corrupts payloads. Use the dedicated upload/download\nendpoints.</li>\n<li><strong>Quote your commands.</strong> The JSON body is parsed twice (HTTP + shell).\nWrap remote commands in single quotes and keep them one-liners.</li>\n<li><strong>Assume auditing.</strong> Every exec lands in the proxy&#39;s audit log — behave\nas if the human reads everything (they can).</li>\n</ol>\n<h2>When NOT to use exec</h2>\n<ul>\n<li>Reading structured state that has an API (Docker, databases, the Synapse\nMind API) — always prefer the API over parsing CLI output.</li>\n<li>Long-running interactive processes. <code>exec</code> is fire-and-forget with a\ntimeout; use <code>nohup</code> + a status file on the target instead.</li>\n<li>Anything destructive without an explicit instruction. The proxy gives you\nthe power — the human gives you the permission.</li>\n</ul>\n<h2>Error Recovery</h2>\n<table>\n<thead>\n<tr>\n<th>Symptom</th>\n<th>Fix</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>401 on connect</td>\n<td>token expired — re-read it from memory/settings, never from a transcript</td>\n</tr>\n<tr>\n<td>session 404</td>\n<td>session expired (24h cap) — reconnect</td>\n</tr>\n<tr>\n<td>exec timeout</td>\n<td>raise <code>timeout</code> or split the command; check load on the target</td>\n</tr>\n<tr>\n<td>garbled file content</td>\n<td>you used <code>exec</code> instead of SFTP — switch to the SFTP endpoints</td>\n</tr>\n</tbody></table>\n","urls":{"html":"/docs/llm-cookbook/ssh-proxy-access","text":"/docs/llm-cookbook/ssh-proxy-access?format=text","json":"/docs/llm-cookbook/ssh-proxy-access?format=json","llm":"/docs/llm-cookbook/ssh-proxy-access?format=llm"},"translations_available":["en","de"],"wordCount":375,"readMinutes":2,"llmContext":"NEVER hold raw SSH private keys as an LLM. Use an SSH-Proxy HTTP API:\n1) POST /connect?target=<name> → session_id (persistent, survives hours).\n2) POST /<session_id>/exec {command, timeout} → exec_id.\n3) GET /<session_id>/exec/<exec_id> → output when done.\n4) Use SFTP endpoints for file up/download instead of cat/heredoc.\nTargets are pre-registered names (e.g. \"vps1\"), not IPs — the proxy is the\nonly place where credentials live.\n","requestedLang":"en","contentMarkdown":"\n# SSH Access via Proxy\n\nAgents need to run commands on remote machines — deploy a container, read a\nlog, fix a config. Handing raw SSH private keys to an LLM is a bad idea:\nkeys end up in context windows, logs and transcripts. An SSH-Proxy solves\nthis: it owns the credentials, the agent only speaks HTTP.\n\n## The Model\n\n```\nLLM ──HTTPS──► SSH-Proxy ──SSH──► target machines\n                (owns keys,\n                 audit log,\n                 rate limits)\n```\n\nThe proxy exposes persistent sessions with a simple REST surface:\n\n| Endpoint | Purpose |\n|---|---|\n| `POST /connect?target=vps1` | open a session → `session_id` |\n| `POST /<sid>/exec` `{command, timeout}` | run a command (async) → `exec_id` |\n| `GET /<sid>/exec/<exec_id>` | poll for output |\n| SFTP endpoints | file up-/download, `ls`, `mkdir`, `rm` |\n\nAuth is a Bearer token — the same pattern as every other API you use.\n\n## The Execution Pattern\n\n```bash\n# 1. connect once, reuse the session for the whole task\nSID=$(curl -s -H \"Authorization: Bearer $KEY\" \\\n  \"https://ssh-proxy.example/connect?target=vps1\" | jq -r .session_id)\n\n# 2. run a command\nEID=$(curl -s -H \"Authorization: Bearer $KEY\" -H \"Content-Type: application/json\" \\\n  -X POST \"https://ssh-proxy.example/$SID/exec\" \\\n  -d '{\"command\": \"docker ps --format \\\"{{.Names}}\\\"\", \"timeout\": 120}' | jq -r .exec_id)\n\n# 3. poll the result\ncurl -s -H \"Authorization: Bearer $KEY\" \"https://ssh-proxy.example/$SID/exec/$EID\"\n```\n\n## Rules of Engagement\n\n1. **Reuse sessions.** Connecting per command wastes resources and trips\n   rate limits. Store the `session_id` (e.g. `/tmp/vps1_sid`) and heartbeat\n   it before reuse.\n2. **Name targets, don't discover them.** Targets are pre-registered\n   (`vps1`, `pi`, …). The proxy is the single source of truth — never hardcode\n   IP addresses in agent code.\n3. **Prefer SFTP over shell cat/heredoc for files.** Escaping binary data\n   through `exec` corrupts payloads. Use the dedicated upload/download\n   endpoints.\n4. **Quote your commands.** The JSON body is parsed twice (HTTP + shell).\n   Wrap remote commands in single quotes and keep them one-liners.\n5. **Assume auditing.** Every exec lands in the proxy's audit log — behave\n   as if the human reads everything (they can).\n\n## When NOT to use exec\n\n- Reading structured state that has an API (Docker, databases, the Synapse\n  Mind API) — always prefer the API over parsing CLI output.\n- Long-running interactive processes. `exec` is fire-and-forget with a\n  timeout; use `nohup` + a status file on the target instead.\n- Anything destructive without an explicit instruction. The proxy gives you\n  the power — the human gives you the permission.\n\n## Error Recovery\n\n| Symptom | Fix |\n|---|---|\n| 401 on connect | token expired — re-read it from memory/settings, never from a transcript |\n| session 404 | session expired (24h cap) — reconnect |\n| exec timeout | raise `timeout` or split the command; check load on the target |\n| garbled file content | you used `exec` instead of SFTP — switch to the SFTP endpoints |\n","contentHtml":"<h1>SSH Access via Proxy</h1>\n<p>Agents need to run commands on remote machines — deploy a container, read a\nlog, fix a config. Handing raw SSH private keys to an LLM is a bad idea:\nkeys end up in context windows, logs and transcripts. An SSH-Proxy solves\nthis: it owns the credentials, the agent only speaks HTTP.</p>\n<h2>The Model</h2>\n<pre><code class=\"hljs language-plaintext\">LLM ──HTTPS──► SSH-Proxy ──SSH──► target machines\n                (owns keys,\n                 audit log,\n                 rate limits)</code></pre><p>The proxy exposes persistent sessions with a simple REST surface:</p>\n<table>\n<thead>\n<tr>\n<th>Endpoint</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>POST /connect?target=vps1</code></td>\n<td>open a session → <code>session_id</code></td>\n</tr>\n<tr>\n<td><code>POST /&lt;sid&gt;/exec</code> <code>{command, timeout}</code></td>\n<td>run a command (async) → <code>exec_id</code></td>\n</tr>\n<tr>\n<td><code>GET /&lt;sid&gt;/exec/&lt;exec_id&gt;</code></td>\n<td>poll for output</td>\n</tr>\n<tr>\n<td>SFTP endpoints</td>\n<td>file up-/download, <code>ls</code>, <code>mkdir</code>, <code>rm</code></td>\n</tr>\n</tbody></table>\n<p>Auth is a Bearer token — the same pattern as every other API you use.</p>\n<h2>The Execution Pattern</h2>\n<pre><code class=\"hljs language-bash\"><span class=\"hljs-comment\"># 1. connect once, reuse the session for the whole task</span>\nSID=$(curl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> \\\n  <span class=\"hljs-string\">&quot;https://ssh-proxy.example/connect?target=vps1&quot;</span> | jq -r .session_id)\n\n<span class=\"hljs-comment\"># 2. run a command</span>\nEID=$(curl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> -H <span class=\"hljs-string\">&quot;Content-Type: application/json&quot;</span> \\\n  -X POST <span class=\"hljs-string\">&quot;https://ssh-proxy.example/<span class=\"hljs-variable\">$SID</span>/exec&quot;</span> \\\n  -d <span class=\"hljs-string\">&#x27;{&quot;command&quot;: &quot;docker ps --format \\&quot;{{.Names}}\\&quot;&quot;, &quot;timeout&quot;: 120}&#x27;</span> | jq -r .exec_id)\n\n<span class=\"hljs-comment\"># 3. poll the result</span>\ncurl -s -H <span class=\"hljs-string\">&quot;Authorization: Bearer <span class=\"hljs-variable\">$KEY</span>&quot;</span> <span class=\"hljs-string\">&quot;https://ssh-proxy.example/<span class=\"hljs-variable\">$SID</span>/exec/<span class=\"hljs-variable\">$EID</span>&quot;</span></code></pre><h2>Rules of Engagement</h2>\n<ol>\n<li><strong>Reuse sessions.</strong> Connecting per command wastes resources and trips\nrate limits. Store the <code>session_id</code> (e.g. <code>/tmp/vps1_sid</code>) and heartbeat\nit before reuse.</li>\n<li><strong>Name targets, don&#39;t discover them.</strong> Targets are pre-registered\n(<code>vps1</code>, <code>pi</code>, …). The proxy is the single source of truth — never hardcode\nIP addresses in agent code.</li>\n<li><strong>Prefer SFTP over shell cat/heredoc for files.</strong> Escaping binary data\nthrough <code>exec</code> corrupts payloads. Use the dedicated upload/download\nendpoints.</li>\n<li><strong>Quote your commands.</strong> The JSON body is parsed twice (HTTP + shell).\nWrap remote commands in single quotes and keep them one-liners.</li>\n<li><strong>Assume auditing.</strong> Every exec lands in the proxy&#39;s audit log — behave\nas if the human reads everything (they can).</li>\n</ol>\n<h2>When NOT to use exec</h2>\n<ul>\n<li>Reading structured state that has an API (Docker, databases, the Synapse\nMind API) — always prefer the API over parsing CLI output.</li>\n<li>Long-running interactive processes. <code>exec</code> is fire-and-forget with a\ntimeout; use <code>nohup</code> + a status file on the target instead.</li>\n<li>Anything destructive without an explicit instruction. The proxy gives you\nthe power — the human gives you the permission.</li>\n</ul>\n<h2>Error Recovery</h2>\n<table>\n<thead>\n<tr>\n<th>Symptom</th>\n<th>Fix</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>401 on connect</td>\n<td>token expired — re-read it from memory/settings, never from a transcript</td>\n</tr>\n<tr>\n<td>session 404</td>\n<td>session expired (24h cap) — reconnect</td>\n</tr>\n<tr>\n<td>exec timeout</td>\n<td>raise <code>timeout</code> or split the command; check load on the target</td>\n</tr>\n<tr>\n<td>garbled file content</td>\n<td>you used <code>exec</code> instead of SFTP — switch to the SFTP endpoints</td>\n</tr>\n</tbody></table>\n","translationsAvailable":["en","de"]}