| name | paperclip-remote-agent |
| description | 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้
็ฝฎ |
| allowed-tools | Bash, Read, Write, Glob, Grep |
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:
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
echo "=== Preflight Checks ==="
echo -n "Node.js: "
node --version 2>/dev/null || { echo "MISSING โ install from https://nodejs.org"; exit 1; }
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
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"
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
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
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; }
}
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--;
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
cd ~/paperclip-remote
pkill -f "listener.mjs" 2>/dev/null || true
sleep 1
PAPERCLIP_API_URL="$PAPERCLIP_API_URL" nohup node listener.mjs > listener.log 2>&1 &
echo $! > listener.pid
sleep 2
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.
cd ~/paperclip-remote
pkill -f "cloudflared.*7700" 2>/dev/null || true
sleep 1
nohup cloudflared tunnel --url http://localhost:7700 > tunnel.log 2>&1 &
echo $! > tunnel.pid
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
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}
}
}")
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
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 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 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:
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:
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
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 |