Phone apps
The same Connect your AI button, inside an iPhone or Android app. The approval screen opens in the phone's browser sheet. One tap to approve, then back to your app. No key in your app, no server to run.
What you need
- An app id
- Comes with your invite. Get one at useoutlet.dev.
- A return address
- Where the sheet sends the person back after they approve. A private
one like
com.yourapp:/outletworks on every phone. Register it with your app. - The SDK, or two calls
- React Native and Expo use the SDK. Swift and Kotlin make the same two calls to the vault.
Works on
- iPhone
- iOS 12 or newer with a private return address. A website return address needs iOS 17.4 or newer, a paid Apple developer account and your own website.
- Android
- Any phone with Chrome.
How it works
Start the connection
Ask the vault for an approval link. Keep the one-time secret it is tied to.
Open the link in the phone's browser sheet
Never a web view inside your app. iPhone: the system sign-in sheet. Android: Chrome's auth tab.
Take the code back
The sheet returns to your address with a short code. Check the state matches yours, then trade the code and your secret for the App key. Keys never travel in the link.
Keep the refresh token on the phone
Keychain on iPhone, Keystore on Android. It rotates on every refresh; save the new one each time.
Example code
iPhone (Swift)
func connect() async throws -> (grantID: String, keys: [String: String]) {
let verifier = try randomBase64url(32), state = try randomBase64url(16)
let challenge = base64url(Array(SHA256.hash(data: Data(verifier.utf8))))
let grant = try await post("/grants", ["app_id": appID, "providers": ["anthropic"], "redirect_uri": redirectURI,
"code_challenge": challenge, "code_challenge_method": "S256", "state": state])
guard let requestID = grant["grant_request_id"] as? String,
let grantURL = URL(string: grant["grant_url"] as? String ?? "") else { throw ConnectError.vault }
let callback = try await openSheet(grantURL)
let query = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
guard query.first(where: { $0.name == "state" })?.value == state,
let code = query.first(where: { $0.name == "code" })?.value else { throw ConnectError.stateMismatch }
let token = try await post("/grants/token", ["grant_request_id": requestID, "code": code, "code_verifier": verifier])
guard let keys = token["keys"] as? [String: String], let grantID = token["grantId"] as? String,
let refreshToken = token["refresh_token"] as? String else { throw ConnectError.vault }
try saveRefreshToken(refreshToken)
return (grantID, keys) // keys stay in memory; the id is not secret and addresses later refreshes
}
private func openSheet(_ url: URL) async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
// The scheme alone; the sheet catches the redirect itself, so no Info.plist URL type is needed.
// Apple lists this initializer as deprecated (no warning yet). iOS 17.4+ has
// init(url:callback:completionHandler:) with .customScheme, or .https(host:path:) for a website address.
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "com.yourapp") { url, error in
if let url, error == nil { continuation.resume(returning: url) }
else { continuation.resume(throwing: error ?? ConnectError.noCallback) }
}
session.presentationContextProvider = self
// iOS shows its own "wants to use useoutlet.dev to sign in" alert on each connect;
// session.prefersEphemeralWebBrowserSession = true would hide it but drop the person's existing sign-in.
guard session.canStart else { continuation.resume(throwing: ConnectError.sheetDidNotOpen); return }
_ = session.start()
}
}
Full file: examples/phones/ios-swift/Connect.swift, in the repo at launch.
Android (Kotlin)
// Auth Tab wants its launcher registered before the activity is created. private val authTab = AuthTabIntent.registerActivityResultLauncher(this) { result -> if (result.resultCode == AuthTabIntent.RESULT_OK) result.resultUri?.let(::exchange) } fun connect() = thread { // the platform HTTP client blocks, so never on the main thread verifier = b64url(ByteArray(32).also { SecureRandom().nextBytes(it) }) state = b64url(ByteArray(16).also { SecureRandom().nextBytes(it) }) val challenge = b64url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())) val grant = post("/grants", JSONObject().put("app_id", "app_yourapp").put("state", state) .put("providers", JSONArray().put("anthropic")) .put("redirect_uri", "com.yourapp:/outlet") // byte for byte what you registered .put("code_challenge", challenge).put("code_challenge_method", "S256")) grantRequestId = grant.getString("grant_request_id") val grantUrl = Uri.parse(grant.getString("grant_url")) runOnUiThread { // the system browser, never a WebView: their session already lives there val browser = CustomTabsClient.getPackageName(this, null) // pinned below, so the check and the tab agree if (browser != null && CustomTabsClient.isAuthTabSupported(this, browser)) AuthTabIntent.Builder().build().apply { intent.setPackage(browser) }.launch(authTab, grantUrl, "com.yourapp") else CustomTabsIntent.Builder().build().apply { intent.setPackage(browser) }.launchUrl(this, grantUrl) // see onNewIntent } } override fun onNewIntent(intent: Intent) { // Custom Tabs fallback; singleTask routes the redirect here while we are alive super.onNewIntent(intent) intent.data?.let(::exchange) } private fun exchange(redirect: Uri) = thread { if (redirect.getQueryParameter("state") != state) return@thread // not our request: drop it val code = redirect.getQueryParameter("code") ?: return@thread val session = post("/grants/token", JSONObject().put("grant_request_id", grantRequestId) .put("code", code).put("code_verifier", verifier)) anthropicKey = session.getJSONObject("keys").getString("anthropic") // Sealed with an AndroidKeyStore key before it touches disk (RefreshTokenStore.kt); the grant id, // not secret, rides along to address later refreshes. The token rotates: overwrite it each time. RefreshTokenStore.save(this, session.getString("grantId"), session.getString("refresh_token")) }
Full files: examples/phones/android-kotlin/Connect.kt and RefreshTokenStore.kt, in the repo at launch.
React Native and Expo
// Hermes has no Web Crypto, so the SDK takes its two primitives from expo-crypto. const crypto: PkceCrypto = { getRandomValues: (buf) => Crypto.getRandomValues(buf), sha256: (data) => Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(data)), }; // React Native's URL has not always parsed search params; three lines do. const param = (url: string, key: string) => decodeURIComponent(url.match(new RegExp(`[?&]${key}=([^&#]*)`))?.[1] ?? ""); export async function connect(appId: string, providers: string[], redirectUri: string) { const grant = await createGrant({ appId, providers, redirectUri, crypto }); // The system browser sheet, never a web view. const result = await WebBrowser.openAuthSessionAsync(grant.grantUrl, redirectUri); if (result.type !== "success") { throw new Error(`Outlet grant sheet closed: ${result.type}`); } // A code under a state this call did not issue belongs to another flow. const code = param(result.url, "code"); if (!code || param(result.url, "state") !== grant.state) { throw new Error("Outlet: state mismatch"); } const session = await exchangeCode({ grantRequestId: grant.grantRequestId, code, codeVerifier: grant.verifier, }); // Only the refresh token is persisted; session.keys stay in memory. // It rotates on every refresh, so save the new one each time. await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, session.refreshToken, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, }); return session; }
Full file: examples/phones/react-native/connect.ts, in the repo at launch.
Flutter: the same two calls to the vault, with flutter_web_auth_2 for the sheet.
Capacitor: use a plugin that opens the system sheet; the plain Browser plugin cannot hand the code back.