Client SDK · Start here

Authentication.

Browser PKCE, device flow, token persistence, and API keys for the Python client SDK.

The SDK provides a managed auth tier: on successful login, a TokenManager is auto-configured as the client's token provider. Every subsequent request automatically carries a managed access token with single-flight, skew-aware auto-refresh — and a 401 response triggers one force_refresh + retry before the failure surfaces.

OAuth client IDs

The following OAuth client ID is currently available for public integrations:

Client IDUse caseNotes
matilda-codePublic CLI / SDK / agent integrationsPKCE + device flow. The default choice for most integrations.

matilda-code is the only publicly accessible client ID at the moment. Additional client IDs will be documented here as access opens up.

auth.login_with_browser(...)

Managed PKCE browser login (RFC 8252 loopback). Starts a temporary local server, opens the browser, receives the auth code, exchanges it for tokens, and auto-wires a TokenManager.

Python
async def main():
    async with MatildaClient() as client:
        tokens = await client.auth.login_with_browser(
            client_id="matilda-code",
            open_browser=lambda url: print(f"Open: {url}"),
        )
        print(tokens.access_token)

Parameters

Fieldtypedescription
client_idstrOAuth client alias (e.g. 'matilda-code').
scopestrSpace-separated OAuth scopes. Defaults to 'openid offline_access'.
identity_provider_idstr | NoneRoute straight to a federated IdP (e.g. Google SSO) instead of the hosted login page.
callback_portint | NoneFixed loopback port. Recommended for FusionAuth redirect validation. Defaults to a random ephemeral port.
timeout_msintHow long to wait for the browser callback. Defaults to 300_000 ms (5 min).
open_browserCallable[[str], None | Awaitable[None]] | NoneCalled with the authorize URL. If omitted, caller handles browser opening.
success_redirectstrURL the browser is 302-redirected to on success. Defaults to https://matilda.maincode.com/cli/signed-in.
error_redirectstr | NoneURL for the error case; otherwise a bare 400 text response.
token_storeStorageAdapter | NoneCustom token persistence. Defaults to in-memory storage.
token_lockTokenLock | NoneCross-process critical-section lock for token refresh (e.g. from create_file_token_store).
on_eventCallable[[LoginFlowEvent], None] | NoneSubscribe to login flow state events.
httphttpx.AsyncClient | NoneOverride HTTP transport (testing, custom clients).

Returns

TokenSet — the token set from the login flow. The TokenManager is also auto-configured on the client instance.

auth.login_with_device_flow(...)

Managed RFC 8628 device flow. Requests a device code, prints the user code and verification URL to stderr (by default), and polls until the user authorises.

Python
async def main():
    async with MatildaClient() as client:
        tokens = await client.auth.login_with_device_flow(
            client_id="matilda-code",
            on_event=lambda e: (
                print(f"Visit {e.verification_uri} and enter code: {e.user_code}")
                if e.type == "user_code"
                else None
            ),
        )

If no on_event handler is provided, the SDK prints the user code and verification URL to stderr automatically (default_device_on_event).

Parameters

Fieldtypedescription
client_idstrOAuth client alias.
scopestrSpace-separated OAuth scopes. Defaults to 'openid offline_access'.
token_storeStorageAdapter | NoneCustom token persistence. Defaults to in-memory storage.
token_lockTokenLock | NoneCross-process lock for token refresh.
on_eventCallable[[LoginFlowEvent], None] | NoneSubscribe to login flow events. If omitted, prints user code to stderr. Defaults to default_device_on_event.
httphttpx.AsyncClient | NoneOverride HTTP transport.

Returns

TokenSet

auth.refresh_token(refresh_token, client_id)

Manually refresh an access token using a refresh token. This bypasses the TokenManager — use it only when you need raw token exchange.

Python
tokens = await client.auth.refresh_token(old_refresh_token, "matilda-code")
Fieldtypedescription
refresh_tokenstrThe refresh token to exchange.
client_idstrOAuth client alias.

Returns TokenSet.

auth.get_tokens()

Returns the current token set from the managed TokenManager, or None if not authenticated.

Python
tokens = await client.auth.get_tokens()
if tokens:
    print(f"Token expires at epoch ms: {tokens.expires_at}")

Returns TokenSet | None.

auth.logout()

Clears the token store, destroys the TokenManager, and disconnects the client's token provider.

Python
await client.auth.logout()

Returns None.

Token persistence

By default, tokens are stored in memory (MemoryStorage()). For cross-process persistence (e.g. CLI sessions), use create_file_token_store:

Python
from pathlib import Path

