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

  1. Inspect the server's `error` message inside the thrown Error for the exact rejection
  2. Re-authenticate the CLI with the dashboard to refresh the Bearer token, then retry connect
  3. Confirm the `server` URL in CLI config matches the running 9router instance and port
  4. 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

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


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/12d5bdda3d5633e0. Report an issue: GitHub.