Skip to main content

Alter Vault Python SDK - OAuth token management with policy enforcement

Project description

Alter SDK for Python

Official Python SDK for Alter Vault - Credential management for agents with policy enforcement.

Features

  • Zero Token Exposure: Tokens are never exposed to developers - injected automatically
  • Single Entry Point: One method (vault.request()) for all provider APIs
  • Type-Safe Enums: Provider and HttpMethod enums with autocomplete
  • URL Templating: Path parameter substitution with automatic URL encoding
  • Automatic Audit Logging: All API calls logged with request metadata (HTTP method and URL) for full audit trail
  • Real-time Policy Enforcement: Every token request checked against current policies
  • Automatic Token Refresh: Tokens refreshed transparently by the backend
  • API Key and Custom Credential Support: Handles OAuth tokens, API keys, and custom credential formats automatically
  • Signed Requests: All SDK-to-backend requests are cryptographically signed for integrity, authenticity, and replay protection
  • Framework Integrations: Optional extras for FastAPI, MCP, and LangChain

Installation

pip install alter-sdk

Optional framework integrations:

pip install 'alter-sdk[fastapi]'    # FastAPI integration
pip install 'alter-sdk[mcp]'        # FastMCP integration
pip install 'alter-sdk[langchain]'  # LangChain/LangGraph integration

Quick Start

import asyncio
from alter_sdk import App, HttpMethod

async def main():
    vault = App(
        api_key="alter_key_...",
        caller="my-agent",
    )

    # Make API request - token injected automatically, never exposed
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id="GRANT_ID",  # from Alter Connect (see below)
        query_params={"maxResults": "10"},
    )
    data = response.json()
    print(data)

    await vault.close()

asyncio.run(main())

Where does grant_id come from?

OAuth grants (per-user, from end user action):

  1. Your end user completes OAuth via Alter Connect (frontend widget) or vault.connect() (headless)
  2. The onSuccess callback returns a grant_id (UUID) - one per user per account
  3. You save it in your database, mapped to your user
  4. You pass it to vault.request() when making API calls

Managed secrets (per-service, from developer action):

  1. You store credentials in the Developer Portal under Managed Secrets
  2. The portal returns a grant_id - one per stored credential, shared across your backend
  3. Use the same vault.request() - credentials are injected automatically
# You can also discover grant_ids programmatically:
result = await vault.list_grants(provider_id="google")
for grant in result.grants:
    print(f"{grant.grant_id}: {grant.account_display_name}")

Usage

The request() method returns the raw httpx.Response. The token is injected automatically and never exposed.

Simple GET Request

response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=grant_id,
)

POST with JSON Body

response = await vault.request(
    HttpMethod.POST,
    "https://api.example.com/v1/items",
    grant_id=grant_id,
    json={"name": "New Item", "price": 99.99},
    reason="Creating new item",
)

URL Path Templating

response = await vault.request(
    HttpMethod.PUT,
    "https://api.example.com/v1/items/{item_id}",
    grant_id=grant_id,
    path_params={"item_id": "123"},
    json={"price": 89.99},
)

Query Parameters and Extra Headers

response = await vault.request(
    HttpMethod.POST,
    "https://api.example.com/v1/databases/{db_id}/query",
    grant_id=grant_id,
    path_params={"db_id": "abc123"},
    extra_headers={"Api-Version": "2022-06-28"},
    json={"page_size": 10},
)

Context Manager

async with App(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id=grant_id,
    )
# Automatically closed

Note: After close() is called, subsequent request() calls raise AlterSDKError. close() is idempotent - calling it multiple times is safe.

Using Managed Secrets

For your own APIs with API keys or service tokens (no OAuth flow needed):

async with App(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/data",
        grant_id="MANAGED_SECRET_GRANT_ID",  # from Developer Portal
    )

The credential is injected automatically as the configured header type (Bearer, API Key, Basic Auth).

Request Context (Audit)

Pass a context dict per-request for structured audit logging:

response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=grant_id,
    context={"tool": "read_data", "agent": "my-agent"},
    reason="Fetching resource for user request",
)

The context dict is stored in audit logs for querying and attribution. The SDK validates the dict before sending — keys and values must be strings, the dict must have at most 20 keys, no key longer than 64 chars, no value longer than 512 chars, and the JSON-encoded payload must fit in 4 KB. Violations raise AlterValueError so a malformed context never silently disappears from your audit trail.

