Claude AI apps that run on your own Claude subscription
Most write-ups on this topic either list Anthropic’s own Claude apps or round up Claude wrappers that quietly bill an API key. mk0r does something different. It is an app builder that accepts your Claude.com OAuth session, the same PKCE flow the Claude CLI uses, and treats the rotated refresh tokens correctly so your plan stays connected past the first hour.
What most write-ups about this topic get wrong
If you open the pages that currently cover this query, they fall into one of two piles. The first pile is a catalog of Anthropic’s own surfaces: the claude.com web app, the Claude desktop app, the iOS app, and the Android app. Those are Claude’s own products. They are where you chat with Claude. They are not where you build apps with Claude.
The second pile is a round-up of third-party Claude wrappers. Each one has its own account, its own billing page, and its own ANTHROPIC_API_KEY sitting server-side. When you use them, your Claude.com Pro subscription is not involved. You are paying twice: once for the plan you already have, and once as metered credits hidden inside the wrapper’s pricing.
Neither pile discusses what it takes for a builder to accept your actual Claude session. The answer is a specific PKCE flow, a specific beta header, and a specific piece of housekeeping for single-use refresh tokens. Everything below is that.
How a chat turn chooses between the shared key and your plan
The anchor fact: the PKCE constants are checked in, line for line
Every bring-your-own-Claude implementation stands on a handful of constants. The client id, the authorize URL, the token endpoint, the redirect URL used for the manual paste flow, the scope, and the beta header that Anthropic requires on inference calls made with an OAuth token. If any one of those drifts, the flow stops working silently. mk0r keeps them in plain sight in src/lib/claude-oauth.ts so they can be audited and monitored.
CLIENT_ID: 9d1c250a-e61b-44d9-88ed-5944d1962f5e. Matches the client id embedded in the published Claude Code CLI binary as of the April 2026 verification run.AUTHORIZE_URL: https://claude.com/cai/oauth/authorize. This is where the user lands when the server hands back a PKCE-hardened URL.TOKEN_URL: https://platform.claude.com/v1/oauth/token. The same endpoint is used for the initial exchange and for refreshes, with different grant_type values.MANUAL_REDIRECT_URL: https://platform.claude.com/oauth/code/callback. The web app cannot receive a cross-origin callback, so the user copies a code off that page and pastes it back into mk0r.SCOPE: user:inference. This is what separates an inference token from a full claude.com session.BETA_HEADER: oauth-2025-04-20. Required on every inference call made with an OAuth token. Leave it off and the token is valid but rejected.
None of that is a theoretical diagram. Open the file and the values are right there, typed as as const so TypeScript can narrow them at the call site.
Storage: Firestore, encrypted, collection-scoped to your uid
After the code-for-token exchange succeeds, the tokens hit a Firestore collection called user_claude_credentials, keyed by your Firebase UID. Before the write, the JSON blob is encrypted with AES-256-GCM using a 32-byte hex key from CLAUDE_OAUTH_ENC_KEY. The encrypt helper prepends a 12-byte IV and a 16-byte auth tag to the ciphertext, then base64-encodes the whole thing. A sibling boolean, connected, plus the expiresAt and scopes, lives unencrypted on the same doc so the UI can show connection state without decrypting anything.
collection: user_claude_credentials
Path in Firestore. One document per Firebase uid, never shared. Deleted cleanly when the user disconnects via /api/claude/oauth/disconnect.
payload: AES-256-GCM blob
Encrypted JSON of { accessToken, refreshToken, expiresAt, scopes }. Server refuses to write if CLAUDE_OAUTH_ENC_KEY is missing or wrong length.
connected: boolean
Unencrypted flag for UI. Lets the settings page show a green dot without paying the cost of a decrypt round trip.
expiresAt: ISO 8601
Unencrypted, so loadAndRefreshTokens can decide whether to refresh without decrypting. Refresh margin is 5 minutes.
scopes: string[]
Always ["user:inference"] for this flow. Surfaced in the UI so you can see what you granted.
updatedAt: epoch ms
Bumped on every save, including reverse-syncs after VM rotations. Useful for debugging token drift without touching the payload.
The trap: Claude refresh tokens are single-use
This is the piece nobody else writing about Claude app builders flags. When the Claude token endpoint returns a new access token, it also returns a new refresh token, and it invalidates the refresh token you used to get there. If you cache the original refresh token in your database and never update it, the first time the Claude CLI inside a VM auto- refreshes, your database copy rots.
mk0r runs Claude Code inside an E2B sandbox. That CLI has its own auto-refresh loop: it hits the Claude API, gets a 401, trades its refresh token for a new pair, and writes the new credentials to ~/.claude/.credentials.json on the VM’s disk. After that moment, the VM is the source of truth and Firestore is stale.
The fix is a reverse sync. After every chat turn, the server calls readCredentialsFromVm(acpUrl) which POSTs to the ACP bridge’s /credentials/read endpoint, reads the live tokens out of the credentials file, and hands them to syncTokensFromVm. That helper only upserts if the VM’s expiresAt is strictly newer than Firestore’s. No tie-breaker wins on equality. If the VM never refreshed during the turn, nothing is written.
One chat turn with an OAuth user, end to end
Shared-key path vs. BYOK path, and how the router decides
The chat route always tries to load your OAuth blob first. If you are anonymous, the Firebase auth check short-circuits and the shared key is used. If you are signed in but never connected Claude, the load returns null and the shared key is used. If you are signed in and connected, apiKey is explicitly set to the empty string and the ACP subprocess is spawned with an oauthToken built by buildCredentialsJson(tokens). That oauthToken is the exact JSON blob claude-agent-acp expects to read from the credentials file on disk.
Same prompt, two payment paths
The wrapper has its own account and its own ANTHROPIC_API_KEY env var on the server. Every prompt you send runs against that key. Your Claude.com Pro subscription is untouched. You pay the wrapper's monthly plan plus a soft ceiling on usage that is actually metered Anthropic credits.
- Needs a wrapper account on top of your Claude plan
- Claude Pro or Max subscription sits idle
- Usage is metered by the wrapper, not by your plan
- No way to use the quota you already paid for
0 token
of Anthropic spend on the shared key, once you connect.
Why the “manual paste” redirect exists
The standard OAuth approach is a server-side callback at your own domain. Claude.com’s PKCE flow supports that when the redirect URL is registered for your client id. mk0r instead reuses the CLI’s manual redirect at platform.claude.com/oauth/code/callback, which renders the code on a success page. The user copies it back into the UI and the exchange runs from the client.
Two reasons. First, the CLI client id is the one the authorize endpoint actually trusts for Pro and Max users right now. Second, the manual flow means no cross-origin callback handshake to maintain, which would need the redirect URL registered on Anthropic’s side for every domain mk0r ever deploys to.
What Claude does once the token is inside the VM
The ACP bridge reads the credentials JSON and spawns the Claude CLI process with them. The default app-builder system prompt pins it to a Vite + React + TypeScript + Tailwind v4 project at /app, with the dev server running on port 5173 and HMR enabled. Playwright MCP is attached to a headful Chromium launched against an Xvfb virtual display on :99, with its remote debugging port at 9222. Claude opens its own work in the browser and asserts against it, no human testing round trip required.
Every prompt you send becomes a real git commit inside /app, which is why undo, redo, and jump-to-SHA are first-class operations. A single app is a git branch, not a chat thread with a diff reconstructed at display time.
How mk0r handles Claude auth vs. a typical Claude wrapper
| Feature | Typical Claude wrapper | mk0r |
|---|---|---|
| Works with a Claude.com Pro or Max plan | No, the wrapper has its own key | Yes, via PKCE OAuth |
| Anonymous users can still try it | Usually, but billed against the wrapper | Yes, shared key fallback is built in |
| Token storage | Single shared env var on the server | AES-256-GCM in user_claude_credentials, per-uid |
| Refresh-token rotation | Not applicable (no per-user refresh) | syncTokensFromVm reverse-syncs on every turn |
| Beta header on inference | Not needed, but not supported either | anthropic-beta: oauth-2025-04-20 attached automatically |
| Disconnect path | Cancel a subscription | POST /api/claude/oauth/disconnect deletes the doc |
When this architecture is the wrong answer
If you are building a product for pure anonymous use, or if you need a ceiling you control on model spend, the shared-key path is the right default. The OAuth path earns its complexity only when users show up with an existing relationship with Anthropic that they want to keep using. In that case, the cost is one PKCE exchange and one reverse-sync hook, and the payoff is a genuinely honest answer to the question “do you bill me for Claude usage?”: no, because you bill yourself, through the plan you already have.
You can verify the whole thing without trusting a marketing blurb. The constants are in src/lib/claude-oauth.ts, the encryption and reverse-sync are in src/lib/claude-oauth-store.ts, and the call sites are in src/app/api/chat/route.ts around lines 174 and 970.
Want to connect your Claude plan on a live call?
Twenty minutes, an OAuth round trip on screen, and a working app at the end.
Frequently asked questions
What does 'Claude AI apps' mean in the context of mk0r?
Two things. First, the apps you build in mk0r: Vite + React + TypeScript + Tailwind v4 projects authored by Claude inside an E2B sandbox, live on a real dev server, driven by the Anthropic ACP bridge. Second, where Claude's compute comes from: either a shared Anthropic API key (for anonymous users) or, if you sign in with Google and connect your Claude.com account, your own Pro or Max subscription via OAuth. The second mode is what separates mk0r from every Claude wrapper that silently bills a metered key.
How is the OAuth flow wired, exactly?
mk0r implements the same PKCE flow the Claude Code CLI uses. The client calls POST /api/claude/oauth/start, the server generates a PKCE verifier and challenge, builds an authorize URL at https://claude.com/cai/oauth/authorize with client_id 9d1c250a-e61b-44d9-88ed-5944d1962f5e, scope user:inference, and the manual redirect URL https://platform.claude.com/oauth/code/callback. The user approves on claude.com, copies the code back, and the client posts it to /api/claude/oauth/exchange. The server trades the code + verifier for access and refresh tokens at https://platform.claude.com/v1/oauth/token. All of that lives in src/lib/claude-oauth.ts with the constants spelled out at the top of the file.
Where do the tokens live after the exchange?
In a Firestore collection named user_claude_credentials, keyed by your Firebase UID. Before anything hits Firestore, the payload is encrypted with AES-256-GCM using a 32-byte hex key from the CLAUDE_OAUTH_ENC_KEY env var. The encrypt() helper in src/lib/claude-oauth-store.ts prepends a 12-byte IV and the 16-byte auth tag to the ciphertext, then base64-encodes the blob. If the env var is missing or the wrong length, saveTokens refuses to write, so tokens never sit in Firestore as plaintext.
What about refresh tokens, do they rotate?
Yes, every refresh rotates the refresh token, and the old one is invalidated. This is the trap that breaks naive bring-your-own-Claude integrations. When the Claude CLI runs inside the VM and hits a 401, it auto-refreshes and writes the new credentials to ~/.claude/.credentials.json on disk. If the server kept the original refresh token in its database, the next cold boot would try to push an already-invalidated token back into the VM and auth would fail. mk0r handles this with syncTokensFromVm, called from the chat route after every turn as a fire-and-forget: it fetches the live credentials from the VM via POST /credentials/read, compares expiresAt, and only upserts if the VM's copy is strictly newer.
What happens on a turn when I am signed in but my tokens are near expiry?
loadAndRefreshTokens runs on the server at the top of the chat route. If expiresAt is less than five minutes away (REFRESH_MARGIN_MS), it calls refreshAccessToken against https://platform.claude.com/v1/oauth/token, persists the new tokens, and hands them to the chat path. The refreshed blob is then converted to the credentials JSON shape that claude-agent-acp reads from ~/.claude/.credentials.json inside the VM, so the ACP subprocess restarts with a live token.
Do I need a Claude subscription to try mk0r?
No. Anonymous users fall through to the shared ANTHROPIC_API_KEY path: the landing page is open, the pool of ready E2B sandboxes is topped up on mount, and the first prompt streams against that shared key. Signing in with Google and connecting your Claude account is additive, not required. If you do connect, oauthToken is preferred over the shared key on every turn, and apiKey is set to the empty string when the ACP subprocess is spawned.
What is the beta header on inference calls, and why does it matter?
Inference calls made with a Claude OAuth token require the request header anthropic-beta: oauth-2025-04-20. mk0r exposes that as CLAUDE_OAUTH.BETA_HEADER in src/lib/claude-oauth.ts. The ACP bridge picks it up during initialize; without it, Claude.com rejects OAuth-authenticated inference even if the token is valid. Anthropic has rotated these constants in the past, so src/lib/oauth-alert.ts fires an email and scripts/verify-claude-oauth.ts exists as a verification script if the token endpoint starts returning unexpected statuses.
What about the apps themselves, what do they look like after Claude builds them?
Claude edits /app/src/ inside the sandbox. The stack is Vite + React + TypeScript with Tailwind v4 pre-configured and the dev server running on port 5173 with HMR. Playwright MCP is attached to a headful Chromium with CDP on :9222, so Claude can open http://localhost:5173 and verify its own work. Every prompt turn is committed on the git repo inside /app, which is why undo, redo, and jump-to-SHA are first-class operations and not diffs Claude re-derives from history.
Build a Claude AI app on the plan you already pay for
Sign in with Google, connect Claude with one paste, and your next prompt runs against your Pro or Max subscription.
Open mk0r