Skip to article
HB CodeDocsGet the app

HandbookRelay & accounts

Accounts, auth, and regions

The Auth0 PKCE handoff through Redis, the HB Code JWT and its refresh, what a region change invalidates, how a phone reads and switches Codex profiles and Claude accounts, and which data leaves your computer.

Checked against the source on 5 October 2026

On this page

How the native sign-in returns to the app

The browser delivers the code to the regional server, and only the polling app can redeem it
The browser delivers the code to the regional server, and only the polling app can redeem itTime runs from left to right. The app opens the browser at the regional server’s initiate-login, which stores a state key in Redis and redirects to Auth0. The app starts polling right away and gets 204 while you sign in. Auth0 sends the browser to the callback with the code. The callback consumes the state key and parks the code under the app’s polling ID, so a replayed callback gets 401. A poll reads the parked code without consuming it, so when a reply is lost the next poll gets the same code. The app redeems the code once at Auth0 with its verifier, then calls finalize-login, which returns the HB Code JWT and deletes the poll key. Nothing goes from the browser to the app.
Appdesktop or phone
Browser
Auth0
Regional APIkeys in Redis
state key
poll key
you sign in
pid · csrf
code · csrf, SET NX
polls from the start: 204 until the code is parked
opens initiate-login with challenge, pid, csrf
reply lost
callback with the code
consumed
code + verifier, once
finalize-login: JWT back
deleted
replayed callback: 401
The app gives up after at most 60 polls, about 2 to 2.5 minutes. A code parked later expires unused after 30 minutes.
Nothing goes from the browser to the app, and the verifier leaves the app only for Auth0.
  • State keyauth0:state:{state}

    Written by initiate-login with the polling ID and CSRF token, and deleted by the callback as it parks the code. A replayed callback finds nothing and gets 401.

  • Poll keyauth0:poll:{pid} · SET NX

    Polls read it without consuming it, so after a lost reply the next poll gets the same code. finalize-login deletes it after issuing the JWT, and both keys expire after 30 minutes.

  • PollingGET /auth0/poll-status

    Phones poll every 2 seconds, up to 60 times. The desktop polls every 2 seconds for about a minute, then every 3, and stops after 60 polls or 150 seconds.

  • Verifiercode_verifier

    Created with the polling ID and kept in app memory. Only Auth0’s token endpoint receives it, once, so a parked or intercepted code is useless without it.

Auth0’s tokens pass through your device once, and the agent sign-ins never leave the desktop
Auth0’s tokens pass through your device once, and the agent sign-ins never leave the desktopAt sign-in the app exchanges the code at Auth0 for an access token and a refresh token, posts both once to finalize-login, and keeps neither. The regional server stores the refresh token encrypted and exchanges it with Auth0 itself whenever a device refreshes. Each device keeps its own HB Code JWT, valid for 7 days and refreshable for 24 hours after it expires. The ChatGPT sign-in and the Claude sign-in stay on the desktop. Codex uses the first for every Codex turn with OpenAI, and Claude uses the second for every Claude turn with Anthropic. Only the name, email, plan, and usage limits of each go over the relay to phones.
Your devices
Regional server
Auth0
Auth0 tokens: used once, not kept
HB Code JWTkeychain or Keystore, one per device
Auth0 refresh tokenusers table, AES-256-GCM
access + refresh token, once
refresh token for new tokens, each refresh
finalize-login, once
new JWT, one per device
refresh with the JWT
HB Code JWT lifetime
valid 7 days
24 h grace: refresh still works
Desktop only: agent sign-ins
ChatGPT sign-inauth home of the Codex profile
Claude sign-inClaude’s own credential store
Relaypasses them to phones, holds no sign-in
name, email, plan, limits
OpenAI
Anthropic
every Codex turn
every Claude turn
  • Access tokenfinalize-login · auth0_id_token

    The app gets it from its own code exchange and posts it once to finalize-login, in a field still named auth0_id_token. The server validates it, fetches userinfo with it, and stores it nowhere.

  • Refresh tokenusers.auth0_refresh_token

    Passes through the app once. The server stores it encrypted, exchanges it with Auth0 using its own client credentials, and keeps the rotated token even when the new tokens then fail validation.

  • HB Code JWTJWT_ACCESS_TOKEN_DURATION_DAYS = 7

    Issued per device with a device_id claim. Up to 24 hours after it expires, refresh-app-token still trades it for a new one. After that you sign in again.

  • ChatGPT sign-incodex --auth-home

    Created by the Codex child process’s own login and kept in the auth home of the desktop profile. Phones get the profile’s name, email, plan, and usage limits over the relay, never the sign-in itself.

  • Claude sign-inclaude auth login --claudeai

    Created by Claude’s own sign-in command, which the desktop runs for one account folder. The desktop never reads the token. Phones get the account’s email, organization, plan, and usage windows over the relay.

