decolua/9router · error · Error
Failed to save tokens
Error message
Failed to save tokens
What it means
Thrown by ClaudeService.saveTokens when the POST to the 9router dashboard endpoint `${server}/api/cli/providers/claude` returns non-2xx. The server's JSON `error` field is used when present, otherwise the generic fallback string. The Claude OAuth exchange already succeeded — only registering the tokens with the local server failed, so the account is not usable in the router.
Source
Thrown at src/lib/oauth/services/claude.js:97
// Server will auto-generate displayName based on existing account count
const response = await fetch(`${server}/api/cli/providers/claude`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-User-Id": userId,
},
body: JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
return await response.json();
}
/**
* Complete Claude OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting Claude OAuth...").start();
try {
spinner.text = "Starting local server...";
// Authenticate and get authorization code
const { code, state, codeVerifier, redirectUri } = await this.authenticate(
"Claude",
this.buildClaudeAuthUrl.bind(this)View on GitHub (pinned to 90b52e06ff)
Solutions
- Inspect the server's `error` message inside the thrown Error for the exact rejection
- Re-authenticate the CLI with the dashboard to refresh the Bearer token, then retry connect
- Confirm the `server` URL in CLI config matches the running 9router instance and port
- Retry the connect flow once the dashboard is confirmed up and reachable
Defensive patterns
Strategy: try-catch
Validate before calling
const { server, token, userId } = getServerCredentials();
if (!server || !token) throw new Error('CLI not authenticated — run login before connecting Claude');
const health = await fetch(`${server}/api/health`).catch(() => null);
if (!health || !health.ok) throw new Error(`Dashboard ${server} unreachable`); Type guard
function canSave(c) { return c && typeof c.server === 'string' && /^https?:\/\//.test(c.server) && typeof c.token === 'string' && c.token.length > 0 && typeof c.userId !== 'undefined'; } Try / catch
try {
await service.saveTokens(tokens);
} catch (e) {
if (/Failed to save tokens/i.test(e.message)) {
// server reachable but rejected: check server error text, refresh CLI auth, retry
} else throw e;
} Prevention
- Refresh CLI login when the session token nears expiry before long OAuth flows
- Confirm the dashboard is running and the server URL/port in CLI config is correct
- Avoid restarting the dashboard between authorize and save
- Check dashboard logs for /api/cli/providers/claude rejection details
When it happens
Trigger: Dashboard API rejects the save: invalid/expired CLI Bearer token from getServerCredentials(), wrong `server` URL, server restarted mid-flow, validation rejection of missing/empty token fields, or duplicate-account constraints.
Common situations: CLI pointed at a stale server URL in its config; session token expired during the browser-based Claude login; dashboard updated/restarted between authorize and save; running CLI against a different 9router instance than the one that issued credentials.
Related errors
- Failed to save tokens
- Failed to save tokens
- HTTP ${response.status}: ${errorMsg}
- Failed to reset Codex limit
- Failed to load Codex reset credits
AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30).
Data as JSON: /api/errors/12d5bdda3d5633e0.
Report an issue: GitHub.