Identity-Based Grant Resolution

For identity-based grant resolution, authenticate end users via the configured IDP:

vault = App(
    api_key="alter_key_...",
    caller="my-agent",
    user_token_getter=lambda: get_current_user_jwt(),
)

# Subsequent requests can resolve grants via user identity
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    provider="<provider>",  # resolved via user identity instead of grant_id
)

Or use explicit browser-based authentication:

auth = await vault.authenticate(timeout=300.0)

print(auth.user_info)  # {"sub": "user-123", "email": "user@example.com", ...}

# Subsequent requests automatically use identity resolution
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    provider="<provider>",
)

Grant Management

Multiple grants on the same connection

A user can hold more than one grant on the same provider — e.g., a direct user grant and additional access through one or more group memberships. Each grant has its own grant_id, scopes, and label. Use grant_id to disambiguate which one a request should use; list_grants() returns all of them and the SDK does not pick one for you.

Branch your code on grant.principal_type ("user" / "group" / "system") to know whether a user_token should be attached to the retrieval call:

# Configure the user-token getter once at construction. The SDK
# attaches the token to identity-mode calls automatically; per-call
# user_token is not part of vault.request()'s signature.
async with App(
    api_key="alter_key_...",
    user_token_getter=lambda: get_current_user_jwt(),
) as vault:
    grants = (await vault.list_grants(provider_id="stripe")).grants
    grant = grants[0]
    response = await vault.request("GET", url, grant_id=grant.grant_id)
    # Branch on grant.principal_type only when the caller-side flow
    # depends on it (e.g., skipping the user-token-getter setup for
    # purely-system workloads).

List Grants

Retrieve OAuth grants for your app, optionally filtered by provider:

from alter_sdk import App

async with App(
    api_key="alter_key_...",
    caller="my-service",
) as vault:
    # List all grants
    result = await vault.list_grants()
    for grant in result.grants:
        print(f"{grant.provider_id}: {grant.account_display_name} ({grant.status})")

    # Filter by provider with pagination
    result = await vault.list_grants(provider_id="google", limit=10, offset=0)
    print(f"Total: {result.total}, Has more: {result.has_more}")
Parameter Type Default Description
provider_id str | None None Filter by provider (e.g., "google")
limit int 100 Max grants to return
offset int 0 Pagination offset

Returns GrantListResult with: grants (list[GrantInfo]), total, limit, offset, has_more.

Each GrantInfo has:

Field Type Description
grant_id str Unique grant identifier (UUID). Not .id — always use .grant_id
provider_id str Provider slug (e.g., "google", "slack")
scopes list[str] Granted OAuth scopes
account_identifier str | None Provider account email or username
account_display_name str | None Human-readable account name
status str Grant status (e.g., "active")
scope_mismatch bool True if granted scopes don't match requested scopes
expires_at str | None Expiry timestamp (if a grant policy TTL was set)
created_at str When the grant was created
last_used_at str | None When the grant was last used for an API call
principal_type Literal["user", "group", "system"] Who the grant is bound to. Branch on this so middleware knows whether to attach user_token (user/group) or skip it (system).

Create Connect Session

Generate a session URL for end-users to authenticate with OAuth providers:

session = await vault.create_connect_session(
    allowed_providers=["google", "github"],
    return_url="https://myapp.com/callback",
)
print(f"Connect URL: {session.connect_url}")
print(f"Expires in: {session.expires_in}s")
Parameter Type Default Description
allowed_providers list[str] | None None Restrict to specific providers
return_url str | None None Redirect URL after OAuth flow
agent str | None None Optional agent UUID or name. When set together with a configured user_token_getter, the user's consent at the Connect UI delegates this grant to the named agent. The agent can then call the provider on the user's behalf via agent.request(provider=…).

Returns ConnectSession with: session_token, connect_url, expires_in, expires_at.

Revoking an agent delegation

After a user has delegated one of their grants to an agent, either side can revoke it:

# Operator path — App revokes a named agent's delegation.
await app.revoke_delegation(grant_id="<grant>", agent_id="<agent-uuid>")

# Agent self-revoke — the agent opts out of its own delegation.
await agent.revoke_delegation(grant_id="<grant>")

Both are idempotent (revoking a missing or already-revoked delegation also succeeds). The underlying OAuth grant stays active — only the agent's access path is removed.

Headless Connect (from code)

