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:
ProviderandHttpMethodenums 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 MCP and LangChain
Installation
pip install alter-sdk
Optional framework integrations:
pip install 'alter-sdk[mcp]' # FastMCP integration
pip install 'alter-sdk[langchain]' # LangChain/LangGraph integration
Quick Start
import asyncio
from alter_sdk import AlterVault, HttpMethod
async def main():
vault = AlterVault(
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):
- Your end user completes OAuth via Alter Connect (frontend widget) or
vault.connect()(headless) - The
onSuccesscallback returns agrant_id(UUID) - one per user per account - You save it in your database, mapped to your user
- You pass it to
vault.request()when making API calls
Managed secrets (per-service, from developer action):
- You store credentials in the Developer Portal under Managed Secrets
- The portal returns a
grant_id- one per stored credential, shared across your backend - 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 AlterVault(
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, subsequentrequest()calls raiseAlterSDKError.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 AlterVault(
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 = AlterVault(
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
List Grants
Retrieve OAuth grants for your app, optionally filtered by provider:
from alter_sdk import AlterVault
async with AlterVault(
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 |
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 |
Returns ConnectSession with: session_token, connect_url, expires_in, expires_at.
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.
Multi-Agent Deployments
Each agent should create its own AlterVault instance with a unique caller. Do not share a single instance across agents.
# Each agent gets its own vault instance
email_agent = AlterVault(
api_key="alter_key_...",
caller="email-assistant-v2",
)
calendar_agent = AlterVault(
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 AlterVault, CallerType
vault = AlterVault(
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
│ ├── 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 AlterVault, 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)
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")
# --- 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
FastMCP (alter-sdk[mcp])
Build MCP tools with automatic OAuth credential injection:
from fastmcp import FastMCP
from alter_sdk import AlterVault
from alter_sdk.mcp import AlterMCP, AlterContext
vault = AlterVault(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
AlterContextwith a pre-configured provider and audit context - Hides
AlterContextfrom the MCP tool schema (the LLM never sees it) - Returns a Connect URL if no grant exists (
GrantNotFoundErrorrecovery) - Returns a re-auth message on scope mismatch (
ScopeReauthRequiredErrorrecovery)
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 AlterVault, HttpMethod
from alter_sdk.langchain import alter_tool
vault = AlterVault(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_idandthread_idfrom LangChain'sconfigfor audit context - Catches
GrantNotFoundErrorand returns a Connect URL message - Catches
ScopeReauthRequiredErrorand 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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file alter_sdk-0.9.0.tar.gz.
File metadata
- Download URL: alter_sdk-0.9.0.tar.gz
- Upload date:
- Size: 68.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
07efb04ebb8b89b112e41ca0d31c0e0d81afe20d44e2a9cfc1e9af0bd901e609
|
|
| MD5 |
e81519f25dc6ee3fb1eadec0610d0489
|
|
| BLAKE2b-256 |
109d7a426ce269f9304711d1d14df270449a6dbd9b81539ec4efeecedfe6f15c
|
Provenance
The following attestation bundles were made for alter_sdk-0.9.0.tar.gz:
Publisher:
python-sdk-release.yml on AlterAIDev/Alter-Vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
alter_sdk-0.9.0.tar.gz -
Subject digest:
07efb04ebb8b89b112e41ca0d31c0e0d81afe20d44e2a9cfc1e9af0bd901e609 - Sigstore transparency entry: 1339961140
- Sigstore integration time:
-
Permalink:
AlterAIDev/Alter-Vault@e6cbe318c89f58094965aa3e292baad6ed767565 -
Branch / Tag:
refs/tags/python-sdk-v0.9.0 - Owner: https://github.com/AlterAIDev
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-sdk-release.yml@e6cbe318c89f58094965aa3e292baad6ed767565 -
Trigger Event:
push
-
Statement type:
File details
Details for the file alter_sdk-0.9.0-py3-none-any.whl.
File metadata
- Download URL: alter_sdk-0.9.0-py3-none-any.whl
- Upload date:
- Size: 69.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
160555bc2df16d75d1b1f7cc4e7b7f439033541dd67f704e3ffbd3d9aa21bfe0
|
|
| MD5 |
3a4dd5cae9eca52a8cbcab9b83811d32
|
|
| BLAKE2b-256 |
5d482ed51d1162f9445f6818474dfda167394a7014ebab36d76918ac4d4bac51
|
Provenance
The following attestation bundles were made for alter_sdk-0.9.0-py3-none-any.whl:
Publisher:
python-sdk-release.yml on AlterAIDev/Alter-Vault
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
alter_sdk-0.9.0-py3-none-any.whl -
Subject digest:
160555bc2df16d75d1b1f7cc4e7b7f439033541dd67f704e3ffbd3d9aa21bfe0 - Sigstore transparency entry: 1339961141
- Sigstore integration time:
-
Permalink:
AlterAIDev/Alter-Vault@e6cbe318c89f58094965aa3e292baad6ed767565 -
Branch / Tag:
refs/tags/python-sdk-v0.9.0 - Owner: https://github.com/AlterAIDev
-
Access:
internal
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-sdk-release.yml@e6cbe318c89f58094965aa3e292baad6ed767565 -
Trigger Event:
push
-
Statement type: