# How Token Refresh Works ## Anthropic OAuth Token Lifecycle Claude Max subscriptions use OAuth tokens for API authentication. - **Access token** (`sk-ant-oat01-...`): Used for API requests, expires in ~8 hours - **Refresh token** (`sk-ant-ort01-...`): Used to get new access tokens, long-lived - **Token endpoint**: `POST https://console.anthropic.com/v1/oauth/token` - **Client ID**: `9d1c250a-e61b-44d9-88ed-5944d1962f5e` (Claude Code public OAuth client) ## How Claude Code CLI Refreshes Tokens Claude Code CLI has built-in token refresh. Before each API request: 1. Checks `[API:auth] OAuth token check` — reads `.credentials.json` 2. If access token is expired or near expiry: - Sends `grant_type: "refresh_token"` to Anthropic's token endpoint - Gets back new `access_token`, `refresh_token`, `expires_in` - Writes updated credentials back to `.credentials.json` 3. Uses the (possibly refreshed) access token for the API request The relevant function in Claude Code's minified source (`cli.js`): ```javascript // Simplified from minified source async function refreshToken(refreshToken, scopes) { const params = { grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID, scope: scopes.join(" ") }; const response = await axios.post(TOKEN_URL, params, { headers: { "Content-Type": "application/json" } }); return { accessToken: response.data.access_token, refreshToken: response.data.refresh_token || refreshToken, expiresAt: Date.now() + response.data.expires_in * 1000, scopes: response.data.scope.split(" ") }; } ``` ## Why We Need a Sync Service The problem is that Claude Code CLI and OpenClaw are **separate processes in separate containers**: - Claude Code CLI runs in `claude-proxy` container, writes to its own `.credentials.json` - OpenClaw gateway runs in `openclaw-openclaw-gateway-1`, reads from `oauth.json` and env vars They don't share the same credential files. So when Claude CLI refreshes the token, OpenClaw doesn't know about it. ## The Sync Approach (inotifywait) We use `inotifywait` from `inotify-tools` to watch for file changes in real-time: ```bash inotifywait -q -e close_write,moved_to "$WATCH_DIR" ``` - **`close_write`**: Fires when a file is written and closed (normal writes) - **`moved_to`**: Fires when a file is moved into the directory (atomic writes: write to temp, then `mv`) - We watch the **directory** not the file, because atomic renames create a new inode When the file changes: 1. Read `accessToken`, `refreshToken`, `expiresAt` from Claude CLI format 2. Map fields: `accessToken` -> `access`, `refreshToken` -> `refresh`, `expiresAt` -> `expires` 3. Write to `oauth.json` (for gateway's `mergeOAuthFileIntoStore()`) 4. Update `ANTHROPIC_OAUTH_TOKEN` in `.env` 5. Recreate gateway container (`docker compose down/up`) to reload env vars ## The Fallback Approach (Timer) If `inotifywait` is unavailable, a systemd timer runs every 6 hours: 1. Reads current refresh token from `.credentials.json` 2. Calls Anthropic's token endpoint directly 3. Writes new tokens to all credential locations 4. Recreates gateway container This is less responsive (up to 6-hour delay) but works without inotify. ## Field Mapping | Claude CLI (`.credentials.json`) | OpenClaw (`oauth.json`) | |----------------------------------|------------------------| | `claudeAiOauth.accessToken` | `anthropic.access` | | `claudeAiOauth.refreshToken` | `anthropic.refresh` | | `claudeAiOauth.expiresAt` | `anthropic.expires` | | `claudeAiOauth.scopes` | `anthropic.scopes` | ## Timeline ``` T=0h Token issued (access + refresh) T=7.5h Claude CLI detects token nearing expiry T=7.5h CLI calls refresh endpoint, gets new tokens T=7.5h CLI writes new .credentials.json T=7.5h inotifywait detects change (< 1 second) T=7.5h sync-oauth-token.sh syncs to oauth.json + .env T=7.5h Gateway recreated with fresh token T=8h Old token would have expired (but we already refreshed) ``` The entire sync happens within seconds of the CLI refresh, well before the old token expires.