For CLI tools, Jupyter notebooks, and backend scripts - opens the browser, waits for the user to complete OAuth, and returns the result:

results = await vault.connect(
    providers=["google"],
    grant_policy={  # optional TTL bounds
        "max_ttl_seconds": 86400,
        "default_ttl_seconds": 3600,
    },
    timeout=300,         # max wait in seconds (default: 5 min)
    open_browser=True,   # set False to print URL instead
)
for result in results:
    print(f"Connected: {result.grant_id} ({result.provider_id})")
    print(f"Account: {result.account_identifier}")

# Now use the grant_id with vault.request()
response = await vault.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=results[0].grant_id,
)
Parameter Type Default Description
providers list[str] | None None Restrict to specific providers
timeout int 300 Max seconds to wait for completion
poll_interval float 2.0 Seconds between status checks
grant_policy dict | None None TTL bounds (max_ttl_seconds, default_ttl_seconds)
open_browser bool True Open browser automatically

Returns list[ConnectResult] - one per connected provider. Each has: grant_id, provider_id, account_identifier, scopes, and optionally grant_policy (if a TTL was set).

Raises ConnectTimeoutError if the user doesn't complete in time, ConnectDeniedError if the user denies authorization, ConnectConfigError if the OAuth app is misconfigured.

Bind a managed-secret grant to a principal

Managed-secret grants are policy-bound access instances on top of a stored credential. create_managed_secret_grant() lets you create one and bind it to one of three principals:

  • UserPrincipal — a single end-user identified by a JWT from the app's IDP
  • GroupPrincipal — every current and future member of an IDP group / role. Group grants require the configured identity provider to emit group lifecycle webhooks so memberships can be revoked in real time; identity providers without that capability are not supported. Attempting to create a GroupPrincipal grant on an unsupported identity provider returns HTTP 422 (group_principal_unsupported_idp). Check the dev portal or the public docs for the current list of supported identity providers.
  • SystemPrincipal — no caller identity (HMAC + app_id is the entire auth context)
from alter_sdk import (
    App,
    GroupPrincipal,
    SystemPrincipal,
    UserPrincipal,
)

async with App(api_key="alter_key_...") as vault:
    # Bind to one user (validates the JWT against the app's IDP)
    user_grant = await vault.create_managed_secret_grant(
        "a1b2c3d4-5e6f-7890-abcd-ef0123456789",
        principal=UserPrincipal(
            user_token=current_jwt,
            label="alice-readonly",
        ),
    )

    # Bind to an IDP group — every current/future member inherits access
    group_grant = await vault.create_managed_secret_grant(
        "a1b2c3d4-5e6f-7890-abcd-ef0123456789",
        principal=GroupPrincipal(
            external_group_id="okta-eng",
            idp_id=app_idp_uuid,
            label="engineering-prod",
        ),
        grant_policy={"max_ttl_seconds": 3600},
    )

    # Server-to-server / cron-worker — no end-user identity
    system_grant = await vault.create_managed_secret_grant(
        "a1b2c3d4-5e6f-7890-abcd-ef0123456789",
        principal=SystemPrincipal(label="cron-worker"),
    )

    # Use any of them by grant_id with vault.request()
    response = await vault.request(
        "GET",
        "https://api.example.com/v1/resource",
        grant_id=group_grant.grant_id,
    )
Parameter Type Description
managed_secret_id str Parent managed secret to bind to (must already have stored credentials)
principal UserPrincipal | GroupPrincipal | SystemPrincipal Discriminated principal binding
grant_policy dict | None Optional per-grant policy overrides (e.g. max_ttl_seconds)

Returns CreateGrantResult with grant_id, principal_type, label, and created_at. Raises GrantNotFoundError if the managed secret is missing or has no stored credentials, ReAuthRequiredError if the UserPrincipal's user_token is invalid or expired, BackendError (HTTP 422) when the request is rejected — for example a GroupPrincipal against an identity provider that does not yet support group grants — and BackendError for persistence failures.

Choosing a principal — user, app, or agent

Every call runs as one of three principals. The principal kind decides whether grant_id is required and how grants get provisioned. See Principals — User, App, and Agent for the full guide.

