# Paperclip Remote Agent

> One-click setup for Paperclip remote agent. Installs listener, starts tunnel, registers with server. Triggers: paperclip remote setup, setup remote agent, remote agent setup, 远程agent配置

- Skill: `mguozhen/paperclip-remote-agent` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add mguozhen/paperclip-remote-agent`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mguozhen/paperclip-remote-agent/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: mguozhen (https://skillmd.com/u/mguozhen)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mguozhen/paperclip-remote-agent

---


# Paperclip Remote Agent Setup

You are setting up this machine as a remote Paperclip agent. This is a ONE-SHOT fully automated setup — run all steps without asking for confirmation unless a critical error occurs.

## Server Configuration

The server URL may change (Quick Tunnel). Use this discovery logic:

```bash
# Try fixed domain first, then fallback
for url in "https://paperclip.solvea.io" "https://paperclip.solveaagent.com"; do
  if curl -sf --connect-timeout 5 "$url/api/health" > /dev/null 2>&1; then
    PAPERCLIP_API_URL="$url"
    break
  fi
done
```

If neither works, ask the user: **"服务端 URL 是什么？问一下 Wayne。"**

Other constants:
```
PAPERCLIP_API_KEY=local-trusted
LISTENER_PORT=7700
```

## Agent Registry

Ask the user ONE question: **"你是哪个 Agent？（vocaisage / reddit2 / reddit-mguozhen / Niko / X-SolveaCX）"**

| Name | Agent ID |
|------|----------|
| vocaisage | `ea0afa73-5a0c-44cc-ad21-5a9f57ab5621` |
| reddit2 | `49c283a0-cc1f-4e81-8fe4-f9509edeed58` |
| reddit-mguozhen | `6e6ad935-e7a5-4642-864a-cc2aee81b3cf` |
| Niko | `22f4130e-f51f-47c4-ac69-ee49b612d389` |
| X-SolveaCX | `1519cbcd-bde6-497d-989d-c1c92fc6ddc7` |

If the name doesn't match, ask the user to confirm. Set `PAPERCLIP_AGENT_ID` accordingly.

After getting the agent name, run ALL remaining steps automatically.

## Step 1: Preflight Checks

```bash
echo "=== Preflight Checks ==="

# Node.js
echo -n "Node.js: "
node --version 2>/dev/null || { echo "MISSING — install from https://nodejs.org"; exit 1; }

