Signing in and creating a client
Create an API client, sign in with a password, a browser, a device, or a token.
Part of SDK Functions. Every call below is shown in each language that has it.
Create An API Client
Section titled “Create An API Client”Use this before hub discovery or identity provisioning.
from thalovant import ThalovantControlPlane
api = ThalovantControlPlane()const api = new ThalovantControlPlane();api := thalovant.NewDefaultControlPlane("")use thalovant::ControlPlane;
let mut api = ControlPlane::default();Sign In
Section titled “Sign In”Sign in before private control-plane actions such as creating a client identity.
Accounts with multi-factor authentication enabled must include a TOTP code or a one-time recovery code; without one, the API rejects the sign-in with HTTP 401 and code mfa_required. Every SDK sends the MFA fields only when they are set.
# MFA-enabled accounts add a TOTP code or a one-time recovery code:
// MFA-enabled accounts add a TOTP code or a one-time recovery code:if err != nil { log.Fatal(err)}
// MFA-enabled accounts use LoginWithOptions with OTPCode or RecoveryCode: OTPCode: "123456",})if err != nil { log.Fatal(err)}
// MFA-enabled accounts use login_with_options with otp_code or recovery_code:use thalovant::LoginOptions;
api.login_with_options( "password", LoginOptions { otp_code: Some("123456".into()), ..Default::default() },).await?;
// MFA-enabled accounts add a TOTP code or a one-time recovery code:
// MFA-enabled accounts add a TOTP code or a one-time recovery code:try await api.login(email: "[email protected]", password: "password", recoveryCode: "abcd-efgh-ijkl")
// MFA-enabled accounts add a TOTP code or a one-time recovery code:Common failure:
Missing Thalovant API access tokenFix it by signing in before the private call, or by passing an API token to the control-plane client. An HTTP 401 with code mfa_required means the account needs the TOTP or recovery code shown above.
Sign In Without A Password
Section titled “Sign In Without A Password”Use device login when the code should never see your password: CLIs, agents, notebooks, and machines you do not fully trust with account credentials.
The SDK prints a line like To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXX, opens that page in your browser when it can, and polls the API while you approve the sign-in with your normal browser session, including Google sign-in or MFA. On approval the SDK receives a scoped, revocable API token and stores it exactly like a password login.
Approving a device sign-in needs a paid workspace plan; on a free plan the approval is rejected with HTTP 402. Device login needs Python 0.4.22, Node.js 0.2.25, Go v0.3.3, Rust v0.2.20, or Kotlin, Swift, and .NET 0.1.1.
api.login_with_browser()
# Optional: request narrower scopes and label the token in the dashboard.api.login_with_browser(scopes=["hubs:read", "clients:write"], client_name="my-cli")// Prints: To sign in, visit https://dash.thalovant.com/activate and enter the code XXXX-XXXXconst token = await api.loginWithBrowser({ clientName: "my-laptop" });
// api.accessToken is now set, exactly like after api.login(...).token, err := api.LoginWithBrowser(ctx, thalovant.DeviceLoginOptions{ Scopes: []string{"hubs:read", "clients:write"}, // optional ClientName: "my-cli", // optional label in the dashboard})if err != nil { log.Fatal(err)}fmt.Println("signed in, token id:", token["token_id"])use thalovant::DeviceLoginOptions;
let token = api .login_with_browser(DeviceLoginOptions { scopes: vec!["hubs:read".into(), "clients:write".into()], client_name: Some("my-tool".into()), ..Default::default() }) .await?;println!("signed in, token scopes: {}", token["scopes"]);api.loginWithBrowser( DeviceLoginOptions(scopes = listOf("hubs:read"), clientName = "kotlin-demo"),)let token = try await api.loginWithBrowser(options: DeviceLoginOptions( scopes: ["hubs:read", "clients:write"], clientName: "my-macbook"))print(token.tokenId ?? "", token.scopes)var result = await api.LoginWithBrowserAsync(new DeviceLoginOptions{ Scopes = new[] { "hubs:read", "clients:write" }, ClientName = "my-tool",});Console.WriteLine($"{result.TokenId} expires {result.ExpiresAt}");Every SDK honors the same options: scopes to narrow what the token can do, client_name (native casing) to label the token in the dashboard, open_browser to skip opening the page, prompt to replace the printed line, and a timeout that defaults to 15 minutes.
The approving user manages the resulting token on the dashboard’s API Tokens page: scopes, expiry, last use, and revocation.
Sign In From A Phone Or Desktop App
Section titled “Sign In From A Phone Or Desktop App”Use native sign-in when the code is the app the person is looking at: an Android or iOS app, or a desktop client. The app opens the browser, the person signs in with their normal session, and the browser hands the app back an authorization code — no password ever reaches your process, and no device code has to be typed.
Device login is the right choice for a CLI on a machine with no browser. Native sign-in is the right choice when the app can open one and receive a redirect.
The flow is OAuth 2.0 authorization code with PKCE (RFC 7636, RFC 8252, RFC 9700), and it is three calls:
- Begin. The SDK builds the authorization URL and hands back two secrets to keep: a
stateand a PKCEverifier. - Open and wait. The app opens that URL and waits for its registered redirect.
- Complete. The app pulls the code out of the redirect and exchanges it, sending the verifier — which never entered the browser.
Native sign-in needs Python 0.7.6, Node.js, Kotlin, Swift or .NET 0.7.2, Go v0.9.2, or Rust 0.9.1.
from thalovant.native_auth import begin_native_sign_in
begun = begin_native_sign_in( client_id="my-app", redirect_uri="myapp://auth",)webbrowser.open(begun.authorization_url)
# ... the browser comes back to myapp://auth?code=...&state=...code = begun.code_from(redirect)if code: api.complete_native_sign_in(code, begun.verifier, "my-app", "myapp://auth")const begun = await beginNativeSignIn({ clientId: "my-app", redirectUri: "myapp://auth",});open(begun.authorizationUrl);
// ... the browser comes back to myapp://auth?code=...&state=...const code = codeFrom(begun, redirect);if (code) { await api.completeNativeSignIn(code, begun.verifier, "my-app", "myapp://auth");}beginNativeSignIn is async because it hashes the PKCE challenge with Web Crypto, which keeps the browser bundle working.
begun, err := thalovant.BeginNativeSignIn(thalovant.NativeSignInOptions{ ClientID: "my-app", RedirectURI: "myapp://auth",})// open begun.AuthorizationURL
// ... the browser comes back to myapp://auth?code=...&state=...if code, ok := begun.CodeFrom(redirect); ok { token, err := plane.CompleteNativeSignIn(ctx, code, begun.Verifier, "my-app", "myapp://auth")}use thalovant::native_auth::{begin_native_sign_in, NativeSignInOptions};
let begun = begin_native_sign_in(NativeSignInOptions { client_id: "my-app".into(), redirect_uri: "myapp://auth".into(), scopes: None, dashboard_url: None,})?;// open begun.authorization_url
// ... the browser comes back to myapp://auth?code=...&state=...if let Some(code) = begun.code_from(&redirect) { plane.complete_native_sign_in(&code, &begun.verifier, "my-app", "myapp://auth").await?;}val begun = NativeSignIn.begin( clientId = "my-app", redirectUri = "myapp://auth",)// open begun.authorizationUrl
// ... the browser comes back to myapp://auth?code=...&state=...val code = begun.codeFrom(redirect)if (code != null) { plane.completeNativeSignIn(code, begun.verifier, "my-app", "myapp://auth")}let begun = try NativeSignIn.begin( clientID: "my-app", redirectURI: "myapp://auth")// open begun.authorizationURL
// ... the browser comes back to myapp://auth?code=...&state=...if let code = begun.code(from: redirect) { try await plane.completeNativeSignIn( code: code, verifier: begun.verifier, clientID: "my-app", redirectURI: "myapp://auth" )}var begun = NativeSignIn.Begin( clientId: "my-app", redirectUri: "myapp://auth");// open begun.AuthorizationUrl
// ... the browser comes back to myapp://auth?code=...&state=...var code = begun.CodeFrom(redirect);if (code is not null){ await plane.CompleteNativeSignInAsync(code, begun.Verifier, "my-app", "myapp://auth");}The default scopes are hubs:read, clients:read and clients:write — enough for an app to list hubs and manage its own client identity. Pass scopes to narrow them.
What the SDK refuses, and why
Section titled “What the SDK refuses, and why”These are not suggestions the caller can opt out of. They are the reason the flow is safe on a device where another app may be listening for the same redirect.
S256only.plainis refused by the API, and the SDKs do not offer it — there is no way to ask for the weaker challenge.- The verifier never enters the browser. It goes to the token endpoint and nowhere else. That is what makes an intercepted code useless to whoever intercepted it.
- The redirect must arrive where the attempt asked it to.
stateproves the answer belongs to this request; the SDK also compares the redirect’s scheme, host and path against the one the attempt registered. A callback that arrives at some other address is not this attempt’s, however good its state looks. - A refusal that carries a code is still a refusal. A redirect with
error=returns no code, even when acode=is present alongside it. - The token exchange refuses cleartext. Sending a verifier and code over plain HTTP would put both on the wire (CWE-319); the exchange requires HTTPS outside explicit loopback development endpoints.
- The dashboard URL is validated. It must be a Thalovant URL, and a URL carrying a fragment is refused — a fragment would put every parameter where a browser never sends it.
code_from (native casing) returns nothing rather than raising on a state mismatch, a missing code, or an error= response. All three mean “do not continue”, and a caller that handles them alike cannot accidentally treat one as success.
Do not retry a failed exchange with the same code. A code presented twice revokes the token the first exchange minted (RFC 9700), so a retry destroys the token it is trying to obtain. Start again from the begin call.
Authenticate With An API Token
Section titled “Authenticate With An API Token”Use a stored API token when the process should start authenticated, such as CI jobs, servers, and AI agent configs.
Mint a token on the dashboard’s API Tokens page or through device login, then pass it to the control-plane constructor. Tokens are scoped, revocable, and show their last use on that page; creating one needs a paid workspace plan. The examples read the token from a THALOVANT_API_TOKEN environment variable, the same convention the MCP server uses.
from thalovant import ThalovantControlPlane
api = ThalovantControlPlane(access_token=os.environ["THALOVANT_API_TOKEN"])# Ready immediately; no login call needed.const api = new ThalovantControlPlane("https://api.thalovant.com", { accessToken: process.env.THALOVANT_API_TOKEN,});
// Ready immediately; no login call needed.api := thalovant.NewDefaultControlPlane(os.Getenv("THALOVANT_API_TOKEN"))use thalovant::ControlPlane;
let token = std::env::var("THALOVANT_API_TOKEN").expect("THALOVANT_API_TOKEN is set");let api = ControlPlane::with_access_token(token);val api = ThalovantControlPlane(accessToken = System.getenv("THALOVANT_API_TOKEN"))let api = ThalovantControlPlane(accessToken: ProcessInfo.processInfo.environment["THALOVANT_API_TOKEN"])var api = new ThalovantControlPlane(accessToken: Environment.GetEnvironmentVariable("THALOVANT_API_TOKEN"));