Principal When to use grant_id required? SDK auth shape
User A logged-in end user is the reason for the call (mail integration, scheduling assistant, …). No — SDK auto-resolves (user, provider) → grant. App API key + user_token_getter.
App (system) Headless backend, cron job, or worker — no end user in the loop. Yes — operator-provisioned. App API key only.
Agent A named managed agent with its own access set, distinct from the app itself. Yes — operator-mapped on the agent detail page. Either (a) a per-agent API key with Agent(api_key=AGENT_KEY) for workload isolation, or (b) the app API key with app.get_agent(agent_id) to obtain an Agent from an App. Both produce the same backend identity and access boundary.
# User principal — JWT auto-resolves to the user's grant.
vault = App(api_key=APP_KEY, user_token_getter=lambda: get_jwt())
resp = await vault.request(HttpMethod.GET, url, provider=PROVIDER_NAME)

# App principal (system) — explicit grant_id from Developer Portal.
vault = App(api_key=APP_KEY)
resp = await vault.request(HttpMethod.GET, url, grant_id=APP_GRANT_ID)

# Agent principal — explicit grant_id mapped on the dashboard.
# Requires `from alter_sdk import Agent`.
vault = Agent(api_key=AGENT_KEY)
resp = await vault.request(HttpMethod.GET, url, grant_id=AGENT_GRANT_ID)

# Agent principal via master key — same agent identity, no per-agent key.
# Look up the managed agent by name, then call ``app.get_agent()`` to
# get an ``Agent`` whose underlying client carries `caller=<uuid>`.
# The audit row records the same agent_id as if a per-agent key had been
# used. ``get_by_name`` is preferred over a ``list().items`` scan: a
# single-page scan can silently miss the target name once the org grows
# past the page boundary, which would drop ``caller=`` and turn this
# back into an app-principal request.
app = App(api_key=APP_KEY)
agent = await app.agents.get_by_name("researcher")
if agent is None:
    raise RuntimeError("Managed agent 'researcher' not found")
researcher = app.get_agent(agent.id)
resp = await researcher.request(HttpMethod.GET, url, grant_id=AGENT_GRANT_ID)

An agent API key cannot be combined with a user_token in the same request — the backend rejects the combination with HTTP 400. Agents are not user impersonators; if a flow needs both an agent identity and a user identity, run them as two separate requests.

The caller= parameter accepts either a free-form string (registered as an actor for audit attribution) or a managed-agent id. The recommended way to obtain that id is app.agents.get_by_name("…") — a single-call lookup that returns the matching agent or None. app.agents.list() works too but a single page can silently miss the target name once the org grows past the page boundary, which would drop caller= and turn the request back into an app-principal call; if list() is used, paginate fully (or filter via get_by_name). For the autonomous-agent flow (no JWT), prefer app.get_agent(agent_id) -> Agent — the same caller= mechanism wrapped in a typed handle. There is no separate agent_id constructor option; the backend disambiguates by shape. When caller= resolves to a managed agent the call is agent-asserting (no JWT) or agent-attributed (with JWT). The fall-back is shape-dependent — and importantly, not symmetric:

  • Non-UUID caller that doesn't match an existing actor — registered as a free-form caller label and used for audit attribution. Typos here are benign (a new label gets registered).
  • UUID-shaped caller that doesn't match any active managed agent for the calling app — rejected with HTTP 404. A UUID is an explicit assertion of agent identity, so a mistyped, deleted, or cross-tenant UUID cannot silently degrade to free-form attribution and smuggle through with broader access than intended.

Agent code path inside a user session

When an LLM agent / orchestrator runs as part of a user-driven request (chat session, scheduled job tied to a user action), the principal IS the user — not a separate agent. Use the user-principal flow with the agent's name carried in caller:

vault = App(
    api_key=APP_KEY,                                   # APP key, not agent key
    user_token_getter=lambda: get_user_jwt(),
    caller="email-research-bot",                       # the agent's name
)
resp = await vault.request(HttpMethod.GET, url, provider="google")

The audit row records BOTH facts: principal_type='user' + app_user_id=<the user> (the principal) AND actor_id=<UUID for "email-research-bot"> (the caller / code path). The caller label is persisted as a named actor record and is queryable from the dashboard's Agents tab.

This is the right pattern whenever the agent's lifecycle is bounded by a user-driven session. Use the Agent principal flow only when the agent runs autonomously without a user present.

Multi-Agent Deployments

Each agent should create its own App instance with a unique caller. Do not share a single instance across agents.

# Each agent gets its own vault instance
email_agent = App(
    api_key="alter_key_...",
    caller="email-assistant-v2",
)

