{
  "manifest_version": "1.0.0",
  "template": {
    "id": "dae7b86e-7cdb-4d30-a4c3-1342684fb4e6",
    "slug": "paperclip-official-1",
    "name": "Paperclip (Official Image)",
    "description": "Run AI agent teams (Claude Code, Codex, Gemini) with budgets and approvals",
    "url": "https://railway.com/deploy/paperclip-official-1",
    "upstream": {
      "image": "ghcr.io/paperclipai/paperclip:2026.916.1"
    }
  },
  "services": [
    {
      "name": "Postgres",
      "source": {
        "image": "ghcr.io/railwayapp-templates/postgres-ssl:17"
      },
      "needs_volume": true,
      "volume_mount_path": "/var/lib/postgresql/data",
      "http": false
    },
    {
      "name": "Paperclip",
      "source": {
        "image": "ghcr.io/paperclipai/paperclip:2026.916.1"
      },
      "needs_volume": true,
      "volume_mount_path": "/paperclip",
      "http": true
    }
  ],
  "required_inputs": [
    {
      "key": "PGDATA",
      "service": "Postgres",
      "description": "Provide a value for PGDATA.",
      "secret": false,
      "strategy": "default",
      "default": "/var/lib/postgresql/data/pgdata"
    },
    {
      "key": "PGHOST",
      "service": "Postgres",
      "description": "Provide a value for PGHOST.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "PGPORT",
      "service": "Postgres",
      "description": "Provide a value for PGPORT.",
      "secret": false,
      "strategy": "default",
      "default": "5432"
    },
    {
      "key": "PGUSER",
      "service": "Postgres",
      "description": "Provide a value for PGUSER.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "PGDATABASE",
      "service": "Postgres",
      "description": "Provide a value for PGDATABASE.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "PGPASSWORD",
      "service": "Postgres",
      "description": "Provide a value for PGPASSWORD.",
      "secret": true,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "POSTGRES_DB",
      "service": "Postgres",
      "description": "Provide a value for POSTGRES_DB.",
      "secret": false,
      "strategy": "default",
      "default": "railway"
    },
    {
      "key": "DATABASE_URL",
      "service": "Postgres",
      "description": "Provide a value for DATABASE_URL.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_private_domain"
    },
    {
      "key": "POSTGRES_USER",
      "service": "Postgres",
      "description": "Provide a value for POSTGRES_USER.",
      "secret": false,
      "strategy": "default",
      "default": "postgres"
    },
    {
      "key": "SSL_CERT_DAYS",
      "service": "Postgres",
      "description": "Provide a value for SSL_CERT_DAYS.",
      "secret": false,
      "strategy": "default",
      "default": "820"
    },
    {
      "key": "POSTGRES_PASSWORD",
      "service": "Postgres",
      "description": "Provide a value for POSTGRES_PASSWORD.",
      "secret": true,
      "strategy": "generate",
      "generate": "strong_password"
    },
    {
      "key": "RAILWAY_DEPLOYMENT_DRAINING_SECONDS",
      "service": "Postgres",
      "description": "Provide a value for RAILWAY_DEPLOYMENT_DRAINING_SECONDS.",
      "secret": false,
      "strategy": "default",
      "default": "60"
    },
    {
      "key": "PORT",
      "service": "Paperclip",
      "description": "Provide a value for PORT.",
      "secret": false,
      "strategy": "default",
      "default": "3101"
    },
    {
      "key": "CONNECT_PORT",
      "service": "Paperclip",
      "description": "Provide a value for CONNECT_PORT.",
      "secret": false,
      "strategy": "default",
      "default": "3100"
    },
    {
      "key": "DATABASE_URL",
      "service": "Paperclip",
      "description": "Provide a value for DATABASE_URL.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "reference_variable"
    },
    {
      "key": "CONNECT_HELPER_JS",
      "service": "Paperclip",
      "description": "Provide a value for CONNECT_HELPER_JS.",
      "secret": false,
      "strategy": "default",
      "default": "// Paperclip /connect helper. Runs inside the official image, in front of Paperclip.\n//\n// Paperclip's \"Connect a model\" subscription flow creates an empty login folder under\n// <instance>/ai-local-logins/<id>/ and polls it, expecting someone to run a CLI login on\n// the server. On Railway nobody has a terminal there, so this helper does it:\n//   Claude: writes the admin's saved `claude setup-token` token as .credentials.json\n//   Codex:  runs `codex login --device-auth` and shows the link + code on /connect\n// Everything that is not /connect is proxied untouched (HTTP + websockets) to Paperclip.\nimport http from \"node:http\";\nimport net from \"node:net\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { spawn } from \"node:child_process\";\n\nconst LISTEN = Number(process.env.CONNECT_PORT || 3100);\nconst UPSTREAM = Number(process.env.PORT || 3101);\nconst HOME = process.env.PAPERCLIP_HOME || \"/paperclip\";\nconst LOGINS = path.join(HOME, \"instances\", process.env.PAPERCLIP_INSTANCE_ID || \"default\", \"ai-local-logins\");\nconst TOKEN_FILE = path.join(HOME, \"connect\", \"claude-token\");\nconst CODEX_URL = \"https://auth.openai.com/codex/device\";\nconst CODEX_TTL_MS = 15 * 60 * 1000;\nconst log = (...a) => console.log(\"[connect]\", ...a);\n\n// Preload for Paperclip's own process (written by `connect.mjs --write-patch <file>`, loaded with\n// node --import). Paperclip verifies a Claude subscription by calling Anthropic's usage endpoint.\n// For `claude setup-token` tokens that endpoint is no gate: they are inference-only, so it answers\n// 403 (no profile scope), and it rate limits hard (429 seen after a handful of calls). Only 401\n// means a bad token. For setup tokens only, any other failure becomes \"no usage data\" so Paperclip\n// accepts the token; every other request and status passes through untouched.\nconst USAGE_PATCH = `const f = globalThis.fetch;\nglobalThis.fetch = async (input, init) => {\n  const res = await f(input, init);\n  try {\n    const url = typeof input === \"string\" ? input : input.url || String(input);\n    const auth = new Headers((init && init.headers) || (input && input.headers) || {}).get(\"authorization\") || \"\";\n    if (!res.ok && res.status !== 401 && url.startsWith(\"https://api.anthropic.com/api/oauth/usage\") && auth.startsWith(\"Bearer sk-ant-oat\"))\n      return new Response(\"{}\", { status: 200, headers: { \"content-type\": \"application/json\" } });\n  } catch {}\n  return res;\n};\n`;\n// Launchers for Paperclip's agents. Its bundled Claude Agent SDK and Codex binaries lag new\n// models (Opus 5.5 needs Claude Code >=2.1.280; bundled 2.1.263) and its env allowlist drops\n// CLAUDE_CODE_EXECUTABLE / CODEX_PATH, so the boot script symlinks the bundled binaries to these.\n// Each prefers the copy kept updated on the volume, else the image's global CLI.\nconst launcher = (name) => `#!/bin/sh\n[ -x /paperclip/.cli/bin/${name} ] && exec /paperclip/.cli/bin/${name} \"$@\"\nexec /usr/local/bin/${name} \"$@\"\n`;\nif (process.argv[2] === \"--write-patch\") {\n  fs.writeFileSync(process.argv[3], USAGE_PATCH);\n  for (const name of [\"claude\", \"codex\"]) fs.writeFileSync(`/tmp/${name}-launcher`, launcher(name), { mode: 0o755 });\n  process.exit(0);\n}\n\n// ---- login folder watcher -------------------------------------------------------------\nconst codex = new Map(); // dir id -> { code, status, startedAt, child }\nlet claudeFilled = null; // last time a Claude login folder was filled\n\nconst readToken = () => { try { return fs.readFileSync(TOKEN_FILE, \"utf8\").trim(); } catch { return \"\"; } };\nconst exists = (p) => fs.existsSync(p);\n\nfunction startCodex(id) {\n  const dir = path.join(LOGINS, id);\n  const s = { code: null, status: \"starting\", startedAt: Date.now(), child: null };\n  codex.set(id, s);\n  // `script` gives codex a pseudo-terminal; it prints the device code only when it has one.\n  const cmd = `codex -c 'cli_auth_credentials_store=\"file\"' login --device-auth`;\n  const child = spawn(\"script\", [\"-qfec\", cmd, \"/dev/null\"], { env: { ...process.env, CODEX_HOME: dir } });\n  s.child = child;\n  let out = \"\";\n  const onData = (buf) => {\n    out = (out + buf.toString()).replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\").slice(-4000);\n    const m = out.match(/\\b([A-Z0-9]{4}-[A-Z0-9]{5})\\b/);\n    if (m && !s.code) { s.code = m[1]; s.status = \"waiting\"; log(`Codex sign-in ${id}: code ${s.code}`); }\n  };\n  child.stdout.on(\"data\", onData);\n  child.stderr.on(\"data\", onData);\n  const timer = setTimeout(() => child.kill(\"SIGTERM\"), CODEX_TTL_MS);\n  child.on(\"exit\", (code) => {\n    clearTimeout(timer);\n    s.status = code === 0 && exists(path.join(dir, \"auth.json\")) ? \"connected\" : (s.status === \"cancelled\" ? \"cancelled\" : \"failed\");\n    log(`Codex sign-in ${id}: ${s.status}`);\n  });\n}\n\nfunction scan() {\n  let ids = [];\n  try { ids = fs.readdirSync(LOGINS); } catch { return; }\n  for (const id of ids) {\n    const dir = path.join(LOGINS, id);\n    if (exists(path.join(dir, \"config.toml\"))) { // Paperclip writes this only for OpenAI/Codex\n      if (!codex.has(id) && !exists(path.join(dir, \"auth.json\"))) startCodex(id);\n    } else if (!exists(path.join(dir, \".credentials.json\"))) {\n      const token = readToken();\n      if (!token) continue;\n      fs.writeFileSync(path.join(dir, \".credentials.json\"),\n        JSON.stringify({ claudeAiOauth: { accessToken: token } }), { mode: 0o600 });\n      claudeFilled = Date.now();\n      log(`Claude sign-in ${id}: token provided`);\n    }\n  }\n  for (const [id, s] of codex) { // Paperclip deletes the folder on connect or cancel\n    if (!ids.includes(id)) {\n      if (s.child && s.child.exitCode === null) { s.status = \"cancelled\"; s.child.kill(\"SIGTERM\"); }\n      if (Date.now() - s.startedAt > CODEX_TTL_MS) codex.delete(id);\n    }\n  }\n}\nsetInterval(scan, 2000);\n\n// ---- /connect page -------------------------------------------------------------------\nasync function admin(req) {\n  const r = await fetch(`http://127.0.0.1:${UPSTREAM}/api/cli-auth/me`, {\n    headers: { cookie: req.headers.cookie || \"\", host: req.headers.host || \"localhost\" },\n  }).catch(() => null);\n  if (!r || !r.ok) return null;\n  const me = await r.json().catch(() => null);\n  return me && me.userId ? me : null;\n}\n\nfunction state() {\n  const active = [...codex.entries()].sort((a, b) => b[1].startedAt - a[1].startedAt)[0];\n  return {\n    claude: { tokenSaved: Boolean(readToken()), lastProvided: claudeFilled },\n    codex: active ? { id: active[0], url: CODEX_URL, code: active[1].code, status: active[1].status,\n                      expiresAt: active[1].startedAt + CODEX_TTL_MS } : null,\n  };\n}\n\nconst body = (req) => new Promise((ok) => { let b = \"\"; req.on(\"data\", (c) => { b += c; if (b.length > 1e5) req.destroy(); }); req.on(\"end\", () => ok(b)); });\nconst send = (res, code, type, data) => { res.writeHead(code, { \"content-type\": type, \"cache-control\": \"no-store\" }); res.end(data); };\nconst json = (res, code, data) => send(res, code, \"application/json\", JSON.stringify(data));\n\nasync function saveClaude(token) {\n  token = token.trim();\n  if (!token) { fs.rmSync(TOKEN_FILE, { force: true }); return { ok: true, removed: true }; }\n  if (!/^sk-ant-oat\\d*-/.test(token)) return { ok: false, error: \"That does not look like a setup token. Run claude setup-token and paste the sk-ant-oat... value.\" };\n  const r = await fetch(\"https://api.anthropic.com/api/oauth/usage\", {\n    headers: { authorization: `Bearer ${token}`, \"anthropic-beta\": \"oauth-2025-04-20\" },\n    signal: AbortSignal.timeout(15000),\n  }).catch(() => null);\n  // Only 401 means a bad token; 403 (no profile scope) and 429/5xx are expected (see USAGE_PATCH).\n  if (r && r.status === 401) return { ok: false, error: \"Anthropic rejected the token. Run claude setup-token again and paste the new one.\" };\n  fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true, mode: 0o700 });\n  fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });\n  return { ok: true };\n}\n\nasync function handleConnect(req, res) {\n  if (req.url.split(\"?\")[0] === \"/connect/inline.js\") return send(res, 200, \"text/javascript\", INLINE); // no secrets; its API calls are gated\n  const me = await admin(req);\n  if (!me) { res.writeHead(302, { location: \"/auth?next=/connect\" }); return res.end(); }\n  if (!me.isInstanceAdmin) return send(res, 403, \"text/plain\", \"Only the Paperclip instance admin can connect models.\");\n  const url = req.url.split(\"?\")[0];\n  if (req.method === \"GET\" && url === \"/connect\") return send(res, 200, \"text/html; charset=utf-8\", PAGE);\n  if (req.method === \"GET\" && url === \"/connect/state\") return json(res, 200, state());\n  if (req.method === \"POST\") {\n    const origin = req.headers.origin || \"\";\n    if (!origin || new URL(origin).host !== req.headers.host) return json(res, 403, { ok: false, error: \"Bad origin\" });\n    if (url === \"/connect/claude\") {\n      const { token = \"\" } = JSON.parse((await body(req)) || \"{}\");\n      return json(res, 200, await saveClaude(String(token)));\n    }\n  }\n  send(res, 404, \"text/plain\", \"Not found\");\n}\n\nconst PAGE = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Connect a model · Paperclip</title><style>\nbody{font:15px/1.5 ui-sans-serif,-apple-system,sans-serif;background:#141413;color:#fff;max-width:620px;margin:48px auto;padding:0 20px}\nh1{font-size:26px;margin:0 0 4px}p.sub{color:#9a958a;margin:0 0 28px}section{border:1px solid #2f2c28;border-radius:14px;padding:20px;margin:0 0 18px;background:#1f1d1a}\nh2{font-size:17px;margin:0 0 10px}ol{padding-left:20px;margin:8px 0}code,.code{font-family:ui-monospace,Menlo,monospace}\n.code{font-size:30px;letter-spacing:3px;margin:10px 0;display:block}input{width:100%;box-sizing:border-box;background:#141413;color:#fff;border:1px solid #3a3836;border-radius:8px;padding:10px;font:14px ui-monospace,monospace}\nbutton{margin-top:10px;background:#fff;color:#141413;border:0;border-radius:999px;padding:8px 18px;font-weight:600;cursor:pointer}\n.muted{color:#9a958a}.ok{color:#22c55e}.err{color:#f87171}a{color:#93c5fd}</style></head><body>\n<h1>Connect a model</h1><p class=\"sub\">For Paperclip's subscription sign-in on Railway. Keep this tab open next to Paperclip's <b>Connect a model</b> step.</p>\n<section><h2>Claude subscription</h2>\n<ol><li>On your own computer run <code>claude setup-token</code> and copy the <code>sk-ant-oat…</code> token.</li>\n<li>Paste it here and save. It is kept on your Paperclip volume only.</li>\n<li>In Paperclip, choose <b>Claude · Subscription</b> and click <b>Connect</b>.</li></ol>\n<input id=\"tok\" type=\"password\" placeholder=\"sk-ant-oat01-...\" autocomplete=\"off\"><button id=\"save\">Save token</button>\n<p id=\"cst\" class=\"muted\"></p></section>\n<section><h2>Codex (ChatGPT subscription)</h2>\n<p class=\"muted\" id=\"xhint\">In Paperclip, choose <b>Codex · Subscription</b>. A one-time code appears here within a few seconds.</p>\n<div id=\"xbox\" hidden><ol><li>Open <a id=\"xurl\" target=\"_blank\" rel=\"noopener\"></a> and sign in to ChatGPT.</li><li>Enter this code:</li></ol>\n<span class=\"code\" id=\"xcode\"></span><p id=\"xst\" class=\"muted\"></p></div></section>\n<script>\nconst $=id=>document.getElementById(id);\nasync function refresh(){const s=await (await fetch('/connect/state')).json();\n$('cst').className=s.claude.tokenSaved?'ok':'muted';\n$('cst').textContent=s.claude.tokenSaved?'Token saved.'+(s.claude.lastProvided?' Last handed to Paperclip '+new Date(s.claude.lastProvided).toLocaleTimeString()+'.':' Click Connect in Paperclip.'):'No token saved yet.';\nconst x=s.codex;$('xbox').hidden=!x||!x.code;\nif(x){$('xurl').href=$('xurl').textContent=x.url;$('xcode').textContent=x.code||'';\nconst left=Math.max(0,Math.round((x.expiresAt-Date.now())/1000));\nconst msg={starting:'Starting sign-in…',waiting:'Waiting for you to approve ('+Math.floor(left/60)+':'+String(left%60).padStart(2,'0')+' left).',connected:'Signed in. Paperclip picks it up automatically.',failed:'Sign-in failed or expired. Click the Codex tile in Paperclip again to retry.',cancelled:'Cancelled in Paperclip.'}[x.status];\n$('xst').textContent=msg;$('xst').className=x.status==='connected'?'ok':x.status==='failed'?'err':'muted';$('xhint').hidden=!!x.code;}}\n$('save').onclick=async()=>{$('cst').textContent='Checking token with Anthropic…';\nconst r=await (await fetch('/connect/claude',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:$('tok').value})})).json();\nif(!r.ok){$('cst').className='err';$('cst').textContent=r.error;return}$('tok').value='';refresh()};\nrefresh();setInterval(refresh,2000);\n</script></body></html>`;\n\n// Injected into Paperclip's own pages: on the \"Connect a model\" step it replaces the\n// \"run this in a terminal\" block with the token box (Claude) or the live device code (Codex).\n// Anchored on the sign-in command's login folder path; if Paperclip's markup changes, it\n// simply finds nothing and /connect keeps working. Uses CSS order, never moves React nodes.\nconst INLINE = `(() => {\nconst BOX = \"margin:0 0 4px;padding:18px;border:1px solid rgba(127,127,127,.3);border-radius:12px;color:var(--foreground,inherit);font-size:14px;line-height:1.5;text-align:center\";\nconst BTN = \"display:block;width:100%;margin-top:14px;padding:12px 16px;border-radius:999px;border:0;background:var(--foreground,#fff);color:var(--background,#141413);font-weight:600;font-size:15px;cursor:pointer;text-decoration:none\";\nconst MUTED = \"opacity:.65;margin-top:10px\";\nlet state = null, busy = false;\nasync function poll(){ try { const r = await fetch(\"/connect/state\",{cache:\"no-store\"}); state = r.ok ? await r.json() : null; } catch { state = null; } render(); }\nfunction render(){\n  for (const code of document.querySelectorAll(\"pre code\")) {\n    const cmd = code.textContent || \"\"; const m = cmd.match(/ai-local-logins.([0-9a-f-]{36})/); if (!m) continue;\n    const kind = cmd.includes(\"CODEX_HOME\") ? \"codex\" : cmd.includes(\"CLAUDE_CONFIG_DIR\") ? \"claude\" : null; if (!kind || !state) continue;\n    const box = code.closest(\"div.rounded-md\"), wrap = box && box.parentElement; if (!wrap) continue;\n    wrap.style.display = \"flex\"; wrap.style.flexDirection = \"column\";\n    box.style.display = \"none\";\n    for (const el of wrap.querySelectorAll(\":scope > p\")) if (/Run this in a terminal|on the machine running Paperclip|Connect uses your local|Run the sign-in command/.test(el.textContent)) el.style.display = \"none\";\n    let panel = wrap.querySelector(\":scope > .pc-connect\");\n    if (!panel) { panel = document.createElement(\"div\"); panel.className = \"pc-connect\"; panel.style.cssText = BOX; panel.style.order = \"-1\"; wrap.appendChild(panel); }\n    const html = kind === \"claude\" ? claude() : codex(m[1]);\n    if (panel.dataset.html !== html) { panel.dataset.html = html; panel.innerHTML = html; wire(panel); }\n  }\n}\nfunction claude(){\n  if (state.claude.tokenSaved) return \"<div style='font-weight:600;font-size:15px'>Claude subscription token saved</div><div style='\" + MUTED + \"'>Click Connect to use it. <a href='/connect' target='_blank' style='text-decoration:underline'>Change token</a></div>\";\n  return \"<div style='font-weight:600;font-size:15px'>Sign in with your Claude subscription</div>\" +\n    \"<div style='\" + MUTED + \";margin-top:4px'>Run <code>claude setup-token</code> on your computer and paste the token below.</div>\" +\n    \"<input class='pc-tok' type='password' placeholder='sk-ant-oat01-...' autocomplete='off' style='display:block;width:100%;box-sizing:border-box;margin-top:14px;padding:11px 12px;border-radius:10px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font-family:ui-monospace,monospace;text-align:center'>\" +\n    \"<button class='pc-save' type='button' style='\" + BTN + \"'>Save token</button><div class='pc-msg' style='margin-top:8px;color:#f87171'></div>\";\n}\nfunction codex(id){\n  const x = state.codex && state.codex.id === id ? state.codex : null;\n  if (!x || !x.code) return \"<div style='\" + MUTED + \";margin:0'>Preparing your ChatGPT sign-in code...</div>\";\n  if (x.status === \"connected\") return \"<div style='font-weight:600;font-size:15px'>Signed in to ChatGPT</div><div style='\" + MUTED + \"'>Click Connect to finish.</div>\";\n  if (x.status === \"failed\") return \"<div style='font-weight:600;font-size:15px'>This code expired</div><div style='\" + MUTED + \"'>Click Start sign-in again below for a new one.</div>\";\n  return \"<div style='font-weight:600;font-size:15px'>Sign in with ChatGPT</div><div style='\" + MUTED + \";margin-top:4px'>Enter this code on the ChatGPT sign-in page</div>\" +\n    \"<div class='pc-code' title='Click to copy' style='font:700 38px/1.1 ui-monospace,Menlo,monospace;letter-spacing:5px;margin:16px 0 4px;cursor:pointer;user-select:all'>\" + x.code + \"</div>\" +\n    \"<a class='pc-open' href='\" + x.url + \"' target='_blank' rel='noopener' data-code='\" + x.code + \"' style='\" + BTN + \"'>Copy code & open ChatGPT</a>\" +\n    \"<div style='\" + MUTED + \"'>Waiting for approval, then click Connect</div>\";\n}\nfunction copy(t){ try { navigator.clipboard.writeText(t); } catch {} }\nfunction wire(panel){\n  const open = panel.querySelector(\".pc-open\"); if (open) open.onclick = () => copy(open.dataset.code);\n  const code = panel.querySelector(\".pc-code\"); if (code) code.onclick = () => { copy(code.textContent); code.style.opacity = \".5\"; setTimeout(() => code.style.opacity = \"1\", 300); };\n  const b = panel.querySelector(\".pc-save\"); if (!b) return;\n  b.onclick = async () => { if (busy) return; busy = true; const msg = panel.querySelector(\".pc-msg\"); msg.style.color = \"inherit\"; msg.textContent = \"Checking token with Anthropic...\";\n    try { const r = await (await fetch(\"/connect/claude\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({token:panel.querySelector(\".pc-tok\").value})})).json();\n      if (!r.ok) { msg.style.color = \"#f87171\"; msg.textContent = r.error; } else { await poll(); } } finally { busy = false; } };\n}\nnew MutationObserver(() => { if (!busy) render(); }).observe(document.documentElement, { childList: true, subtree: true });\npoll(); setInterval(poll, 2000);\n})();`;\n\n// ---- proxy -----------------------------------------------------------------------------\nconst server = http.createServer((req, res) => {\n  if (req.url === \"/connect\" || req.url.startsWith(\"/connect/\") || req.url.startsWith(\"/connect?\")) {\n    return handleConnect(req, res).catch((e) => { log(e); send(res, 500, \"text/plain\", \"connect helper error\"); });\n  }\n  const page = req.method === \"GET\" && (req.headers.accept || \"\").includes(\"text/html\");\n  const headers = { ...req.headers };\n  if (page) delete headers[\"accept-encoding\"]; // uncompressed HTML so the script tag can be added\n  const up = http.request({ host: \"127.0.0.1\", port: UPSTREAM, method: req.method, path: req.url, headers }, (r) => {\n    if (!page || !(r.headers[\"content-type\"] || \"\").startsWith(\"text/html\")) {\n      res.writeHead(r.statusCode, r.rawHeaders);\n      return r.pipe(res);\n    }\n    const chunks = [];\n    r.on(\"data\", (c) => chunks.push(c));\n    r.on(\"end\", () => {\n      const html = Buffer.concat(chunks).toString(\"utf8\").replace(\"</body>\", '<script src=\"/connect/inline.js\" defer></script></body>');\n      const h = { ...r.headers, \"content-length\": Buffer.byteLength(html) };\n      delete h[\"transfer-encoding\"];\n      res.writeHead(r.statusCode, h);\n      res.end(html);\n    });\n  });\n  up.on(\"error\", () => { if (!res.headersSent) send(res, 502, \"text/plain\", \"Paperclip is starting…\"); else res.destroy(); });\n  req.pipe(up);\n});\nserver.on(\"upgrade\", (req, socket, head) => {\n  const up = net.connect(UPSTREAM, \"127.0.0.1\", () => {\n    let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\\r\\n`;\n    for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\\r\\n`;\n    up.write(raw + \"\\r\\n\");\n    if (head && head.length) up.write(head);\n    up.pipe(socket).pipe(up);\n  });\n  up.on(\"error\", () => socket.destroy());\n  socket.on(\"error\", () => up.destroy());\n});\nserver.listen(LISTEN, \"::\", () => log(`listening on ${LISTEN}, proxying to ${UPSTREAM}, watching ${LOGINS}`));\n"
    },
    {
      "key": "BETTER_AUTH_SECRET",
      "service": "Paperclip",
      "description": "Provide a value for BETTER_AUTH_SECRET.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "PAPERCLIP_PUBLIC_URL",
      "service": "Paperclip",
      "description": "Canonical URL used for auth callbacks and invite links. Change it if you add a custom domain.",
      "secret": false,
      "strategy": "railway_provided",
      "railway_source": "railway_domain"
    },
    {
      "key": "PAPERCLIP_DEPLOYMENT_MODE",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_DEPLOYMENT_MODE.",
      "secret": false,
      "strategy": "default",
      "default": "authenticated"
    },
    {
      "key": "PAPERCLIP_AGENT_JWT_SECRET",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_AGENT_JWT_SECRET.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    },
    {
      "key": "PAPERCLIP_ALLOWED_HOSTNAMES",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_ALLOWED_HOSTNAMES.",
      "secret": false,
      "strategy": "default",
      "default": "healthcheck.railway.app"
    },
    {
      "key": "PAPERCLIP_DEPLOYMENT_EXPOSURE",
      "service": "Paperclip",
      "description": "private: sign-in required; the first signed-in user claims admin from the browser. Claim right after deploy.",
      "secret": false,
      "strategy": "default",
      "default": "private"
    },
    {
      "key": "PAPERCLIP_MIGRATION_AUTO_APPLY",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_MIGRATION_AUTO_APPLY.",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "PAPERCLIP_AUTH_RATE_LIMIT_ENABLED",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_AUTH_RATE_LIMIT_ENABLED.",
      "secret": false,
      "strategy": "default",
      "default": "true"
    },
    {
      "key": "PAPERCLIP_TOOL_ACTION_SIGNING_SECRET",
      "service": "Paperclip",
      "description": "Provide a value for PAPERCLIP_TOOL_ACTION_SIGNING_SECRET.",
      "secret": true,
      "strategy": "generate",
      "generate": "random_base64_32"
    }
  ],
  "deploy": {
    "mcp": {
      "server": "railway",
      "tool": "deploy_template",
      "args": {
        "template_code": "paperclip-official-1"
      }
    },
    "cli": "railway deploy --template paperclip-official-1",
    "api": {
      "method": "POST",
      "path": "/graphql/v2",
      "body": {
        "query": "mutation templateDeploy($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }",
        "variables": {
          "input": {
            "templateId": "dae7b86e-7cdb-4d30-a4c3-1342684fb4e6",
            "serializedConfig": {
              "buckets": {},
              "services": {
                "216cd53b-7b2a-45b2-a914-de7d5a890ebb": {
                  "icon": "https://devicons.railway.app/i/postgresql.svg",
                  "name": "Postgres",
                  "deploy": {
                    "startCommand": null,
                    "healthcheckPath": null,
                    "restartPolicyType": "ON_FAILURE",
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "ghcr.io/railwayapp-templates/postgres-ssl:17"
                  },
                  "variables": {
                    "PGDATA": {
                      "isOptional": false,
                      "defaultValue": "/var/lib/postgresql/data/pgdata"
                    },
                    "PGHOST": {
                      "isOptional": false,
                      "defaultValue": "${{RAILWAY_PRIVATE_DOMAIN}}"
                    },
                    "PGPORT": {
                      "isOptional": false,
                      "defaultValue": "5432"
                    },
                    "PGUSER": {
                      "isOptional": false,
                      "defaultValue": "${{POSTGRES_USER}}"
                    },
                    "PGDATABASE": {
                      "isOptional": false,
                      "defaultValue": "${{POSTGRES_DB}}"
                    },
                    "PGPASSWORD": {
                      "isOptional": false,
                      "defaultValue": "${{POSTGRES_PASSWORD}}"
                    },
                    "POSTGRES_DB": {
                      "isOptional": false,
                      "defaultValue": "railway"
                    },
                    "DATABASE_URL": {
                      "isOptional": false,
                      "defaultValue": "postgresql://${{PGUSER}}:${{POSTGRES_PASSWORD}}@${{RAILWAY_PRIVATE_DOMAIN}}:5432/${{PGDATABASE}}"
                    },
                    "POSTGRES_USER": {
                      "isOptional": false,
                      "defaultValue": "postgres"
                    },
                    "SSL_CERT_DAYS": {
                      "isOptional": false,
                      "defaultValue": "820"
                    },
                    "POSTGRES_PASSWORD": {
                      "isOptional": false,
                      "defaultValue": "{{POSTGRES_PASSWORD}}"
                    },
                    "RAILWAY_DEPLOYMENT_DRAINING_SECONDS": {
                      "isOptional": false,
                      "defaultValue": "60"
                    }
                  },
                  "volumeMounts": {
                    "216cd53b-7b2a-45b2-a914-de7d5a890ebb": {
                      "mountPath": "/var/lib/postgresql/data"
                    }
                  }
                },
                "9408401a-aa4c-46e2-a7ee-9c90e5473e4f": {
                  "icon": "https://raw.githubusercontent.com/paperclipai/paperclip/v2026.916.1/ui/public/android-chrome-512x512.png",
                  "name": "Paperclip",
                  "deploy": {
                    "startCommand": "/usr/bin/tini -- docker-entrypoint.sh /bin/sh -c \"printenv CONNECT_HELPER_JS > /tmp/connect.mjs && node /tmp/connect.mjs --write-patch /tmp/usage-patch.mjs; (while true; do node /tmp/connect.mjs; sleep 2; done) & (npm i -g --prefix /paperclip/.cli @anthropic-ai/claude-code@latest @openai/codex@latest > /tmp/cli-update.log 2>&1 && echo [cli] updated: $(/paperclip/.cli/bin/claude --version), $(/paperclip/.cli/bin/codex --version) || echo [cli] update failed, see /tmp/cli-update.log) & for f in /app/node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-linux-*/node_modules/@anthropic-ai/claude-agent-sdk-linux-*/claude; do ln -sf /tmp/claude-launcher $f; done; for f in /app/node_modules/.pnpm/@openai+codex@*-linux-*/node_modules/@openai/codex/vendor/*/bin/codex; do ln -sf /tmp/codex-launcher $f; done; export PATH=/paperclip/.cli/bin:$PATH; exec node --import /tmp/usage-patch.mjs --import ./server/node_modules/tsx/dist/loader.mjs server/dist/index.js\"",
                    "healthcheckPath": "/api/health",
                    "restartPolicyType": "ON_FAILURE",
                    "healthcheckTimeout": 300,
                    "restartPolicyMaxRetries": 10
                  },
                  "source": {
                    "image": "ghcr.io/paperclipai/paperclip:2026.916.1"
                  },
                  "variables": {
                    "PORT": {
                      "isOptional": false,
                      "defaultValue": "3101"
                    },
                    "CONNECT_PORT": {
                      "isOptional": false,
                      "defaultValue": "3100"
                    },
                    "DATABASE_URL": {
                      "isOptional": false,
                      "defaultValue": "${{Postgres.DATABASE_URL}}"
                    },
                    "CONNECT_HELPER_JS": {
                      "isOptional": false,
                      "defaultValue": "// Paperclip /connect helper. Runs inside the official image, in front of Paperclip.\n//\n// Paperclip's \"Connect a model\" subscription flow creates an empty login folder under\n// <instance>/ai-local-logins/<id>/ and polls it, expecting someone to run a CLI login on\n// the server. On Railway nobody has a terminal there, so this helper does it:\n//   Claude: writes the admin's saved `claude setup-token` token as .credentials.json\n//   Codex:  runs `codex login --device-auth` and shows the link + code on /connect\n// Everything that is not /connect is proxied untouched (HTTP + websockets) to Paperclip.\nimport http from \"node:http\";\nimport net from \"node:net\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { spawn } from \"node:child_process\";\n\nconst LISTEN = Number(process.env.CONNECT_PORT || 3100);\nconst UPSTREAM = Number(process.env.PORT || 3101);\nconst HOME = process.env.PAPERCLIP_HOME || \"/paperclip\";\nconst LOGINS = path.join(HOME, \"instances\", process.env.PAPERCLIP_INSTANCE_ID || \"default\", \"ai-local-logins\");\nconst TOKEN_FILE = path.join(HOME, \"connect\", \"claude-token\");\nconst CODEX_URL = \"https://auth.openai.com/codex/device\";\nconst CODEX_TTL_MS = 15 * 60 * 1000;\nconst log = (...a) => console.log(\"[connect]\", ...a);\n\n// Preload for Paperclip's own process (written by `connect.mjs --write-patch <file>`, loaded with\n// node --import). Paperclip verifies a Claude subscription by calling Anthropic's usage endpoint.\n// For `claude setup-token` tokens that endpoint is no gate: they are inference-only, so it answers\n// 403 (no profile scope), and it rate limits hard (429 seen after a handful of calls). Only 401\n// means a bad token. For setup tokens only, any other failure becomes \"no usage data\" so Paperclip\n// accepts the token; every other request and status passes through untouched.\nconst USAGE_PATCH = `const f = globalThis.fetch;\nglobalThis.fetch = async (input, init) => {\n  const res = await f(input, init);\n  try {\n    const url = typeof input === \"string\" ? input : input.url || String(input);\n    const auth = new Headers((init && init.headers) || (input && input.headers) || {}).get(\"authorization\") || \"\";\n    if (!res.ok && res.status !== 401 && url.startsWith(\"https://api.anthropic.com/api/oauth/usage\") && auth.startsWith(\"Bearer sk-ant-oat\"))\n      return new Response(\"{}\", { status: 200, headers: { \"content-type\": \"application/json\" } });\n  } catch {}\n  return res;\n};\n`;\n// Launchers for Paperclip's agents. Its bundled Claude Agent SDK and Codex binaries lag new\n// models (Opus 5.5 needs Claude Code >=2.1.280; bundled 2.1.263) and its env allowlist drops\n// CLAUDE_CODE_EXECUTABLE / CODEX_PATH, so the boot script symlinks the bundled binaries to these.\n// Each prefers the copy kept updated on the volume, else the image's global CLI.\nconst launcher = (name) => `#!/bin/sh\n[ -x /paperclip/.cli/bin/${name} ] && exec /paperclip/.cli/bin/${name} \"$@\"\nexec /usr/local/bin/${name} \"$@\"\n`;\nif (process.argv[2] === \"--write-patch\") {\n  fs.writeFileSync(process.argv[3], USAGE_PATCH);\n  for (const name of [\"claude\", \"codex\"]) fs.writeFileSync(`/tmp/${name}-launcher`, launcher(name), { mode: 0o755 });\n  process.exit(0);\n}\n\n// ---- login folder watcher -------------------------------------------------------------\nconst codex = new Map(); // dir id -> { code, status, startedAt, child }\nlet claudeFilled = null; // last time a Claude login folder was filled\n\nconst readToken = () => { try { return fs.readFileSync(TOKEN_FILE, \"utf8\").trim(); } catch { return \"\"; } };\nconst exists = (p) => fs.existsSync(p);\n\nfunction startCodex(id) {\n  const dir = path.join(LOGINS, id);\n  const s = { code: null, status: \"starting\", startedAt: Date.now(), child: null };\n  codex.set(id, s);\n  // `script` gives codex a pseudo-terminal; it prints the device code only when it has one.\n  const cmd = `codex -c 'cli_auth_credentials_store=\"file\"' login --device-auth`;\n  const child = spawn(\"script\", [\"-qfec\", cmd, \"/dev/null\"], { env: { ...process.env, CODEX_HOME: dir } });\n  s.child = child;\n  let out = \"\";\n  const onData = (buf) => {\n    out = (out + buf.toString()).replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, \"\").slice(-4000);\n    const m = out.match(/\\b([A-Z0-9]{4}-[A-Z0-9]{5})\\b/);\n    if (m && !s.code) { s.code = m[1]; s.status = \"waiting\"; log(`Codex sign-in ${id}: code ${s.code}`); }\n  };\n  child.stdout.on(\"data\", onData);\n  child.stderr.on(\"data\", onData);\n  const timer = setTimeout(() => child.kill(\"SIGTERM\"), CODEX_TTL_MS);\n  child.on(\"exit\", (code) => {\n    clearTimeout(timer);\n    s.status = code === 0 && exists(path.join(dir, \"auth.json\")) ? \"connected\" : (s.status === \"cancelled\" ? \"cancelled\" : \"failed\");\n    log(`Codex sign-in ${id}: ${s.status}`);\n  });\n}\n\nfunction scan() {\n  let ids = [];\n  try { ids = fs.readdirSync(LOGINS); } catch { return; }\n  for (const id of ids) {\n    const dir = path.join(LOGINS, id);\n    if (exists(path.join(dir, \"config.toml\"))) { // Paperclip writes this only for OpenAI/Codex\n      if (!codex.has(id) && !exists(path.join(dir, \"auth.json\"))) startCodex(id);\n    } else if (!exists(path.join(dir, \".credentials.json\"))) {\n      const token = readToken();\n      if (!token) continue;\n      fs.writeFileSync(path.join(dir, \".credentials.json\"),\n        JSON.stringify({ claudeAiOauth: { accessToken: token } }), { mode: 0o600 });\n      claudeFilled = Date.now();\n      log(`Claude sign-in ${id}: token provided`);\n    }\n  }\n  for (const [id, s] of codex) { // Paperclip deletes the folder on connect or cancel\n    if (!ids.includes(id)) {\n      if (s.child && s.child.exitCode === null) { s.status = \"cancelled\"; s.child.kill(\"SIGTERM\"); }\n      if (Date.now() - s.startedAt > CODEX_TTL_MS) codex.delete(id);\n    }\n  }\n}\nsetInterval(scan, 2000);\n\n// ---- /connect page -------------------------------------------------------------------\nasync function admin(req) {\n  const r = await fetch(`http://127.0.0.1:${UPSTREAM}/api/cli-auth/me`, {\n    headers: { cookie: req.headers.cookie || \"\", host: req.headers.host || \"localhost\" },\n  }).catch(() => null);\n  if (!r || !r.ok) return null;\n  const me = await r.json().catch(() => null);\n  return me && me.userId ? me : null;\n}\n\nfunction state() {\n  const active = [...codex.entries()].sort((a, b) => b[1].startedAt - a[1].startedAt)[0];\n  return {\n    claude: { tokenSaved: Boolean(readToken()), lastProvided: claudeFilled },\n    codex: active ? { id: active[0], url: CODEX_URL, code: active[1].code, status: active[1].status,\n                      expiresAt: active[1].startedAt + CODEX_TTL_MS } : null,\n  };\n}\n\nconst body = (req) => new Promise((ok) => { let b = \"\"; req.on(\"data\", (c) => { b += c; if (b.length > 1e5) req.destroy(); }); req.on(\"end\", () => ok(b)); });\nconst send = (res, code, type, data) => { res.writeHead(code, { \"content-type\": type, \"cache-control\": \"no-store\" }); res.end(data); };\nconst json = (res, code, data) => send(res, code, \"application/json\", JSON.stringify(data));\n\nasync function saveClaude(token) {\n  token = token.trim();\n  if (!token) { fs.rmSync(TOKEN_FILE, { force: true }); return { ok: true, removed: true }; }\n  if (!/^sk-ant-oat\\d*-/.test(token)) return { ok: false, error: \"That does not look like a setup token. Run claude setup-token and paste the sk-ant-oat... value.\" };\n  const r = await fetch(\"https://api.anthropic.com/api/oauth/usage\", {\n    headers: { authorization: `Bearer ${token}`, \"anthropic-beta\": \"oauth-2025-04-20\" },\n    signal: AbortSignal.timeout(15000),\n  }).catch(() => null);\n  // Only 401 means a bad token; 403 (no profile scope) and 429/5xx are expected (see USAGE_PATCH).\n  if (r && r.status === 401) return { ok: false, error: \"Anthropic rejected the token. Run claude setup-token again and paste the new one.\" };\n  fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true, mode: 0o700 });\n  fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });\n  return { ok: true };\n}\n\nasync function handleConnect(req, res) {\n  if (req.url.split(\"?\")[0] === \"/connect/inline.js\") return send(res, 200, \"text/javascript\", INLINE); // no secrets; its API calls are gated\n  const me = await admin(req);\n  if (!me) { res.writeHead(302, { location: \"/auth?next=/connect\" }); return res.end(); }\n  if (!me.isInstanceAdmin) return send(res, 403, \"text/plain\", \"Only the Paperclip instance admin can connect models.\");\n  const url = req.url.split(\"?\")[0];\n  if (req.method === \"GET\" && url === \"/connect\") return send(res, 200, \"text/html; charset=utf-8\", PAGE);\n  if (req.method === \"GET\" && url === \"/connect/state\") return json(res, 200, state());\n  if (req.method === \"POST\") {\n    const origin = req.headers.origin || \"\";\n    if (!origin || new URL(origin).host !== req.headers.host) return json(res, 403, { ok: false, error: \"Bad origin\" });\n    if (url === \"/connect/claude\") {\n      const { token = \"\" } = JSON.parse((await body(req)) || \"{}\");\n      return json(res, 200, await saveClaude(String(token)));\n    }\n  }\n  send(res, 404, \"text/plain\", \"Not found\");\n}\n\nconst PAGE = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>Connect a model · Paperclip</title><style>\nbody{font:15px/1.5 ui-sans-serif,-apple-system,sans-serif;background:#141413;color:#fff;max-width:620px;margin:48px auto;padding:0 20px}\nh1{font-size:26px;margin:0 0 4px}p.sub{color:#9a958a;margin:0 0 28px}section{border:1px solid #2f2c28;border-radius:14px;padding:20px;margin:0 0 18px;background:#1f1d1a}\nh2{font-size:17px;margin:0 0 10px}ol{padding-left:20px;margin:8px 0}code,.code{font-family:ui-monospace,Menlo,monospace}\n.code{font-size:30px;letter-spacing:3px;margin:10px 0;display:block}input{width:100%;box-sizing:border-box;background:#141413;color:#fff;border:1px solid #3a3836;border-radius:8px;padding:10px;font:14px ui-monospace,monospace}\nbutton{margin-top:10px;background:#fff;color:#141413;border:0;border-radius:999px;padding:8px 18px;font-weight:600;cursor:pointer}\n.muted{color:#9a958a}.ok{color:#22c55e}.err{color:#f87171}a{color:#93c5fd}</style></head><body>\n<h1>Connect a model</h1><p class=\"sub\">For Paperclip's subscription sign-in on Railway. Keep this tab open next to Paperclip's <b>Connect a model</b> step.</p>\n<section><h2>Claude subscription</h2>\n<ol><li>On your own computer run <code>claude setup-token</code> and copy the <code>sk-ant-oat…</code> token.</li>\n<li>Paste it here and save. It is kept on your Paperclip volume only.</li>\n<li>In Paperclip, choose <b>Claude · Subscription</b> and click <b>Connect</b>.</li></ol>\n<input id=\"tok\" type=\"password\" placeholder=\"sk-ant-oat01-...\" autocomplete=\"off\"><button id=\"save\">Save token</button>\n<p id=\"cst\" class=\"muted\"></p></section>\n<section><h2>Codex (ChatGPT subscription)</h2>\n<p class=\"muted\" id=\"xhint\">In Paperclip, choose <b>Codex · Subscription</b>. A one-time code appears here within a few seconds.</p>\n<div id=\"xbox\" hidden><ol><li>Open <a id=\"xurl\" target=\"_blank\" rel=\"noopener\"></a> and sign in to ChatGPT.</li><li>Enter this code:</li></ol>\n<span class=\"code\" id=\"xcode\"></span><p id=\"xst\" class=\"muted\"></p></div></section>\n<script>\nconst $=id=>document.getElementById(id);\nasync function refresh(){const s=await (await fetch('/connect/state')).json();\n$('cst').className=s.claude.tokenSaved?'ok':'muted';\n$('cst').textContent=s.claude.tokenSaved?'Token saved.'+(s.claude.lastProvided?' Last handed to Paperclip '+new Date(s.claude.lastProvided).toLocaleTimeString()+'.':' Click Connect in Paperclip.'):'No token saved yet.';\nconst x=s.codex;$('xbox').hidden=!x||!x.code;\nif(x){$('xurl').href=$('xurl').textContent=x.url;$('xcode').textContent=x.code||'';\nconst left=Math.max(0,Math.round((x.expiresAt-Date.now())/1000));\nconst msg={starting:'Starting sign-in…',waiting:'Waiting for you to approve ('+Math.floor(left/60)+':'+String(left%60).padStart(2,'0')+' left).',connected:'Signed in. Paperclip picks it up automatically.',failed:'Sign-in failed or expired. Click the Codex tile in Paperclip again to retry.',cancelled:'Cancelled in Paperclip.'}[x.status];\n$('xst').textContent=msg;$('xst').className=x.status==='connected'?'ok':x.status==='failed'?'err':'muted';$('xhint').hidden=!!x.code;}}\n$('save').onclick=async()=>{$('cst').textContent='Checking token with Anthropic…';\nconst r=await (await fetch('/connect/claude',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({token:$('tok').value})})).json();\nif(!r.ok){$('cst').className='err';$('cst').textContent=r.error;return}$('tok').value='';refresh()};\nrefresh();setInterval(refresh,2000);\n</script></body></html>`;\n\n// Injected into Paperclip's own pages: on the \"Connect a model\" step it replaces the\n// \"run this in a terminal\" block with the token box (Claude) or the live device code (Codex).\n// Anchored on the sign-in command's login folder path; if Paperclip's markup changes, it\n// simply finds nothing and /connect keeps working. Uses CSS order, never moves React nodes.\nconst INLINE = `(() => {\nconst BOX = \"margin:0 0 4px;padding:18px;border:1px solid rgba(127,127,127,.3);border-radius:12px;color:var(--foreground,inherit);font-size:14px;line-height:1.5;text-align:center\";\nconst BTN = \"display:block;width:100%;margin-top:14px;padding:12px 16px;border-radius:999px;border:0;background:var(--foreground,#fff);color:var(--background,#141413);font-weight:600;font-size:15px;cursor:pointer;text-decoration:none\";\nconst MUTED = \"opacity:.65;margin-top:10px\";\nlet state = null, busy = false;\nasync function poll(){ try { const r = await fetch(\"/connect/state\",{cache:\"no-store\"}); state = r.ok ? await r.json() : null; } catch { state = null; } render(); }\nfunction render(){\n  for (const code of document.querySelectorAll(\"pre code\")) {\n    const cmd = code.textContent || \"\"; const m = cmd.match(/ai-local-logins.([0-9a-f-]{36})/); if (!m) continue;\n    const kind = cmd.includes(\"CODEX_HOME\") ? \"codex\" : cmd.includes(\"CLAUDE_CONFIG_DIR\") ? \"claude\" : null; if (!kind || !state) continue;\n    const box = code.closest(\"div.rounded-md\"), wrap = box && box.parentElement; if (!wrap) continue;\n    wrap.style.display = \"flex\"; wrap.style.flexDirection = \"column\";\n    box.style.display = \"none\";\n    for (const el of wrap.querySelectorAll(\":scope > p\")) if (/Run this in a terminal|on the machine running Paperclip|Connect uses your local|Run the sign-in command/.test(el.textContent)) el.style.display = \"none\";\n    let panel = wrap.querySelector(\":scope > .pc-connect\");\n    if (!panel) { panel = document.createElement(\"div\"); panel.className = \"pc-connect\"; panel.style.cssText = BOX; panel.style.order = \"-1\"; wrap.appendChild(panel); }\n    const html = kind === \"claude\" ? claude() : codex(m[1]);\n    if (panel.dataset.html !== html) { panel.dataset.html = html; panel.innerHTML = html; wire(panel); }\n  }\n}\nfunction claude(){\n  if (state.claude.tokenSaved) return \"<div style='font-weight:600;font-size:15px'>Claude subscription token saved</div><div style='\" + MUTED + \"'>Click Connect to use it. <a href='/connect' target='_blank' style='text-decoration:underline'>Change token</a></div>\";\n  return \"<div style='font-weight:600;font-size:15px'>Sign in with your Claude subscription</div>\" +\n    \"<div style='\" + MUTED + \";margin-top:4px'>Run <code>claude setup-token</code> on your computer and paste the token below.</div>\" +\n    \"<input class='pc-tok' type='password' placeholder='sk-ant-oat01-...' autocomplete='off' style='display:block;width:100%;box-sizing:border-box;margin-top:14px;padding:11px 12px;border-radius:10px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;font-family:ui-monospace,monospace;text-align:center'>\" +\n    \"<button class='pc-save' type='button' style='\" + BTN + \"'>Save token</button><div class='pc-msg' style='margin-top:8px;color:#f87171'></div>\";\n}\nfunction codex(id){\n  const x = state.codex && state.codex.id === id ? state.codex : null;\n  if (!x || !x.code) return \"<div style='\" + MUTED + \";margin:0'>Preparing your ChatGPT sign-in code...</div>\";\n  if (x.status === \"connected\") return \"<div style='font-weight:600;font-size:15px'>Signed in to ChatGPT</div><div style='\" + MUTED + \"'>Click Connect to finish.</div>\";\n  if (x.status === \"failed\") return \"<div style='font-weight:600;font-size:15px'>This code expired</div><div style='\" + MUTED + \"'>Click Start sign-in again below for a new one.</div>\";\n  return \"<div style='font-weight:600;font-size:15px'>Sign in with ChatGPT</div><div style='\" + MUTED + \";margin-top:4px'>Enter this code on the ChatGPT sign-in page</div>\" +\n    \"<div class='pc-code' title='Click to copy' style='font:700 38px/1.1 ui-monospace,Menlo,monospace;letter-spacing:5px;margin:16px 0 4px;cursor:pointer;user-select:all'>\" + x.code + \"</div>\" +\n    \"<a class='pc-open' href='\" + x.url + \"' target='_blank' rel='noopener' data-code='\" + x.code + \"' style='\" + BTN + \"'>Copy code & open ChatGPT</a>\" +\n    \"<div style='\" + MUTED + \"'>Waiting for approval, then click Connect</div>\";\n}\nfunction copy(t){ try { navigator.clipboard.writeText(t); } catch {} }\nfunction wire(panel){\n  const open = panel.querySelector(\".pc-open\"); if (open) open.onclick = () => copy(open.dataset.code);\n  const code = panel.querySelector(\".pc-code\"); if (code) code.onclick = () => { copy(code.textContent); code.style.opacity = \".5\"; setTimeout(() => code.style.opacity = \"1\", 300); };\n  const b = panel.querySelector(\".pc-save\"); if (!b) return;\n  b.onclick = async () => { if (busy) return; busy = true; const msg = panel.querySelector(\".pc-msg\"); msg.style.color = \"inherit\"; msg.textContent = \"Checking token with Anthropic...\";\n    try { const r = await (await fetch(\"/connect/claude\",{method:\"POST\",headers:{\"content-type\":\"application/json\"},body:JSON.stringify({token:panel.querySelector(\".pc-tok\").value})})).json();\n      if (!r.ok) { msg.style.color = \"#f87171\"; msg.textContent = r.error; } else { await poll(); } } finally { busy = false; } };\n}\nnew MutationObserver(() => { if (!busy) render(); }).observe(document.documentElement, { childList: true, subtree: true });\npoll(); setInterval(poll, 2000);\n})();`;\n\n// ---- proxy -----------------------------------------------------------------------------\nconst server = http.createServer((req, res) => {\n  if (req.url === \"/connect\" || req.url.startsWith(\"/connect/\") || req.url.startsWith(\"/connect?\")) {\n    return handleConnect(req, res).catch((e) => { log(e); send(res, 500, \"text/plain\", \"connect helper error\"); });\n  }\n  const page = req.method === \"GET\" && (req.headers.accept || \"\").includes(\"text/html\");\n  const headers = { ...req.headers };\n  if (page) delete headers[\"accept-encoding\"]; // uncompressed HTML so the script tag can be added\n  const up = http.request({ host: \"127.0.0.1\", port: UPSTREAM, method: req.method, path: req.url, headers }, (r) => {\n    if (!page || !(r.headers[\"content-type\"] || \"\").startsWith(\"text/html\")) {\n      res.writeHead(r.statusCode, r.rawHeaders);\n      return r.pipe(res);\n    }\n    const chunks = [];\n    r.on(\"data\", (c) => chunks.push(c));\n    r.on(\"end\", () => {\n      const html = Buffer.concat(chunks).toString(\"utf8\").replace(\"</body>\", '<script src=\"/connect/inline.js\" defer></script></body>');\n      const h = { ...r.headers, \"content-length\": Buffer.byteLength(html) };\n      delete h[\"transfer-encoding\"];\n      res.writeHead(r.statusCode, h);\n      res.end(html);\n    });\n  });\n  up.on(\"error\", () => { if (!res.headersSent) send(res, 502, \"text/plain\", \"Paperclip is starting…\"); else res.destroy(); });\n  req.pipe(up);\n});\nserver.on(\"upgrade\", (req, socket, head) => {\n  const up = net.connect(UPSTREAM, \"127.0.0.1\", () => {\n    let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\\r\\n`;\n    for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\\r\\n`;\n    up.write(raw + \"\\r\\n\");\n    if (head && head.length) up.write(head);\n    up.pipe(socket).pipe(up);\n  });\n  up.on(\"error\", () => socket.destroy());\n  socket.on(\"error\", () => up.destroy());\n});\nserver.listen(LISTEN, \"::\", () => log(`listening on ${LISTEN}, proxying to ${UPSTREAM}, watching ${LOGINS}`));\n"
                    },
                    "BETTER_AUTH_SECRET": {
                      "isOptional": false,
                      "defaultValue": "{{BETTER_AUTH_SECRET}}"
                    },
                    "PAPERCLIP_PUBLIC_URL": {
                      "isOptional": false,
                      "description": "Canonical URL used for auth callbacks and invite links. Change it if you add a custom domain.",
                      "defaultValue": "https://${{RAILWAY_PUBLIC_DOMAIN}}"
                    },
                    "PAPERCLIP_DEPLOYMENT_MODE": {
                      "isOptional": false,
                      "defaultValue": "authenticated"
                    },
                    "PAPERCLIP_AGENT_JWT_SECRET": {
                      "isOptional": false,
                      "defaultValue": "{{PAPERCLIP_AGENT_JWT_SECRET}}"
                    },
                    "PAPERCLIP_ALLOWED_HOSTNAMES": {
                      "isOptional": false,
                      "defaultValue": "healthcheck.railway.app"
                    },
                    "PAPERCLIP_DEPLOYMENT_EXPOSURE": {
                      "isOptional": false,
                      "description": "private: sign-in required; the first signed-in user claims admin from the browser. Claim right after deploy.",
                      "defaultValue": "private"
                    },
                    "PAPERCLIP_MIGRATION_AUTO_APPLY": {
                      "isOptional": false,
                      "defaultValue": "true"
                    },
                    "PAPERCLIP_AUTH_RATE_LIMIT_ENABLED": {
                      "isOptional": false,
                      "defaultValue": "true"
                    },
                    "PAPERCLIP_TOOL_ACTION_SIGNING_SECRET": {
                      "isOptional": false,
                      "defaultValue": "{{PAPERCLIP_TOOL_ACTION_SIGNING_SECRET}}"
                    }
                  },
                  "networking": {
                    "serviceDomains": {
                      "<hasDomain>:3100": {
                        "port": 3100
                      }
                    }
                  },
                  "volumeMounts": {
                    "9408401a-aa4c-46e2-a7ee-9c90e5473e4f": {
                      "mountPath": "/paperclip"
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "post_deploy": {
    "healthcheck": {
      "service": "Paperclip",
      "method": "GET",
      "path": "/api/health",
      "expect_status": 200
    }
  },
  "resources": {
    "expected_services": 2,
    "needs_volume": true
  },
  "generated_at": "2026-10-06T04:14:42.626Z",
  "generator_version": "0.1.0",
  "status": "validated",
  "validated_at": "2026-10-03T16:00:47.911Z",
  "success_rate_30d": 1,
  "validation": {
    "last_run_id": "run_59e9c934ec174f1b8d2b",
    "checks": [
      {
        "name": "workflow_completed",
        "passed": true
      },
      {
        "name": "all_services_deployed",
        "passed": true
      },
      {
        "name": "healthcheck",
        "passed": true
      },
      {
        "name": "stays_up",
        "passed": true
      }
    ],
    "typical_ready_seconds": 90,
    "typical_build_seconds": 0,
    "typical_start_seconds": 61,
    "slowest_service": "Paperclip"
  }
}
