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配置
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.
The server URL may change (Quick Tunnel). Use this discovery logic:
# 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
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.
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.
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.
mkdir -p ~/paperclip-remote
Write the file ~/paperclip-remote/listener.mjs using the Write tool with this EXACT content:
#!/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...`);
});
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"
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.
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"
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