calendar_agent = App(
    api_key="alter_key_...",
    caller="calendar-agent-v1",
)

# Audit logs and policies are tracked per caller
await email_agent.request(
    HttpMethod.GET,
    "https://api.example.com/v1/messages",
    grant_id=gmail_grant_id,
)
await calendar_agent.request(
    HttpMethod.GET,
    "https://api.example.com/v1/resource",
    grant_id=calendar_grant_id,
)

# Clean up each instance
await email_agent.close()
await calendar_agent.close()

Configuration

from alter_sdk import App, CallerType

vault = App(
    api_key="alter_key_...",              # Required: Alter Vault API key
    caller="my-agent",                    # Optional: Identifies this SDK instance for audit + policy
    caller_type=CallerType.AGENT,         # Optional: "agent" (default, Agents tab) or "service" (hidden)
    timeout=30.0,                         # Optional: HTTP timeout in seconds
    user_token_getter=lambda: get_jwt(),  # Optional: Per-request user identity for grant resolution
)

Error Handling

The SDK provides a typed exception hierarchy so you can handle each failure mode precisely:

AlterSDKError (base)
├── AlterValueError                  # SDK rejected your input — fix your code
├── BackendError                     # Generic backend error
│   ├── ReAuthRequiredError          # User must re-authorize via Alter Connect
│   │   ├── GrantExpiredError        # 403 — grant TTL elapsed
│   │   ├── CredentialRevokedError   # 400 — underlying auth permanently broken (revoked, invalid_grant)
│   │   ├── GrantRevokedError        # 400 — grant revoked
│   │   └── GrantDeletedError        # 410 — user disconnected via Wallet (new ID on re-auth)
│   ├── GrantNotFoundError           # 404 — wrong grant_id
│   ├── AmbiguousGrantError          # 409 — JWT identity matched multiple grants; pass `account` to disambiguate
│   ├── PolicyViolationError         # 403 — policy denied (business hours, IP allowlist, etc.)
│   └── TokenRefreshInProgressError  # 409 — another request is refreshing the token; retry after backoff
├── ConnectFlowError                 # Headless connect() failed
│   ├── ConnectDeniedError           # User clicked Deny
│   ├── ConnectConfigError           # OAuth app misconfigured
│   └── ConnectTimeoutError          # User didn't complete in time
├── ProviderAPIError                 # Provider returned 4xx/5xx
│   └── ScopeReauthRequiredError    # 403 + scope mismatch — user must re-authorize
└── NetworkError                     # Backend or provider unreachable
    └── TimeoutError                 # Request timed out (safe to retry)

AlterValueError is raised for input validation failures the developer must fix in their own code (e.g., a malformed context dict). It signals a programming bug, not a runtime/network/backend failure.

from alter_sdk import App, HttpMethod
from alter_sdk.exceptions import (
    AlterSDKError,              # Base exception
    AlterValueError,            # SDK rejected input — fix your code
    BackendError,               # Generic backend error
    ReAuthRequiredError,        # Parent for all re-auth errors
    GrantDeletedError,          # User disconnected via Wallet — new ID on re-auth (410)
    GrantExpiredError,          # TTL expired — user must re-authorize (403)
    GrantNotFoundError,         # Wrong grant_id — check for typos (404)
    AmbiguousGrantError,        # JWT identity matched >1 grant — pass `account` to pick (409)
    CredentialRevokedError,     # Underlying auth permanently broken — revoked/invalid_grant (400)
    GrantRevokedError,          # Grant revoked (400)
    PolicyViolationError,       # Policy denied access — business hours, IP, etc. (403)
    TokenRefreshInProgressError,  # Another request is refreshing the token; retry after backoff (409)
    ConnectFlowError,           # Headless connect() failed (denied, provider error)
    ConnectDeniedError,         # User denied authorization
    ConnectConfigError,         # OAuth app misconfigured
    ConnectTimeoutError,        # Headless connect() timed out
    NetworkError,               # Backend or provider unreachable
    TimeoutError,               # Request timed out (subclass of NetworkError)
    ProviderAPIError,           # Provider API returned error (4xx/5xx)
    ScopeReauthRequiredError,   # 403 + scope mismatch (subclass of ProviderAPIError)
)

try:
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resource",
        grant_id=grant_id,
    )

# --- Grant unusable — user must re-authorize via Alter Connect ---
except GrantExpiredError:
    # TTL set during Connect flow has elapsed
    print("Grant expired — prompt user to re-authorize")
except CredentialRevokedError:
    # Underlying auth permanently broken — user revoked at provider, refresh token
    # expired, or token refresh permanently failed (invalid_grant)
    print("Connection revoked — prompt user to re-authorize")
except GrantRevokedError:
    # Grant itself was revoked
    print("Grant revoked — prompt user to re-authorize")
except GrantDeletedError:
    # User disconnected via Wallet — re-auth generates a NEW grant_id
    print("Grant deleted — prompt user to re-connect, store the new ID")
except GrantNotFoundError:
    # No grant with this ID exists — check for typos or stale references
    print("Grant not found — verify your grant_id")
except AmbiguousGrantError as e:
    # JWT identity resolution (user_token mode) matched multiple active grants —
    # supply the `account` parameter to disambiguate. The matching identifiers
    # are exposed on the exception so the caller can prompt or pick.
    print(f"Multiple grants for {e.provider_id}: {e.account_identifiers}")
    # Re-issue with one of the listed accounts (identity-mode call —
    # `user_token_getter` is configured on the App constructor):
    #   await vault.request(..., provider="<provider>", account=e.account_identifiers[0])

# --- Policy restrictions — may resolve on its own ---
except PolicyViolationError as e:
    # Business hours, IP allowlist, or other policy denial configured in the Developer Portal
    print(f"Policy denied: {e.message} (reason: {e.policy_error})")
    print("Check policy config in Developer Portal, or queue work for later")

# --- Transient / infrastructure errors — safe to retry ---
except TokenRefreshInProgressError as e:
    # Another request is currently refreshing the token (backend holds a Redis lock).
    # Wait briefly and retry — the refresh will complete within a few seconds.
    print(f"Token refresh in progress for {e.grant_id} — retry after backoff")
except NetworkError as e:
    # TimeoutError is a subclass, so this catches both
    print(f"Network issue — retry with backoff: {e.message}")

except ScopeReauthRequiredError as e:
    print(f"Scope mismatch on {e.grant_id} - user needs to re-authorize")
    # Create a new Connect session so the user can grant updated scopes

# --- Provider errors ---
except ProviderAPIError as e:
    print(f"Provider error {e.status_code}: {e.response_body}")

Re-authorization and Grant IDs

When a user re-authorizes through Alter Connect, the same grant_id is preserved in most cases. The existing grant record is updated in place with fresh tokens. You do not need to update your stored grant_id.

The exception is GrantDeletedError — the user disconnected via the Wallet, so re-authorization creates a new grant with a new grant_id. Store the new ID from the ConnectResult.

Exception Same grant_id after re-auth?
GrantExpiredError Yes
CredentialRevokedError Yes
GrantRevokedError Yes
GrantDeletedError No — new ID generated
GrantNotFoundError N/A — ID never existed

Supported Providers

The SDK includes type-safe Provider enums for all 66 supported providers. Use them for filtering grants or as documentation -- request() takes a grant_id string, not a provider enum.

from alter_sdk import Provider

# Custom providers (full OAuth implementations)
Provider.GOOGLE       # "google"
Provider.GITHUB       # "github"
Provider.SLACK        # "slack"
Provider.CALENDLY     # "calendly"
Provider.CLICKUP      # "clickup"
Provider.CANVA        # "canva"
# ... and 23 more (see Provider enum for full list)

# Config-driven providers (45 total) -- examples:
Provider.HUBSPOT      # "hubspot"
Provider.SALESFORCE   # "salesforce"
Provider.STRIPE       # "stripe"
Provider.MICROSOFT    # "microsoft"
Provider.DISCORD      # "discord"
Provider.SPOTIFY      # "spotify"
Provider.LINKEDIN     # "linkedin"
Provider.DROPBOX      # "dropbox"
Provider.FIGMA        # "figma"
# ... and 36 more (see Provider enum for full list)

# Usage: filter grants by provider
result = await vault.list_grants(provider_id=Provider.HUBSPOT)

# Usage: make requests with grant_id
await vault.request(HttpMethod.GET, "https://api.example.com/v1/resource", grant_id=grant_id)
All 66 providers