from matilda_client import MatildaClient, create_file_token_store


async def main():
    token_path = Path.home() / ".matilda" / "tokens.json"
    token_store = create_file_token_store(token_path)

    async with MatildaClient() as client:
        await client.auth.login_with_browser(
            client_id="matilda-code",
            token_store=token_store.store,
            token_lock=token_store.lock,
        )

The file store uses a 0600 JSON file with a lockfile-based single-writer lock to prevent cross-process refresh races.

StorageAdapter protocol

Python
class StorageAdapter(Protocol):
    def get(self, key: str) -> str | None | Awaitable[str | None]: ...
    def set(self, key: str, value: str) -> None | Awaitable[None]: ...
    def remove(self, key: str) -> None | Awaitable[None]: ...

Implement this (sync or async) to store tokens in a database, keychain, or any custom backend.

TokenManager

Python
manager = TokenManager(
    token_endpoint="https://matilda.maincode.com/api/auth/oauth/token",
    client_id="matilda-code",
    store=...,
)

await manager.get_access_token(force_refresh=False)  # -> str
await manager.get_tokens()                           # -> TokenSet | None
await manager.set_tokens(tokens)                     # -> None
await manager.clear()                                # -> None

Also constructible via the create_token_manager(**kwargs) factory. The SDK auto-creates one on login. Refresh is single-flight per instance, evicts the session on invalid_grant, and re-reads the store inside the optional TokenLock so a process that lost a cross-process refresh race sees the winner's fresh token.

TokenSet

Python
@dataclass(frozen=True)
class TokenSet:
    access_token: str
    expires_at: int              # epoch milliseconds
    refresh_token: str | None
    id_token: str | None         # OIDC id_token when 'openid' scope is granted

TokenSet.to_json_dict() / TokenSet.from_json_dict(...) serialise with camelCase keys — the on-disk JSON contract is shared with the TypeScript SDK, so token stores can be exchanged between the two.

AuthError class

Python
class AuthError(MatildaError):
    code: str         # OAuth error code (e.g. 'invalid_grant', 'authorization_pending')
    retryable: bool   # True for transient 5xx/network; False for revoked tokens

LoginFlowEvent

Three frozen dataclasses, each with a type discriminator:

Python
# StateEvent        (type="state")         — status: 'idle' | 'awaiting_user' | 'exchanging' | 'authenticated' | 'error'
# AuthorizeUrlEvent (type="authorize_url") — url: str
# UserCodeEvent     (type="user_code")     — user_code, verification_uri, verification_uri_complete

Standalone auth helpers

The matilda_client.auth module provides the raw PKCE and device-flow protocol helpers without the managed TokenManager. The managed client.auth.* methods cover most integrations, but these remain available for integrators who need direct protocol access:

FunctionDescription
fetch_auth_server_metadata(issuer)RFC 8414 metadata discovery.
begin_login(authorization_endpoint, ...)Stateless "begin" half of a redirect/BFF login. Returns BeginLoginResult.
complete_login(token_endpoint, ...)Stateless "complete" half — validates state, exchanges the code.
refresh_token_grant(token_endpoint, client_id, refresh_token)Raw token refresh. Returns TokenSet.
request_device_code(device_authorization_endpoint, ...)Start a device flow. Returns DeviceAuthorization.
poll_device_token(token_endpoint, ...)Poll until the user authorises or the code expires.
create_pkce_pair()Generate PKCE code_verifier + code_challenge (S256).
build_authorize_url(authorization_endpoint, ...)Construct the authorize URL.
assert_state(received, expected)Validate the OAuth state round-trip (CSRF guard).
random_state()High-entropy OAuth state value.
base64_url_encode(data)Padding-free base64url (RFC 4648 §5).

The matilda_client.auth_local module adds the local-machine adapters on top of the protocol core:

Function / classDescription
run_loopback_login_flow(...)Headless loopback login (PKCE + RFC 8252 redirect).
run_device_login_flow(...)Headless device-flow login (RFC 8628).
create_loopback_receiver(...)RFC 8252 single-use loopback redirect receiver.
create_file_token_store(file_path)0600 JSON file store with cross-process lock.
default_device_on_event(event)Default device-flow event handler (prints code to stderr).
memory_storage() / MemoryStorageIn-memory StorageAdapter.
create_token_manager(**kwargs)Per-session token manager with single-flight refresh.

API Key Management

The SDK provides methods for managing mc_live_ API keys. These hit the same JWT-protected endpoints the developer dashboard uses — the session JWT from a prior login_with_browser() or login_with_device_flow() call is carried automatically by the TokenManager.