HTTP entry pointPurpose
GET /auth/auth0/initiate-loginBegin account login.
GET /auth/auth0/callbackAuth0 redirects the browser here. Park the code and send the browser to a confirmation page.
GET /auth0/poll-statusNon-consuming poll; 204 with Cache-Control: no-store until the code is parked.
POST /auth0/finalize-loginValidate the Auth0 token and client ID, require a verified email, issue the HB Code JWT, grant first-login credits, then delete the poll key when the request carries its polling ID and CSRF token.
POST /api/auth0/refresh-app-tokenRefresh the HB Code app token. The only route that accepts an expired token.

Consuming the state key and deleting the poll key each run as one Lua script in Redis. The store is Redis rather than memory because every API process in the region shares it.

The HB Code JWT is HS256 with a shared secret, issuer hbcode, audience hbcode-api, and scope read write rpc. Its jti is checked against a revocation table on every request and again at the WebSocket upgrade, its device_id claim is compared to the X-Device-ID header, and it lives 7 days by default. The refresh route accepts a token for up to 24 hours after expiry, with issuer, audience, device binding, scopes, and revocation still enforced, runs in a per-user serialized transaction, and refuses to mint a token if the Auth0 subject or email no longer matches the stored user. The WebSocket upgrade captures a per-user registration fence before its revocation check, so a logout that completes between the HTTP check and relay registration invalidates the registration.

The server’s optional server-client credential pair is needed for server-side Auth0 refresh exchange. Configure the native app’s Auth0 domain, native client ID, audience, and callback settings together. The selected API region and the iOS authServerURL are distinct configuration paths, so verify both when creating a local environment.

What a region change or sign-out invalidates

Changing the region or signing out ends the previous attempt and clears its credentials. A late result from an old attempt cannot restore an account, because every result is bound to the attempt and the origin that started it.

Desktop login, logout, token refresh, and session reconciliation share one mutation lock, and reconciliation discards results when the token or origin changed during a request. Callback replay is rejected, and handoff acknowledgement is idempotent.

Agent accounts from a phone

Codex profiles and Claude accounts live on the desktop. A phone reads them and picks the active one through six relay methods in the system namespace. Four methods read. Two methods switch, and both are mutations that need an idempotencyKey.

MethodParams and result
system.codexAuthProfilesRead, no params. Returns profiles. Each entry has profileId, label, authMode, accountId, email, name, planType, workspaceName, workspaceKind, isAuthenticated, isActiveAgentChat, canDelete, ownedSessionLinkCount, and errorMessage.
system.activateCodexAuthProfileMutation, param profileId. Returns the same profiles list, read after the switch.
system.codexUsageLimitsRead, no params. Returns the limits of the active Codex profile: primary and secondary, each with usedPercent, windowMinutes, and resetsAt, plus credits, planType, and rateLimitReachedType.
system.claudeAccountsRead, no params. Returns accounts. Each entry has accountId, isDefault, isActive, signedIn, email, organization, planType, usageWindows, and errorMessage. A usage window has kind (session or weekly), model, usedPercent, and resetsAt.
system.activateClaudeAccountMutation, param accountId. Returns the same accounts list, read after the switch.
system.agentUsageHistoryRead, param agent with the value codex or claude. Returns agent, recordingSince, weeklySamples (sampledAt, usedPercent, resetsAt), dailyTokens, and tokenSummary.

The two switch methods accept only their one ID. An unknown field, a snake_case field, or a blank ID is an invalid-params error. system.agentUsageHistory rejects every agent value other than codex and claude.