Acuity Scheduling, Adobe, Aircall, Airtable, Apollo, Asana, Atlassian, Attio, Autodesk, Basecamp, Bitbucket, Bitly, Box, Brex, Cal.com, Calendly, Canva, ClickUp, Close, Constant Contact, Contentful, Deel, Dialpad, DigitalOcean, Discord, DocuSign, Dropbox, eBay, Eventbrite, Facebook, Figma, GitHub, Google, HubSpot, Instagram, Linear, LinkedIn, Mailchimp, Mercury, Microsoft, Miro, Monday, Notion, Outreach, PagerDuty, PayPal, Pinterest, Pipedrive, QuickBooks, Ramp, Reddit, RingCentral, Salesforce, Sentry, Slack, Snapchat, Spotify, Square, Squarespace, Stripe, TikTok, Todoist, Twitter, Typeform, Webex, Webflow

Framework Integrations

FastAPI (alter-sdk[fastapi])

Automatic Bearer token capture via FastAPI dependency injection — no ContextVar or header extraction boilerplate:

from fastapi import Depends, FastAPI
from alter_sdk.fastapi import AlterFastAPI

alter = AlterFastAPI(api_key="alter_key_...", caller="my-app")
app = FastAPI()

@app.post("/chat")
async def chat(vault=Depends(alter)):
    resp = await vault.request("GET", "https://api.example.com/v1/resource", provider="<provider>")
    return resp.json()

@app.get("/connect")
async def connect(vault=Depends(alter)):
    session = await vault.create_connect_session(allowed_providers=["<provider>"])
    return {"connect_url": session.connect_url}

Depends(alter) extracts the Authorization: Bearer <token> header per-request and makes it available to the SDK for identity resolution. Requests without a valid Bearer token receive a 403 response automatically.

For combined FastAPI + MCP servers, use alter.auth_provider() to create an MCP auth provider that shares the same identity context:

from fastmcp import FastMCP

auth = alter.auth_provider(providers={"<provider>": ["scope1", "scope2"]})
mcp = FastMCP("my-server", auth=auth)

FastMCP (alter-sdk[mcp])

Build MCP tools with automatic OAuth credential injection:

from fastmcp import FastMCP
from alter_sdk import App
from alter_sdk.mcp import AlterMCP, AlterContext

vault = App(api_key="alter_key_...", caller="my-mcp-server")
alter = AlterMCP(vault)
mcp = FastMCP("my-server")


@mcp.tool()
@alter.tool(provider="google")
async def list_events(ctx: AlterContext, max_results: int = 10) -> list[dict]:
    """List upcoming calendar events."""
    response = await ctx.request(
        "GET",
        "https://www.googleapis.com/calendar/v3/calendars/primary/events",
        query_params={"maxResults": str(max_results)},
    )
    return response.json().get("items", [])

The @alter.tool() decorator:

  • Injects AlterContext with a pre-configured provider and audit context
  • Hides AlterContext from the MCP tool schema (the LLM never sees it)
  • Returns a Connect URL if no grant exists (GrantNotFoundError recovery)
  • Returns a re-auth message on scope mismatch (ScopeReauthRequiredError recovery)

For user authentication, use AlterAuthProvider with FastMCP:

from alter_sdk.mcp.auth import AlterAuthProvider

auth = AlterAuthProvider(vault, providers={"google": ["calendar.events"]})
mcp = FastMCP("my-server", auth=auth)

LangChain / LangGraph (alter-sdk[langchain])

Build LangChain tools that are real StructuredTool instances:

from alter_sdk import App, HttpMethod
from alter_sdk.langchain import alter_tool

vault = App(api_key="alter_key_...", caller="my-agent")


@alter_tool(vault, provider="example_provider")
async def list_resources(owner: str) -> str:
    """List resources owned by a given principal."""
    response = await vault.request(
        HttpMethod.GET,
        "https://api.example.com/v1/resources",
        provider="example_provider",
        query_params={"owner": owner},
    )
    resources = response.json()
    return "\n".join(r["name"] for r in resources)


# Pass directly to any LangChain agent or LangGraph node
from langgraph.prebuilt import create_react_agent

agent = create_react_agent(llm, tools=[list_resources])

The @alter_tool() decorator:

  • Produces a real langchain_core.tools.StructuredTool (no manual wrapping needed)
  • Extracts run_id and thread_id from LangChain's config for audit context
  • Catches GrantNotFoundError and returns a Connect URL message
  • Catches ScopeReauthRequiredError and returns a re-auth message

For remote MCP tools via langchain-mcp-adapters, use AlterMCPInterceptor:

