Web Service Onboarding Skill
The Core Pattern
CODEBLOCK0
Do this in a single unbroken browser session. Never close the browser between steps.
Critical Rules (Learned the Hard Way — Turnkey, 2026-03-25)
1. One session, no gaps
- - Complete signup AND API key creation AND secret storage in the same browser session
- Close the browser only after credentials are saved to 1Password
- If the browser closes before credentials are extracted, you've lost access — the passkey/session is gone
2. Email alias trap
- - Proton Mail (and many providers) treat
user+alias@domain.com as the same user - If the service already has an account for
user@domain.com, the alias will route to that existing account - Always check whether the service resolves aliases before using them for a fresh account
- Use a completely different email (different domain, different provider) for a truly separate account
3. WebAuthn virtual authenticator is ephemeral
- - Playwright's
WebAuthn.addVirtualAuthenticator creates an in-memory credential store - The passkey it registers is only valid for that browser process
- If you close the browser and reopen it, the credential is gone forever
- The only way to reuse it is to export the credential before closing, then re-import on next run
- Export immediately after registration:
const creds = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
fs.writeFileSync('/tmp/webauthn-creds.json', JSON.stringify(creds));
- - Re-import on next session:
CODEBLOCK2
4. Email verification is link-based, not always OTP
- - Don't assume OTP input fields — check the actual email body first
- Turnkey, Vercel, Railway, Render all send magic links not codes
- Parse the email body with quoted-printable decoding before extracting URLs
- Watch for soft line breaks (
=\n) in QP-encoded emails
5. Session cookies are tied to the authenticator
- - If you complete signup in context A and try to use the session in context B, it won't work
- Cookies + passkey credential must stay in the same browser context
6. Internal APIs are not public APIs
- -
app.service.com/internal/api/* endpoints require session cookies - INLINECODE5 endpoints require API key stamping
- You can't call public API endpoints to bootstrap if you have no API key yet
- Only the internal API (cookie-auth) is accessible from an authenticated browser session
Workflow
Phase A: Send signup email (clean context — no cookies)
CODEBLOCK3
Why separate? Prevents existing session cookies from hijacking the signup flow.
Phase B: Complete signup + save API keys (same context throughout)
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
// Set up virtual authenticator BEFORE navigating
await cdp.send('WebAuthn.enable', { enableUI: false });
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2', transport: 'internal',
hasResidentKey: true, hasUserVerification: true,
isUserVerified: true, automaticPresenceSimulation: true,
}
});
// Navigate to verify link
await page.goto(verifyUrl);
// Complete signup steps...
// IMMEDIATELY export credential after passkey registration
const creds = await cdp.send('WebAuthn.getCredentials', { authenticatorId });
fs.writeFileSync('/tmp/webauthn-creds.json', JSON.stringify(creds));
console.log('Credentials backed up:', creds.credentials?.length);
// Continue to API key + wallet creation IN SAME SESSION
// ...save API keys...
// Close browser ONLY after saving everything to 1Password
Email Fetching via Proton Bridge IMAP
CODEBLOCK5
Key: Pattern-match the redirect URL to the service's domain, not a generic URL.
Proton Mail Setup
- - IMAP host:
127.0.0.1, port: 1143, STARTTLS - Credentials in 1Password: INLINECODE8
- Bridge must be running: INLINECODE9
Input/Form Filling — Use Native Value Setter
Standard element.fill() sometimes fails on React inputs. Use this:
CODEBLOCK6
Button Clicking — Scroll Into View First
Buttons outside viewport fail with element is outside of the viewport. Always scroll:
CODEBLOCK7
After Successful Authentication — save API keys
For services with internal browser APIs:
// Call authenticated internal API from page context
const data = await page.evaluate(async () => {
const r = await fetch('/internal/api/v1/whoami');
return r.json();
});
// data.organizationId, data.userId, etc.
Creating API Keys / Resources via Internal API
Once authenticated (cookie present), call internal endpoints from the page context:
CODEBLOCK9
Important: Internal endpoints vary per service. Before creating resources, capture network traffic to learn the real endpoint:
page.on('request', req => {
if (req.method() !== 'GET' && req.url().includes(serviceDomain))
console.log(req.method(), req.url());
});
Saving to 1Password
CODEBLOCK11
Signup Registry (MANDATORY)
Before starting any signup, add an entry to the Notion signup registry:
- - DB: INLINECODE12
- Fields: service name, email used, date, purpose
- This is non-negotiable per POLICIES.md
Service-Specific Notes
Turnkey (app.turnkey.com)
- - Email verify: link-based (magic link, not OTP)
- Auth: WebAuthn passkey (virtual authenticator works)
- Post-signup API:
/tkhq/api/v1/activities (internal, cookie-auth) - Public API:
api.turnkey.com/public/v1/ requires X-Stamp (signed request) - Email aliases (
+tag) map to the same Turnkey account — use a different provider for separate orgs - Org created in one run:
b7378687-cf82-45ab-a46c-7dda9239001d (Reddi Agent Protocol)
Generic patterns
- - Vercel: email OTP or GitHub OAuth
- Railway: GitHub OAuth (no email signup)
- Supabase: email + password, then API key in dashboard
- Fly.io: email + credit card, CLI bootstrap preferred
Pre-flight Checklist
Before starting any signup:
- - [ ] Added to Notion signup registry
- [ ] Confirmed email available (not already used for this service)
- [ ] Email aliases: does service collapse them? (test first)
- [ ] IMAP readable for email provider being used
- [ ] 1Password vault accessible
- [ ] Proton Bridge running (if using Proton)
- [ ] Sufficient budget for paid tier (if applicable) — ask Nissan first
Web Service 注册技能
核心模式
发送注册邮件 → 验证邮箱 → 完成注册 →
生成 API 密钥 → 安全存储到 1Password → 配置 .env 文件
在同一个不间断的浏览器会话中完成上述操作。步骤之间切勿关闭浏览器。
关键规则(经验教训 — Turnkey, 2026-03-25)
1. 单一会话,无间断
- - 在同一个浏览器会话中完成注册、API 密钥创建和密钥存储
- 仅在凭证保存到 1Password 后才关闭浏览器
- 如果在提取凭证前关闭浏览器,将失去访问权限 — 通行密钥/会话已丢失
2. 邮箱别名陷阱
- - Proton Mail(以及许多邮件服务商)将 user+alias@domain.com 视为同一用户
- 如果该服务已有 user@domain.com 的账户,别名将路由到该现有账户
- 在使用别名创建新账户前,务必检查该服务是否解析别名
- 使用完全不同的邮箱(不同域名、不同服务商)来创建真正独立的账户
3. WebAuthn 虚拟认证器是临时的
- - Playwright 的 WebAuthn.addVirtualAuthenticator 创建的是内存中的凭证存储
- 注册的通行密钥仅对该浏览器进程有效
- 如果关闭浏览器后重新打开,凭证将永久丢失
- 重复使用的唯一方法是在关闭前导出凭证,然后在下次运行时重新导入
- 注册后立即导出:
js
const creds = await cdp.send(WebAuthn.getCredentials, { authenticatorId });
fs.writeFileSync(/tmp/webauthn-creds.json, JSON.stringify(creds));
js
const saved = JSON.parse(fs.readFileSync(/tmp/webauthn-creds.json));
for (const cred of saved.credentials) {
await cdp.send(WebAuthn.addCredential, { authenticatorId, credential: cred });
}
4. 邮箱验证基于链接,不总是 OTP
- - 不要假设有 OTP 输入框 — 先检查实际的邮件正文
- Turnkey、Vercel、Railway、Render 都发送魔法链接而非验证码
- 在提取 URL 前,使用 quoted-printable 解码解析邮件正文
- 注意 QP 编码邮件中的软换行符(=\n)
5. 会话 Cookie 与认证器绑定
- - 如果在上下文 A 中完成注册,并尝试在上下文 B 中使用会话,将无法工作
- Cookie + 通行密钥凭证必须保持在同一个浏览器上下文中
6. 内部 API 不是公共 API
- - app.service.com/internal/api/ 端点需要会话 Cookie
- api.service.com/public/v1/ 端点需要 API 密钥签名
- 如果没有 API 密钥,无法调用公共 API 端点进行引导
- 只有内部 API(Cookie 认证)可以从已认证的浏览器会话中访问
工作流程
阶段 A:发送注册邮件(干净的上下文 — 无 Cookie)
js
const ctxClean = await browser.newContext({ storageState: undefined });
const page = await ctxClean.newPage();
await page.goto(https://service.com/signup);
// 填写邮箱,点击继续
// 提交后立即关闭 ctxClean
await ctxClean.close();
为什么要分开? 防止现有会话 Cookie 劫持注册流程。
阶段 B:完成注册 + 保存 API 密钥(全程同一上下文)
js
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
const cdp = await ctx.newCDPSession(page);
// 在导航前设置虚拟认证器
await cdp.send(WebAuthn.enable, { enableUI: false });
const { authenticatorId } = await cdp.send(WebAuthn.addVirtualAuthenticator, {
options: {
protocol: ctap2, transport: internal,
hasResidentKey: true, hasUserVerification: true,
isUserVerified: true, automaticPresenceSimulation: true,
}
});
// 导航到验证链接
await page.goto(verifyUrl);
// 完成注册步骤...
// 通行密钥注册后立即导出凭证
const creds = await cdp.send(WebAuthn.getCredentials, { authenticatorId });
fs.writeFileSync(/tmp/webauthn-creds.json, JSON.stringify(creds));
console.log(凭证已备份:, creds.credentials?.length);
// 在同一个会话中继续创建 API 密钥和钱包
// ...保存 API 密钥...
// 仅在将所有内容保存到 1Password 后关闭浏览器
通过 Proton Bridge IMAP 获取邮件
js
function fetchLatestTurnkeyLink(host=127.0.0.1, port=1143, user, pass) {
return new Promise((resolve) => {
const socket = net.connect(port, host);
let buf=, tls2=null, step=0, body=[], inBody=false;
const t = setTimeout(() => { try{(tls2||socket).destroy()}catch(e){}; resolve(null); }, 22000);
function send(cmd) { (tls2||socket).write(cmd+\r\n); }
function onData(data) {
buf += data.toString();
const lines = buf.split(\r\n); buf = lines.pop();
for (const l of lines) {
if (inBody) body.push(l);
if (step===0 && l.includes(OK)) { step=1; send(a1 STARTTLS); }
else if (step===1 && l.includes(a1 OK)) { tls2=tls.connect({socket,rejectUnauthorized:false}); tls2.on(data,onData); step=2; send(a2 LOGIN ${user} ${pass}); }
else if (step===2 && l.includes(a2 OK)) { step=3; send(a3 SELECT INBOX); }
else if (step===3 && l.includes(a3 OK)) { step=4; send(a4 SEARCH ALL); }
else if (step===4 && l.startsWith(* SEARCH)) {
const nums = l.replace(* SEARCH,).trim().split( ).filter(Boolean);
step=5; inBody=true; send(a5 FETCH ${nums[nums.length-1]} (BODY[TEXT]));
}
else if (step===5 && l.includes(a5 OK)) {
clearTimeout(t); (tls2||socket).end();
// 解码 quoted-printable
const decoded = body.join(\n)
.replace(/=\r?\n/g, )
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
// 提取重定向 URL
const urls = [...decoded.matchAll(/https:\/\/service\.com\/redirect\?token=[^\s<>)]+/g)].map(m=>m[0]);
resolve(urls[0] || null);
}
}
}
socket.on(data, onData);
socket.on(error, () => { clearTimeout(t); resolve(null); });
});
}
关键点: 将重定向 URL 与服务域名匹配,而非通用 URL。
Proton Mail 设置
- - IMAP 主机:127.0.0.1,端口:1143,STARTTLS
- 凭证在 1Password 中:op://OpenClaw/Proton Bridge - Monk Fenix/...
- Bridge 必须正在运行:ps aux | grep -i bridge
输入/表单填写 — 使用原生值设置器
标准的 element.fill() 有时在 React 输入框上会失败。请使用以下方法:
js
await page.evaluate((value) => {
const input = document.querySelector(input);
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, value)
.set.call(input, value);
input.dispatchEvent(new Event(input, { bubbles: true }));
input.dispatchEvent(new Event(change, { bubbles: true }));
}, value);
按钮点击 — 先滚动到视图中
视口外的按钮会报错 element is outside of the viewport。始终先滚动:
js
await page.evaluate((text) => {
const btn = [...document.querySelectorAll(button)]
.find(b => b.textContent?.toLowerCase().includes(text) && !b.disabled);
if (btn) { btn.scrollIntoView(); btn.click(); }
}, buttonText);
认证成功后 — 保存 API 密钥
对于具有内部浏览器 API 的服务:
js
// 从页面上下文调用已认证的内部 API
const data =