api_keys.create(...)

Mints a new API key. The secret is returned only at creation time — store it immediately.

Python
async def main():
    async with MatildaClient() as client:
        await client.auth.login_with_device_flow(client_id="matilda-code")

        key = await client.api_keys.create(
            name="ci-runner",
            # scopes=["api:code"],                  # omit → server default
            # expires_at="2026-12-31T23:59:59Z",    # omit → never expires
        )

        print(key["secret"])      # mc_live_...  — shown only once
        print(key["keyPrefix"])   # mc_live_abcd
        print(key["id"])          # UUID for revocation

Parameters

Fieldtypedescription
namestrHuman-readable key name (1–80 chars).
scopeslist[str] | NonePermission scopes (e.g. ['api:code']). Defaults to the server default.
expires_atstr | NoneISO 8601 expiry timestamp. Omit for no expiry.

Returns a dict — the key metadata plus the one-time secret.

api_keys.list()

Lists all non-revoked API keys for the authenticated user. Secrets are never included — only the keyPrefix for identification.

Python
keys = await client.api_keys.list()
for key in keys:
    status = "REVOKED" if key.get("revokedAt") else "ACTIVE"
    print(f"{key['keyPrefix']}  {key['name']}  {status}")

Returns list[dict].

api_keys.revoke(key_id)

Revokes a key by ID. The key immediately stops working for authentication.

Python
await client.api_keys.revoke(key["id"])
Fieldtypedescription
key_idstrThe key UUID (from create or list).

Returns a dict (the revoked key with revokedAt set).

Response shape

API key dicts use the server's camelCase wire keys:

Python
# ApiKey dict:
{
    "id": "8f3a2b1c-...",
    "name": "ci-runner",
    "keyPrefix": "mc_live_abcd1234",
    "scopes": ["api:code"],
    "createdAt": "2026-08-18T10:30:00.000Z",
    "lastUsedAt": None,
    "revokedAt": None,
    "expiresAt": None,
    # "secret": "mc_live_..."  — create() only, shown exactly once
}

CLI — matilda-key

The package ships a matilda-key CLI that mints API keys via device-flow login. This is the easiest way for social-login (Google/Apple) users to get an API key without writing code.

Installation

The console script is installed with the package (on the venv's PATH), or run without installing via uvx:

Shell
matilda-key create-api-key --name "my-key"
# or, without installing:
uvx --from matilda-client matilda-key create-api-key --name "my-key"

Usage

Shell
matilda-key create-api-key --name <key-name> [--scopes ...] [--expires-at ...] [--api-base-url ...] [--client-id ...]
Fieldtypedescription
--namestringAPI key name (1–80 chars).
--scopesstringComma-separated scopes (e.g. api:code,api:chat). Defaults to the server default.
--expires-atstringISO 8601 expiry date. Defaults to never expiring.
--api-base-urlstringAPI base URL. Defaults to https://matilda.maincode.com/api.
--client-idstringOAuth client ID / alias. Defaults to matilda-code.

The MATILDA_API_BASE_URL environment variable is also honoured as a fallback for --api-base-url.

What happens when you run it

  1. Device-flow login — a verification URL and code are printed to stderr. Open the URL, sign in with Google or Apple, enter the code.
  2. Key minting — once authenticated, an API key is created from the session JWT.
  3. Output — key metadata (ID, name, prefix, scopes, expiry) is printed to stderr. The secret is printed to stdout.

The stdout/stderr separation is deliberate: the secret on stdout is clean and pipeable, while the login flow and metadata remain visible on the terminal via stderr.

Piping the secret

Shell
# Capture into an env var (login flow still visible on terminal):
MATILDA_API_KEY=$(matilda-key create-api-key --name "ci-runner")

# Pipe to a file:
matilda-key create-api-key --name "ci-runner" > /tmp/key.txt

# Use in CI:
export MATILDA_API_KEY="$(cat /tmp/key.txt)"

Full example

Shell
$ matilda-key create-api-key --name "ci-runner" --scopes api:code

Starting device-flow login...

  Open https://matilda.maincode.com/device and enter code: ABCD-1234

Login successful!

Minting API key "ci-runner"...

API key created successfully.
  ID:          8f3a2b1c-...
  Name:        ci-runner
  Prefix:      mc_live_abcd1234
  Scopes:      api:code
  Expires:     never
  Created at:  2026-08-18T10:30:00.000Z

Secret printed to stdout. Store it securely it won't be shown again.

The secret (mc_live_...) is on stdout; everything else is on stderr.