from langchain_mcp_adapters import MCPToolkit
from alter_sdk.langchain import AlterMCPInterceptor

interceptor = AlterMCPInterceptor()
toolkit = MCPToolkit(server="https://mcp.example.com", interceptor=interceptor)

Requirements

  • Python 3.11+
  • httpx[http2]
  • pydantic

License

MIT License

Managed Agents

Provision named agent identities + per-agent API keys from the operator side via app.agents.* (only App exposes this namespace — agent keys can't mint other agents):

from alter_sdk import App

app = App(api_key="alter_key_app_...")

result = await app.agents.create(
    name="email-assistant",
    display_name="Email Assistant",
    metadata={"team": "growth"},
)
print(result.api_key)  # plaintext returned ONCE — store securely

agents = await app.agents.list(limit=50)
agent = await app.agents.get_by_name("email-assistant")
await app.agents.update(agent.id, display_name="New Name")
await app.agents.delete(agent.id)

On the workload side, construct an Agent with the agent's own key and call agent.me() for self-introspection (useful for self-diagnostics and User-Agent population):

from alter_sdk import Agent

agent = Agent(api_key="alter_key_agent_...")
me = await agent.me()  # returns the calling agent's record

agent.me() is intentionally only on Agent — calling it on App raises a clear type error rather than the runtime MeRequiresAgentKeyError.

Idempotency: pass idempotency_key="..." to create() to make the call safe to retry. On a replay, the response carries the original agent metadata but result.api_key is None because the plaintext key was returned exactly once at the original call. Branch on result.api_key is None to detect a replay and consult the storage where the original key was persisted.

AgentError (and every agent-domain subclass) inherits from BackendError, so existing except BackendError handlers that wrap any /sdk/* call also catch agent failures. To branch on a specific cause use except AgentNotFoundError / except AgentNameExistsError etc., or switch on the stable err.code attribute.

Per-agent scopes (broadening only)

app.agents.update(agent_id, scopes={...}) replaces the agent's per-provider scope allowlist. The PATCH path supports broadening only — pass the FULL desired allowlist; if any provider key drops out, or any scope is removed from an existing provider's list, the call raises AgentScopeNarrowingNotSupportedError (HTTP 409 narrowing_requires_phase_g). The accompanying hint enumerates the missing scopes/providers so you don't have to diff manually. Narrowing's two-phase lazy-apply path ships in a later release.

app.agents.update(agent_id, policy={...}) replaces the agent's policy block atomically. cerbos_attributes values must be JSON scalars (str | bool | int | float | list[str]); nested dicts surface as a 422 at the backend boundary so the operator fixes the config now rather than every Cerbos call later 500'ing on serialization.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

alter_sdk-0.12.0.tar.gz (154.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

alter_sdk-0.12.0-py3-none-any.whl (155.0 kB view details)

Uploaded Python 3

File details

Details for the file alter_sdk-0.12.0.tar.gz.

File metadata

  • Download URL: alter_sdk-0.12.0.tar.gz
  • Upload date:
  • Size: 154.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for alter_sdk-0.12.0.tar.gz
Algorithm Hash digest
SHA256 b531b30fde3095cc04ae729beb96723cc89475bda4e11adeeff6055465ddff18
MD5 50a6734e6af4e40427d72238efb0c0b7
BLAKE2b-256 c021be888935356e19baa67f0aab62b977e56f3077f605cf6dea87d30541cbde

See more details on using hashes here.

Provenance

The following attestation bundles were made for alter_sdk-0.12.0.tar.gz:

Publisher: python-sdk-release.yml on AlterAIDev/Alter-Vault

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file alter_sdk-0.12.0-py3-none-any.whl.

File metadata

  • Download URL: alter_sdk-0.12.0-py3-none-any.whl
  • Upload date:
  • Size: 155.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for alter_sdk-0.12.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dee0d2e3554069ec76f30edbac899830bf76e9bec62c56dd2950cd491f72a0e9
MD5 603a751031c3fab9c80e92c9c0220ead
BLAKE2b-256 31eed4166d111c5013d7f2672a4e7add8c910bdc008305bdd591ff02105cccef

See more details on using hashes here.

Provenance

The following attestation bundles were made for alter_sdk-0.12.0-py3-none-any.whl:

Publisher: python-sdk-release.yml on AlterAIDev/Alter-Vault

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page