A switch from the phone changes one setting, and the turn that already runs keeps its account
A switch from the phone changes one setting, and the turn that already runs keeps its accountTime runs from left to right in four lanes: phone, relay, desktop, and the Claude agent host. A Claude turn runs on account A. The phone sends system.activateClaudeAccount for account B with the key k1 through the relay. The desktop writes the active account setting and answers with the accounts list, and that reply is lost on the way to the phone. The phone sends the same call with the same key again. The desktop does not switch a second time and returns the stored answer, in which B is active. The host of the running turn keeps account A until the turn ends. The next turn starts a new host in the folder of account B.
Phone
Relaychecks the key
Desktopowns the setting
Claude hostone per turn
running turn: account A
next turn: account B
activate account B, key k1
writes the setting, stops nothing
reply lost
same call, key k1
no second switch
stored answer: B is active
new host in the folder of B
The phone sends only an account ID. No relay method starts or carries a sign-in.
Codex works the same way: system.activateCodexAuthProfile writes agent.chat.codex_profile_id, and no Codex child process stops.
  • Switch callsystem.activateClaudeAccount · system.activateCodexAuthProfile

    Params are one ID, accountId or profileId. Both methods are mutations. The relay refuses them without an idempotencyKey with error -32026.

  • Settingagent.chat.claude_account_id · agent.chat.codex_profile_id

    The switch is one settings write on the desktop. It stops no process. The desktop then emits agent-accounts-changed to its own window.

  • Stored answeridempotencyKey

    The desktop keeps the finished answer under the key. A retry with the same key and params gets it back, so a lost reply cannot cause a second switch.

  • Next turnCLAUDE_CONFIG_DIR

    A Claude turn reads the active account once, when it starts, and spawns its host with the folder of that account. A Codex switch also stops no child process. A running Codex turn keeps its profile, and the next Codex turn signs in with the active profile.

A switch is one settings write. It stops no process, and it changes only which sign-in the next turn uses. Session history does not move.

StepCodexClaude
CheckTakes the profile lifecycle lock and checks that the profile exists.Finds the account folder. An unknown account is an error.
WriteSets agent.chat.codex_profile_id.Sets agent.chat.claude_account_id. The default account has no folder of its own, so the switch to it deletes the setting. The switch also clears the cached Claude model catalog.
Running turnKeeps the profile pair it started with. A steer message goes to that same Codex child process.Keeps its host and its account folder until the turn ends.
Next turn of an existing sessionSigns in with the profile that is active when the turn is admitted. The session history stays in the profile that stores it. A turn is refused when the active profile is not signed in.Reads the setting once, when it starts, and runs on the active account. Every account reads and continues the same sessions.

After the write the desktop emits the event agent-accounts-changed with the agent name to its own window. The sidebar and the settings page read again. A failed emit is only logged, because the switch is already stored and the desktop window catches up at its next periodic read.

The relay refuses a switch without a key with error -32026. The desktop stores the finished answer under the key. A retry with the same key and the same params gets that stored answer, and the same key with other params is a conflict.

ReadFreshnessLimit
Claude accountsOne reading per account for 60 seconds. A reading runs claude auth status --json with a 15 second timeout and, for a signed-in account, one usage read.A failed read becomes errorMessage on that account. The list still returns. A finished Claude run drops the reading of its account.
Codex usage limitsOne cached value for 60 seconds, read through account/rateLimits/read on the active profile.The cache has no profile key and a switch does not clear it. For up to 60 seconds after a switch the phone can see the limits of the previous profile.
Usage history, weekly samplesThe desktop saves each fresh limit reading in the table agent_usage_samples. It saves only the two all-model windows, the five-hour session window and the weekly window.A reading that repeats the newest one within 10 minutes, with the same reset time and less than 0.5 percentage points of change, is skipped. Rows are kept for 35 days. The answer holds the last 14 days of the active account, oldest first.
Usage history, tokensCodex only. dailyTokens and tokenSummary come from account/usage/read. The desktop caches one answer for 5 minutes and drops it when the active profile changes. dailyTokens holds at most 30 days.Both fields are null for Claude and for a Codex sign-in without history, such as an API key.

Local execution still uses remote services

Tools and file edits run on the desktop, and a model request still sends the task context to the provider of the session’s agent: OpenAI for Codex, Anthropic for Claude. Mobile control sends requests and the returned workspace content through the regional server. Dictation sends a recording to the transcription service you configured, and the agent’s Gemini tools send text or video to Gemini under your key.

So “desktop-owned” describes where execution and files live. It is neither an offline mode nor an end-to-end encryption claim.