# Claude Code — search common paths
CLAUDE_PATH=$(which claude 2>/dev/null || echo "")
if [ -z "$CLAUDE_PATH" ]; then
  for p in ~/.local/bin/claude /usr/local/bin/claude /opt/homebrew/bin/claude; do
    if [ -x "$p" ]; then CLAUDE_PATH="$p"; break; fi
  done
  # Also try nvm paths
  if [ -z "$CLAUDE_PATH" ]; then
    for p in ~/.nvm/versions/node/*/bin/claude; do
      if [ -x "$p" ]; then CLAUDE_PATH="$p"; break; fi
    done
  fi
fi
if [ -z "$CLAUDE_PATH" ]; then
  echo "Claude: MISSING"
  echo "Fix: npm install -g @anthropic-ai/claude-code && claude login"
  exit 1
fi
echo "Claude: $CLAUDE_PATH"

# cloudflared
echo -n "cloudflared: "
which cloudflared 2>/dev/null || {
  echo "MISSING"
  echo "Fix (macOS): brew install cloudflare/cloudflare/cloudflared"
  echo "Fix (Linux): https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
  exit 1
}

echo "=== All checks passed ==="
```

Save `CLAUDE_PATH` for later steps.

## Step 2: Discover Server URL

```bash
echo "=== Discovering server URL ==="
PAPERCLIP_API_URL=""
for url in "https://paperclip.solvea.io" "https://paperclip.solveaagent.com"; do
  echo -n "Trying $url ... "
  if curl -sf --connect-timeout 5 "$url/api/health" > /dev/null 2>&1; then
    PAPERCLIP_API_URL="$url"
    echo "OK"
    break
  else
    echo "unreachable"
  fi
done

if [ -z "$PAPERCLIP_API_URL" ]; then
  echo "ERROR: Cannot reach server. Ask Wayne for the current URL."
  exit 1
fi
echo "Server: $PAPERCLIP_API_URL"
```

If both fail, ask the user for the URL. Do NOT proceed without a working server URL.

## Step 3: Create Workspace & Listener

```bash
mkdir -p ~/paperclip-remote
```

Write the file `~/paperclip-remote/listener.mjs` using the Write tool with this EXACT content:

```javascript
#!/usr/bin/env node
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { existsSync, mkdirSync } from "node:fs";

const PORT = parseInt(process.env.PORT || "7700", 10);
const LOCAL_API_URL = process.env.PAPERCLIP_API_URL || "";
const MAX_STDOUT = 4 * 1024 * 1024;

function expandHome(p) {
  if (!p) return resolve(homedir(), "paperclip-remote");
  return p.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p;
}
function ts() { return new Date().toISOString(); }

let activeRuns = 0;

const server = createServer(async (req, res) => {
  if (req.method === "GET" && req.url === "/health") {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ status: "ok", activeRuns }));
    return;
  }

  if (req.method === "POST" && req.url?.startsWith("/execute")) {
    let body = "";
    for await (const chunk of req) body += chunk;

    let payload;
    try { payload = JSON.parse(body); }
    catch { res.writeHead(400); res.end('{"error":"Invalid JSON"}'); return; }

    const { runId = "unknown", prompt = "", command = "claude", args = [],
            env: remoteEnv = {}, cwd = "~/paperclip-remote", timeoutSec = 600 } = payload;

    const resolvedCwd = expandHome(cwd);
    if (!existsSync(resolvedCwd)) {
      try { mkdirSync(resolvedCwd, { recursive: true }); }
      catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return; }
    }

    // remoteEnv (from /execute payload) wins — the server-side adapter
    // dictates env per call, so we MUST NOT let LOCAL_API_URL clobber it.
    const mergedEnv = { ...process.env,
      ...(LOCAL_API_URL ? { PAPERCLIP_API_URL: LOCAL_API_URL } : {}),
      ...remoteEnv };

    console.log(`[${ts()}] > run=${runId} cmd=${command} cwd=${resolvedCwd}`);
    activeRuns++;

    const result = await new Promise((done) => {
      let stdout = "", stderr = "", timedOut = false, stdoutBytes = 0;
      const child = spawn(command, args, { cwd: resolvedCwd, env: mergedEnv, stdio: ["pipe","pipe","pipe"] });
      const timer = timeoutSec > 0 ? setTimeout(() => {
        timedOut = true; child.kill("SIGTERM");
        setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 15000);
      }, timeoutSec * 1000) : null;

      child.stdout.on("data", (c) => { const s = c.toString(); stdoutBytes += s.length; if (stdoutBytes <= MAX_STDOUT) stdout += s; });
      child.stderr.on("data", (c) => { stderr += c.toString(); if (stderr.length > 512*1024) stderr = stderr.slice(-512*1024); });
      if (prompt) { child.stdin.write(prompt); } child.stdin.end();

      child.on("close", (exitCode, signal) => { if (timer) clearTimeout(timer); done({ exitCode, signal, stdout, stderr, timedOut }); });
      child.on("error", (err) => { if (timer) clearTimeout(timer); done({ exitCode: 1, signal: null, stdout, stderr: stderr + "\n" + err.message, timedOut: false }); });
    });

    activeRuns--;
    // Strip ANSI escape codes to produce clean JSON
    const ansiRe = /\x1B\[[0-9;]*[A-Za-z]|\x1B\].*?\x07|\x1B[()][A-B012]|\x1B\[[\?]?[0-9;]*[hl]/g;
    result.stdout = result.stdout.replace(ansiRe, "");
    result.stderr = result.stderr.replace(ansiRe, "");
    console.log(`[${ts()}] < run=${runId} exit=${result.exitCode} timedOut=${result.timedOut}`);
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(result));
    return;
  }

  res.writeHead(404); res.end("Not found\n");
});

server.listen(PORT, () => {
  console.log(`Paperclip Remote Listener on port ${PORT}`);
  console.log(`API URL: ${LOCAL_API_URL || "(from server)"}`);
  console.log(`Waiting for heartbeat requests...`);
});
```

## Step 4: Start Listener

```bash
cd ~/paperclip-remote

# Kill any existing listener
pkill -f "listener.mjs" 2>/dev/null || true
sleep 1

# Start listener (pass server URL so remote claude can call back)
PAPERCLIP_API_URL="$PAPERCLIP_API_URL" nohup node listener.mjs > listener.log 2>&1 &
echo $! > listener.pid
sleep 2

# Verify
curl -s http://localhost:7700/health || { echo "ERROR: Listener failed"; cat listener.log; exit 1; }
echo " Listener OK"
```

## Step 5: Start Cloudflare Tunnel

Uses a Cloudflare **Quick Tunnel** — zero login, zero per-machine account
setup. Tradeoff: the public URL rotates on every cloudflared restart, and a
single HTTP request is capped at ~100s at the Cloudflare edge. The skill
re-registers with the Paperclip server every time it runs, so URL rotation
is handled automatically.

```bash
cd ~/paperclip-remote

# Kill any existing tunnel
pkill -f "cloudflared.*7700" 2>/dev/null || true
sleep 1

# Start tunnel
nohup cloudflared tunnel --url http://localhost:7700 > tunnel.log 2>&1 &
echo $! > tunnel.pid

# Wait for URL (retry up to 60 seconds)
TUNNEL_URL=""
for i in $(seq 1 12); do
  sleep 5
  TUNNEL_URL=$(grep -o 'https://[a-z0-9-]*\.trycloudflare\.com' tunnel.log | head -1)
  if [ -n "$TUNNEL_URL" ]; then break; fi
  echo "Waiting for tunnel... ($i/12)"
done

if [ -z "$TUNNEL_URL" ]; then
  echo "ERROR: Failed to get tunnel URL"
  cat tunnel.log
  exit 1
fi
echo "Tunnel: $TUNNEL_URL"
```

## Step 6: Register with Server

```bash
RESULT=$(curl -s -X PATCH "$PAPERCLIP_API_URL/api/agents/$PAPERCLIP_AGENT_ID" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer local-trusted" \
  -d "{
    \"adapterType\": \"claude_remote\",
    \"adapterConfig\": {
      \"url\": \"$TUNNEL_URL\",
      \"apiUrl\": \"$PAPERCLIP_API_URL\",
      \"command\": \"$CLAUDE_PATH\",
      \"cwd\": \"$HOME/paperclip-remote\",
      \"dangerouslySkipPermissions\": true
    },
    \"runtimeConfig\": {
      \"heartbeat\": {\"enabled\": true, \"intervalSec\": 60, \"wakeOnDemand\": true}
    }
  }")

# Check result
echo "$RESULT" | python3 -c "
import sys,json
try:
  d=json.load(sys.stdin)
  if 'adapterType' in d and d['adapterType'] == 'claude_remote':
    print('Registration OK')
  else:
    print('ERROR:', json.dumps(d)[:200])
except: print('ERROR: Invalid response')
"
```

## Step 7: Verify End-to-End

```bash
echo ""
echo "=== Verification ==="
echo -n "1. Listener:  "; curl -s http://localhost:7700/health | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])" 2>/dev/null || echo "FAIL"
echo -n "2. Tunnel:    "; curl -s "$TUNNEL_URL/health" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])" 2>/dev/null || echo "FAIL"
echo -n "3. Server:    "; curl -s "$PAPERCLIP_API_URL/api/agents/$PAPERCLIP_AGENT_ID" \
  -H "Authorization: Bearer local-trusted" | python3 -c "
import sys,json
d=json.load(sys.stdin)
print(f'{d[\"name\"]} | type={d[\"adapterType\"]} | heartbeat={d[\"runtimeConfig\"][\"heartbeat\"][\"enabled\"]} | url={d[\"adapterConfig\"][\"url\"][:50]}...')
" 2>/dev/null || echo "FAIL"
```

If any check fails, stop and report the error.

## Step 8: Auto-start on Reboot

Detect OS and configure:

### macOS (launchd)

Create `~/Library/LaunchAgents/com.paperclip.remote-listener.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key><string>com.paperclip.remote-listener</string>
    <key>ProgramArguments</key>
    <array>
        <string>node</string>
        <string>$HOME/paperclip-remote/listener.mjs</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$HOME/.local/bin</string>
        <key>HOME</key><string>$HOME</string>
        <key>PAPERCLIP_API_URL</key><string>$PAPERCLIP_API_URL</string>
    </dict>
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>StandardOutPath</key><string>/tmp/paperclip-listener.log</string>
    <key>StandardErrorPath</key><string>/tmp/paperclip-listener.log</string>
</dict>
</plist>
```

Create `~/Library/LaunchAgents/com.paperclip.remote-tunnel.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key><string>com.paperclip.remote-tunnel</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/cloudflared</string>
        <string>tunnel</string>
        <string>--url</string>
        <string>http://localhost:7700</string>
    </array>
    <key>RunAtLoad</key><true/>
    <key>KeepAlive</key><true/>
    <key>StandardOutPath</key><string>/tmp/paperclip-tunnel.log</string>
    <key>StandardErrorPath</key><string>/tmp/paperclip-tunnel.log</string>
</dict>
</plist>
```

Replace `$HOME` and `$PAPERCLIP_API_URL` with actual values. Load both:
```bash
launchctl load ~/Library/LaunchAgents/com.paperclip.remote-listener.plist
launchctl load ~/Library/LaunchAgents/com.paperclip.remote-tunnel.plist
```

### Linux (systemd)
Create two services with `Restart=always`, enable with `systemctl enable --now`.

**NOTE**: Quick Tunnel URL changes on reboot. After reboot, re-run this skill to re-register the new URL.

## Step 9: Write Agent Heartbeat SOP

When the Paperclip server invokes `claude` on this machine, Claude starts in
`~/paperclip-remote/` and reads the `CLAUDE.md` at that path. Without a SOP
there, remote Claude has no idea it's a Paperclip agent — it'll treat the
heartbeat as a generic prompt and drift.

Write the SOP file so every heartbeat follows the same checkout → execute →
report flow:

```bash
cat > ~/paperclip-remote/CLAUDE.md <<EOF
## CRITICAL: Paperclip Heartbeat

You are "${AGENT_NAME}", a remote agent connected to Paperclip.
Agent ID: ${PAPERCLIP_AGENT_ID}
Server: ${PAPERCLIP_API_URL}

### On Wake:
1. If env var PAPERCLIP_TASK_ID is set → that IS your task, go to Execute Task.
2. Otherwise check inbox:
   GET \$PAPERCLIP_API_URL/api/agents/me/inbox-lite
   Authorization: Bearer \$PAPERCLIP_API_KEY
   (If empty, exit cleanly.)

### Execute Task:
1. Checkout:  POST \$PAPERCLIP_API_URL/api/issues/{issueId}/checkout
   Body: {"agentId":"\$PAPERCLIP_AGENT_ID","expectedStatuses":["todo","backlog","in_progress"]}
2. Read:      GET  \$PAPERCLIP_API_URL/api/issues/{issueId}/heartbeat-context
3. Do the work.
4. Report:    PATCH \$PAPERCLIP_API_URL/api/issues/{issueId}
   Body: {"status":"done","comment":"what you did"}

### If blocked:
   PATCH \$PAPERCLIP_API_URL/api/issues/{issueId}
   Body: {"status":"blocked","comment":"reason"}
EOF
echo "Wrote agent SOP to ~/paperclip-remote/CLAUDE.md"
```

The `\$` escapes keep the `PAPERCLIP_*` env-var references literal in the
written file — they're resolved at runtime by remote Claude, not at install
time. `${AGENT_NAME}`, `${PAPERCLIP_AGENT_ID}`, `${PAPERCLIP_API_URL}` are
expanded now so the written SOP is self-identifying.

## Step 10: Save Config

```bash
cat > ~/paperclip-remote/config.json << EOF
{
  "agentName": "$AGENT_NAME",
  "agentId": "$PAPERCLIP_AGENT_ID",
  "apiUrl": "$PAPERCLIP_API_URL",
  "tunnelUrl": "$TUNNEL_URL",
  "claudePath": "$CLAUDE_PATH",
  "listenerPort": 7700,
  "setupAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
```

## Final Output

Print this summary:

```
╔═══════════════════════════════════════════════════╗
║  Paperclip Remote Agent — Setup Complete          ║
╠═══════════════════════════════════════════════════╣
║  Agent:       $AGENT_NAME                         ║
║  Agent ID:    $PAPERCLIP_AGENT_ID                 ║
║  Listener:    http://localhost:7700               ║
║  Tunnel:      $TUNNEL_URL                         ║
║  Claude:      $CLAUDE_PATH                        ║
║  Server:      $PAPERCLIP_API_URL                  ║
║  Heartbeat:   enabled (60s)                       ║
║  Auto-start:  configured                          ║
╚═══════════════════════════════════════════════════╝

Next heartbeat arrives within 60 seconds.
Logs: tail -f ~/paperclip-remote/listener.log
```

## Troubleshooting

| Problem | Fix |
|---------|-----|
| `claude not found` | `npm install -g @anthropic-ai/claude-code && claude login` |
| `cloudflared not found` | macOS: `brew install cloudflare/cloudflare/cloudflared` |
| `tunnel URL empty` | `cat ~/paperclip-remote/tunnel.log` |
| `server unreachable` | Ask Wayne for current server URL |
| `EADDRINUSE :7700` | `lsof -i :7700` then kill that process |
| `spawn claude ENOENT` | Claude path wrong — `which claude` and re-register |
| `reboot后URL变了` | 重新运行此 skill 即可（会自动重新注册） |
| `heartbeat exit 0 with empty stdout` | `adapterConfig.env` on the SERVER must NOT set `HOME` — clear it via PATCH |

