Skip to main content

spaps (Python client)

Python SDK for SPAPS-compatible APIs. The distribution name is spaps; the import path is spaps_client.

Examples in this README use placeholders such as user@example.com, admin@example.com, spaps_test_key, and https://api.example.test. Replace them with values from your own deployment.

Install

pip install spaps

This package targets Python 3.9+.

When It Fits

Need Package gives you
Sync and async clients SpapsClient and AsyncSpapsClient with a similar surface
Typed responses Pydantic models for auth, sessions, payments, entitlements, webhooks, and more
HTTP control points Retry config, logging hooks, injected httpx clients, and custom token storage
Narrow integrations Standalone helpers for device flow, webhooks, users, email, entitlements, marketing, and permission checks

Quick Start

from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    login = client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    if isinstance(login, MfaRequiredChallenge):
        tokens = client.auth.mfa_verify(
            challenge_id=login.challenge_id,
            challenge=login.challenge,
            code="123456",
        )
    else:
        tokens = login

    bootstrap = client.auth.get_session_context()
    session = client.sessions.get_current_session()
    products = client.payments.list_products(
        category="subscription",
        active=True,
        limit=5,
    )

    print(tokens.user.email)
    print(bootstrap.tier)
    print(session.session_id)
    print(products.total)
finally:
    client.close()

Async Example

import asyncio

from spaps_client import AsyncSpapsClient


async def main() -> None:
    client = AsyncSpapsClient(
        base_url="http://localhost:3301",
        api_key="spaps_test_key",
    )
    try:
        await client.auth.sign_in_with_password(
            email="user@example.com",
            password="correct-horse-battery-staple",
        )
        sessions = await client.sessions.list_sessions()
        print(sessions.total)
    finally:
        await client.aclose()


asyncio.run(main())

Sync and async auth calls raise AuthError for HTTP failures, including malformed upstream error bodies. Use status_code, error_code, and request_id for handling. Valid structured responses retain provider messages and details; malformed and plain-text server responses use a generic message.

Sync and async sessions.list_sessions() use limit and zero-based offset for pagination. The server defaults to 50 sessions per page. Session calls raise SessionError for malformed HTTP responses, including non-object 2xx bodies. Valid structured errors retain their code and message; malformed or plain-text server errors use a generic message.

Sync and async whitelist calls raise WhitelistError for malformed HTTP responses, including non-object 2xx bodies. Valid structured errors keep their provider code and message; malformed or plain-text server errors use a generic message.

Modern Auth Methods

The auth client exposes the active SPAPS auth routes in both sync and async forms:

from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(base_url="http://localhost:3301", api_key="spaps_test_key")

methods = client.auth.list_methods()
print([item.method for item in methods.methods if item.enabled])

oidc = client.auth.oidc_nonce()
login = client.auth.oidc_sign_in(
    provider="google",
    id_token="provider-id-token",
    challenge_id=oidc.challenge_id,
)

if isinstance(login, MfaRequiredChallenge):
    login = client.auth.mfa_verify(
        challenge_id=login.challenge_id,
        challenge=login.challenge,
        recovery_code="recovery-code",
    )

TOTP management and passkey registration are authenticated operations and use stored tokens unless you pass access_token explicitly:

enrollment = client.auth.mfa_totp_enroll()
activation = client.auth.mfa_totp_activate(code="123456")
options = client.auth.webauthn_register_options(origin="https://app.example.com")
credential = client.auth.webauthn_register_verify(
    challenge_id=options.challenge_id,
    credential={"id": "browser-produced-credential"},
    origin="https://app.example.com",
)

The sync and async auth clients also cover passkey-first bootstrap, credential listing/labeling/revocation, application passkey profiles, action-bound step-up, user recovery, and administrator recovery preparation/execution. Pass origin on ceremonies so SPAPS can validate the browser origin. Public ceremonies send only X-API-Key; lifecycle, profile, step-up, and administrator operations also require a bearer token, using an explicit access_token before stored credentials.

Passkey signup's webauthn_bootstrap_verify returns a restricted WebAuthnBootstrapPendingResponse, without account/session creation or token storage. Keep its bootstrap_grant in attempt memory, obtain a fresh assertion from the user's authenticator, then submit that grant, challenge_id, and credential through webauthn_bootstrap_assertion_verify. Only the validated final response stores tokens. Preserve the original application, Origin/RP, user handle, and expiry; discard the grant when the attempt ends. Ordinary membership does not confer a dashboard grant.

Bootstrap also binds configuration identity/version and the security profile fingerprint across both ceremonies; security profile changes require fresh options, while cosmetic display-name changes remain valid. Signup requests a discoverable passkey with resident_key="required"; authenticated enrollment keeps its configured preference. This changed contract in checkout Python 0.8.3 requires coordinated server and matching SDK/types/Python package/channel adoption, including the server/keyscope stored-rule contract. Publishable final assertion needs an exact singleton POST rule in publishable_key_access.allowed_routes; declared scopes, legacy defaults, and pattern backfill cannot enable it. Compiled browser/dashboard settings contain the rule; custom/legacy settings need an explicit adoption upgrade. Checkout source does not prove published or deployed compatibility.

Python coordinates the server half of each WebAuthn ceremony. It returns typed registration or assertion options and accepts the credential JSON produced by a browser or another WebAuthn-capable user agent. It does not call navigator.credentials, emulate an authenticator, perform CTAP, or sign challenges. Recovery registration rotates the recovery grant; it does not issue login tokens until the new credential completes the recovery assertion.

Core Surface

High-Level Clients

Client Best for
SpapsClient Sync auth, sessions, payments, usage, access decisions, capability graph inspection, whitelist, secure messages, issue reporting, marketing, metrics, app links, dayrate, skill evals, token gates, and support telemetry
AsyncSpapsClient Async auth, sessions, payments, usage, whitelist, secure messages, issue reporting, metrics, entitlements, app links, dayrate, skill evals, token gates, and users

Standalone Helpers

Helper Purpose
EntitlementsClient / AsyncEntitlementsClient Resource entitlements, purchase history, manual grants/revokes, and project grants
TokenGatesClient / AsyncTokenGatesClient Public enabled-gate list and user JWT verify; UNVERIFIABLE is unknown, not no
UsageClient / AsyncUsageClient Secret-key usage authorization and immutable usage recording
CapabilityClient Access decisions, action preparation, decision traces, graph inspection, and contract discovery
EmailClient / AsyncEmailClient Template lookup, preview, and send flows
UsersClient / AsyncUsersClient Batch user/email lookups, secret-key membership decisions, and app membership administration
MarketingClient Browser-safe marketing event emission and server-side experiment results
AppLinksClient / AsyncAppLinksClient Authenticated short-link creation/update and public link resolution
DayrateClient / AsyncDayrateClient Availability, booking, cancellation, x402 booking holds, and admin booking reads
DeviceFlowClient Device-code login workflows
PermissionChecker Role and admin convenience checks
verify_spaps_webhook Signature verification for incoming SPAPS webhooks

Public app-link lookups omit owner_user_id; AppLink.owner_user_id is None for those results and is present on authenticated create/update results. Sync and async app-link calls raise AppLinkError for non-JSON or non-object HTTP responses; malformed server errors use a generic message and retain the HTTP status.

Sync and async dayrate.cancel_booking() require the booking owner's access token, stored on the client or passed as access_token_override. The legacy user_id and email arguments remain accepted but are omitted from the request: the server establishes ownership from the bearer token.

Device-code login

DeviceFlowClient is an async public-client flow. Pass the SPAPS application slug as client_id, or set SPAPS_CLI_CLIENT_ID. The authorize and token requests are keyless: they send client_id, the RFC 8628 device-code grant type, and the current protocol fields, but never X-API-Key or a bearer token.

import asyncio

from spaps_client import DeviceFlowClient


async def device_login() -> None:
    client = DeviceFlowClient(
        base_url="https://api.example.test",
        client_id="my-app",
    )
    try:
        tokens = await client.login()
        print(tokens.expires_in)
    finally:
        await client.aclose()


asyncio.run(device_login())

login() displays the server-provided verification_uri_complete when available, so the application-specific page can prefill the user code. The older api_key= constructor keyword remains accepted for migration safety but is ignored by device authorize/token calls; it cannot substitute for client_id.

Device-flow HTTP failures raise DeviceFlowError even when an upstream error body is malformed. Handle status_code and error_code; server-side failures use a generic exception message instead of copying upstream response text.

Configuration

Constructor values override package defaults.

from spaps_client import SpapsClient, RetryConfig, default_logging_hooks

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    retry_config=RetryConfig(max_attempts=4, backoff_factor=0.2),
    logging_hooks=default_logging_hooks(),
)

Common parameters:

Parameter Purpose
base_url Target API origin
api_key Application or service API key
request_timeout Per-request timeout
token_storage Custom token persistence backend
http_client Injected httpx.Client or httpx.AsyncClient
retry_config Retry and backoff policy
logging_hooks Structured request and response logging callbacks

default_logging_hooks() records method, host, status, timing, attempt, and error type. It omits URL paths and queries, URL credentials, response request IDs, and exception messages because these can contain private data. Custom logging hooks must apply their own redaction.

Requests that cannot be retried keep their streaming bodies; the SDK does not buffer them before the first send. For retryable streamed bodies, it spools up to 1 MiB in memory, then uses a temporary file and sends 64 KiB chunks on each attempt. The spool is removed after the final attempt; large retries need temporary disk space.

Common Flows

Agent Access Decisions

client.access.check_access(...) returns a typed decision for both allowed and denied outcomes. A blocked action is not an exception; inspect allowed, outcome, and next_actions. Checkout clients reject non-boolean authority flags and contradictory allowed/outcome or action-execution gates. Malformed response objects raise CapabilityError; unstructured error bodies do not become exception text.

from spaps_client import AccessDecisionRequest, CapabilityError, SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)
client.set_tokens(access_token="admin-access-token")

try:
    decision = client.access.check_access(
        AccessDecisionRequest.model_validate(
            {
                "actor": {"actor_type": "user", "actor_ref": "user_123"},
                "action": "checkout.create",
                "resource": {"resource_type": "product", "resource_ref": "dayrate"},
                "controls": {"entitlement_key": "bookme_paid"},
            }
        )
    )

    if not decision.allowed:
        print(decision.outcome, decision.next_actions)

    prepared = client.access.prepare_action(
        {
            "access": {
                "actor": {"actor_ref": "admin_123"},
                "action": "admin.delete_user",
                "resource": {"resource_type": "user", "resource_ref": "user_123"},
            },
            "include_command_templates": True,
            "environment": "production",
        }
    )
    print(prepared.status, prepared.execution.operator_gate_required)

    contract = client.contract.get_contract()
    refresh = client.graph.refresh_graph(correlation_id="local-refresh")
    nodes = client.graph.list_graph_nodes(node_type="x402_resource", q="dayrate")
    explanation = client.access.explain_decision(decision.decision_trace_id)
    print(contract.version, refresh.status, refresh.diagnostics.phase2_gate.recommendation if refresh.diagnostics.phase2_gate else None)
    print(nodes.projection.projection_status if nodes.projection else None, explanation.graph_node_keys)
except CapabilityError as exc:
    print(exc.status_code, exc.code, exc.request_id, exc.diagnostics, exc.remediations)
finally:
    client.close()

client.contract.get_contract() includes graph vocabulary fields: graph_node_types, graph_edge_types, graph_source_domains, and source_domain_notes. Use them before filtering graph nodes by stable v1 types such as wallet, api_key, role, and approver.

CapabilityError exposes both Python-native names (status_code, error_code, request_id) and parity aliases (status, code). Diagnostics and remediations preserve server-provided details fields.

operator-gated is compatibility/descriptive input when a client supplies it; it does not authorize mutation command templates. SPAPS returns mutation command templates only when the request has a server-recognized non-publishable key and an authenticated admin/operator user context. Publishable callers cannot self-attest the gate.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    client.auth.send_magic_link(email="user@example.com")
    reset_request = client.auth.request_password_reset(email="user@example.com")
    reset_confirm = client.auth.confirm_password_reset(
        token="reset-token-from-email",
        new_password="Sup3rStrong!",
    )
    print(reset_request.email, reset_request.sent_at, reset_confirm.message)
finally:
    client.close()

Password reset responses use the standard SPAPS success envelope with top-level success, data, request_id, and timestamp. The Python client returns the nested data payload: reset requests expose email and sent_at, while reset confirmation exposes the nested message. A top-level English message is not canonical and should not drive UI copy. Prefer app-owned success copy, or map future nested data.message_key/data.status values in your app.

Downstream Adoption

HTMA and future SPAPS consumers should use spaps>=0.6.2 for password reset flows and stream-aware email sends. Version 0.6.2 is available on PyPI; the passkey-complete 0.7.0 line remains a retained candidate until publication.

Release execution is intentionally separate from this handoff. Publish only the retained exact artifact from the package self-release lane. Do not use npm run publish:python-client, deploy SPAPS, or publish the package as part of a consumer-only adoption check.

Authenticated Stripe Checkout

create_checkout_session is the convenience path for authenticated checkout. Pass the server-managed Stripe price_id; the client sends the active Stripe checkout contract with one line item for that price.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_test_key",
)

try:
    client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    checkout = client.payments.create_checkout_session(
        price_id="price_123",
        mode="subscription",
        success_url="https://app.example.test/success",
        cancel_url="https://app.example.test/cancel",
        trial_period_days=14,
    )
    print(checkout.checkout_url)
finally:
    client.close()

Server-Side Usage Control

Use usage from a trusted backend with a secret SPAPS key. Browser apps should call their own backend first; that backend asks SPAPS to authorize the work, runs the work only on an allow or warning decision, then records the actual usage with a stable idempotency key.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    authorization = client.usage.authorize_usage(
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={"requests": 1, "input_tokens": 1200},
    )
    if authorization.decision == "blocked":
        message = authorization.reasons[0].message if authorization.reasons else "Usage not authorized"
        raise RuntimeError(message)

    result = run_assistant_job()

    client.usage.record_usage(
        authorization_ref=authorization.authorization_ref,
        idempotency_key=result.job_id,
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={
            "input_tokens": result.input_tokens,
            "output_tokens": result.output_tokens,
        },
        metadata={"job_id": result.job_id},
    )
finally:
    client.close()

For dashboard and policy views, use client.usage.get_features(), client.usage.get_status(...), and client.usage.get_history(...) from the same trusted backend context.

Account Memberships and Project Grants

Use these helpers from a trusted backend with a secret SPAPS key. Membership decisions need the secret key but no admin JWT. Admin mutations require an admin access token; do not call membership invitation, project grant, or project revoke helpers from browser code.

from spaps_client import EntitlementsClient, UsersClient

admin_token = "admin-access-token"

users = UsersClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)
entitlements = EntitlementsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)

try:
    membership = users.memberships.decide(
        "user_123",
        grant_allowed=True,
    )
    if membership.decision == "deny":
        raise RuntimeError(", ".join(membership.reasons))

    users.memberships.add_account_user(
        email="teammate@example.com",
        capabilities={"projects": ["project_123"]},
        metadata={"invited_by": "admin@example.com"},
    )
    entitlements.project_grants.grant_user(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        email="teammate@example.com",
        reason="Project teammate invite",
    )

    project_users = entitlements.project_grants.list_project_users(
        "project_123",
        entitlement_key="pds.project.viewer",
    )
    user_projects = entitlements.project_grants.list_user_projects("user_123")
    access = entitlements.project_grants.check_access(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        user_id="user_123",
    )
    print(project_users.count, user_projects.count, access.has_access)
finally:
    users.close()
    entitlements.close()

Token gates

list_gates() is API-key only. verify_gates() needs the secret key and the user JWT. UNVERIFIABLE means the server could not check holdings — unknown, not no. Concepts and presets: docs/guides/token-gates.md and docs/guides/token-gates-cookbook.md.

from spaps_client import (
    TokenGateAccessKind,
    TokenGatesClient,
    token_gate_access_decision,
)

gates = TokenGatesClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token="user-jwt",
)
try:
    listed = gates.list_gates()
    verified = gates.verify_gates(entitlement_key="fwa_holder")
    for row in verified.results:
        decision = token_gate_access_decision(row)
        if decision.kind is TokenGateAccessKind.ALLOW:
            entitled = True
        elif decision.kind is TokenGateAccessKind.DENY:
            entitled = False
        elif decision.kind is TokenGateAccessKind.UNVERIFIABLE:
            entitled = None
        else:
            raise RuntimeError(f"unknown token-gate access kind: {decision.kind}")
    print(listed.count, entitled)
finally:
    gates.close()

Marketing Events

Use client.marketing.emit_event(...) with a publishable key to record anonymous attribution touches or experiment exposures. Use a secret key from a trusted server or agent to read experiment results and the current conservative stop signal.

from spaps_client import SpapsClient

browser_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    browser_client.marketing.emit_event(
        anon_id="anon_01HY...",
        event_type="experiment_exposure",
        experiment_id="landing-hero-copy",
        variant_id="treatment",
        dedupe_key="landing-hero-copy:anon_01HY:treatment",
    )
finally:
    browser_client.close()

agent_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    results = agent_client.marketing.get_experiment_results("landing-hero-copy")
    print(results.decision.recommendation, results.decision.winner_variant_id)
finally:
    agent_client.close()

Permission Checks With Explicit Admin Config

from spaps_client import PermissionChecker

checker = PermissionChecker(customAdmins=["admin@example.com"])
role = checker.getRole("operator@example.com")

if checker.requiresAdmin({"email": "operator@example.com"}):
    raise PermissionError(
        checker.getErrorMessage("admin", role, action="change billing settings")
    )

Issue Reporting Voice Token

Voice issue reporting uses a short-lived SPAPS token for ElevenLabs Scribe. Keep the ElevenLabs API key on the SPAPS server.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    voice_token = client.issue_reporting.create_voice_token()
    print(voice_token.provider, voice_token.model_id)
finally:
    client.close()

Issue Reporting Screenshot Attachments

Screenshots are uploaded as private pending hosted assets first. Create, update, or reply calls then attach those IDs; raw image bytes, data URLs, and base64 payloads should not be stored in issue notes or target metadata.

from pathlib import Path

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    attachment = client.issue_reporting.upload_issue_report_attachment(
        file=Path("protocol-save-failure.png").read_bytes(),
        filename="protocol-save-failure.png",
        mime_type="image/png",
    )

    issue = client.issue_reporting.create_issue_report(
        target={
            "component_key": "patient_protocol_widget",
            "component_label": "Patient Protocol Widget",
            "page_url": "/patients/123/protocol",
            "surface_ref": "daily-log",
            "metadata": {"section": "daily log"},
        },
        note="The save action silently fails after I edit today's protocol note.",
        reporter_role_hint="practitioner",
        attachment_ids=[attachment.id],
    )

    access = client.issue_reporting.get_issue_report_attachment_access(
        attachment_id=attachment.id,
    )
    print(issue.id, access.expires_in_seconds)
finally:
    client.close()

SPAPS accepts PNG, JPEG, and WebP screenshots up to 10 MiB each, with at most 5 retained screenshots per report. The hosted object remains private; callers fetch a short-lived access URL after normal issue-reporting authorization succeeds. SPAPS does not redact screenshot contents, so host apps should warn users when a capture may include sensitive data.

Skill Evals

Skill eval helpers wrap the SPAPS blind review endpoints for agent-skill logs. Paid case creation accepts a PAYMENT-SIGNATURE through payment_signature. Reviewers submit valuable and not_valuable marks, and submitters read those marks through an insight inbox before applying skill changes.

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    created = client.skill_evals.create_case(
        {
            "title": "Docs skill comparison",
            "task_claim": "Compare both implementations.",
            "success_criteria": ["Finds repo boundaries"],
            "candidates": [
                {
                    "candidate_id": "A",
                    "output_ref": "spaps-artifact://case/a",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/a.jsonl",
                    "jsonl_log_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.0",
                    "skill_version_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/a",
                },
                {
                    "candidate_id": "B",
                    "output_ref": "spaps-artifact://case/b",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/b.jsonl",
                    "jsonl_log_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.1",
                    "skill_version_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/b",
                },
            ],
            "case_policy": {
                "access_mode": "team_private",
                "allowed_model_efforts": [
                    {"model_id": "openai/gpt-5.4", "effort_level": "medium"}
                ],
                "participant_allowlist": ["reviewer-actor-id"],
            },
            "idempotency_key": "eval-create-001",
        },
        payment_signature=signed_payment,
    )
    room = client.skill_evals.get_review_room(created["case_id"])
    print(room["reviewer_state"])
    review = client.skill_evals.submit_review(
        created["case_id"],
        {
            "review_marks": [
                {
                    "candidate_id": "B",
                    "kind": "valuable",
                    "note": "B checks the configured docs path before recommending an edit.",
                    "reason_code": "prevents_wrong_repo_patch",
                    "confidence": "high",
                    "criterion": "Finds repo boundaries",
                }
            ]
        },
    )
    inbox = client.skill_evals.get_insights(created["case_id"])
    print(inbox["valuable"][0]["jsonl_log_ref"], review["review_mark_counts"])
    client.skill_evals.respond_to_review(
        created["case_id"],
        inbox["valuable"][0]["source_review_id"],
        {
            "response": "applied",
            "reason": "Updated the skill from the concrete log-backed insight.",
            "applied_insight_ref": inbox["valuable"][0]["insight_ref"],
            "skill_change_ref": "skill://docs-review/v2.1",
            "skill_version_before": "skill://docs-review/v2.0",
            "skill_version_after": "skill://docs-review/v2.1",
            "jsonl_log_ref": "spaps-artifact://logs/apply.jsonl",
            "jsonl_log_hash": (
                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
            ),
            "model_id": "openai/gpt-5.4",
            "effort_level": "medium",
        },
    )
finally:
    client.close()

Webhook Verification

from spaps_client import verify_spaps_webhook

payload = verify_spaps_webhook(
    body=request_body_bytes,
    signature=request_headers["X-SPAPS-Signature"],
    secret="whsec_example",
)

print(payload.type)

The default five-minute verification window allows bounded clock skew in either direction. Timestamps farther in the future or past are rejected.

Validation

From the repository root:

npm run lint:python-client
npm run typecheck:python-client
npm run test:python-client

For local package work:

cd packages/python-client
pip install -e '.[dev]'

Troubleshooting

ValueError: Access token not found

Authenticate first with client.auth... helpers, or seed tokens with your configured token storage.

401 or 403 responses

Check the API key, access token, and endpoint role requirements for the target environment.

Hosted examples fail against localhost

Set base_url="http://localhost:3301" and use a local development key or test credentials.

I need more control over HTTP behavior

Inject a custom httpx client or configure RetryConfig and logging hooks.

Which client should I start with?

Use SpapsClient unless your service is already async end to end.

Limitations

  • Some specialty integrations live as standalone helpers instead of methods on the main client.
  • Endpoint coverage tracks the active SPAPS backend surface; new backend features may appear here incrementally.
  • You still need to provide environment-specific API keys, tokens, and deployment URLs.

FAQ

Is the package name different from the import path?

Yes. Install spaps; import from spaps_client.

Does it support async codebases?

Yes. Use AsyncSpapsClient and the async helper classes.

Is webhook verification included?

Yes. Use verify_spaps_webhook.

Can I replace the default token storage?

Yes. Pass a custom token_storage implementation.

The default is InMemoryTokenStorage. For persistence, pass FileTokenStorage(path=...): it replaces the token file only after a complete write, keeps file permissions owner-only on POSIX, and rejects symlinked storage directories. Loading ignores symlinked or malformed token files. On platforms without directory-relative no-follow I/O, symlink checks are best-effort; use a private storage path with trusted ancestors.

Does it retry requests automatically?

It can. Pass RetryConfig if you want retry and backoff behavior.

Metadata

This checkout is the unpublished 0.8.3 source candidate. It includes APIs beyond the retained public 0.8.2 artifact and has no publication receipt. After publishing a version, advance the checkout to its next development version before changing its API. The offline package-surface check compares checkout declarations with retained public artifacts; release and consumer adoption still require their own receipts.

The metadata below describes this checkout. latest_version is the source package version; it does not establish registry publication or availability.

  • package_name: spaps
  • latest_version: 0.8.3
  • minimum_runtime: Python >=3.9
  • api_base_url: https://api.sweetpotato.dev

About Contributions

About Contributions: Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via gh and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.

License

MIT

CFO: server decision and browser enrollment

client.cfo.decide_access(...) and client.cfo.start_browser_relay(...) are server-only. The async client exposes the same methods with await. Decision requires the CFO application's secret and the current human bearer; allowed=False is a valid HTTP 200 denial. Start requires a trusted CfoBrowserSession (or AsyncCfoBrowserSession), current human bearer, and the real browser's refresh cookie. Start does not send the application secret. No connection inventory, provider exchange, callback, or delegation-freshen helper is exposed.

These calls use dedicated one-shot HTTPX transports. Shared SDK retries, logging hooks, automatic refresh, and injected aggregate HTTP clients do not apply. Configure the host so httpcore DEBUG/wire logging is disabled or redacted; the facade cannot prevent secrets appearing in arbitrary host instrumentation.

# CFO decision example
from spaps_client import CfoClient, CfoDecision


def decide_cfo_report(cfo: CfoClient, company_id: str) -> CfoDecision:
    return cfo.decide_access(
        company_id=company_id,
        effect="write",
        action="report.read",
        correlation_id="test:1",
    )

Construct a dedicated client with CfoClient(base_url=spaps_origin, api_key=application_secret, access_token_provider=current_access_token), or use an authenticated aggregate's client.cfo. The provider is called for each decision; it must return the current token, not a captured login token.

# CFO browser example
from spaps_client import CfoBrowserSession, CfoClient


def begin_cfo_enrollment(
    cfo: CfoClient, bridge: CfoBrowserSession, company_id: str
) -> str:
    return cfo.start_browser_relay(
        company_id=company_id, action="enroll", browser_session=bridge
    )

The bridge constructor requires spaps_origin, allowed application origin, browser user_agent, credentials_provider, invalidate_access, and response_handler. The credentials provider returns CfoBrowserCredentials(access_token=..., cookie_header=...) from the current authenticated browser request after host CSRF validation. Only the configured refresh cookie and spaps_cfo_context are forwarded. Use the same bridge for overlapping attempts in one browser context; concurrent attempts fail closed. Different browser contexts require different bridges.

response_handler(status, cookies, location) receives ordered raw allowlisted Set-Cookie values, including deletions and failure responses. It must apply them at the actual SPAPS browser origin before forwarding an optional validated Intuit navigation. A BFF on an unrelated hostname cannot set SPAPS host-only cookies. The callback URLs and cookies are sensitive trusted bridge inputs: do not serialize them into ordinary application results or logs. A local cookie sink does not provision this deployed bridge.

On refresh-cookie rotation, invalidate_access must invalidate the cached bearer. Before another deliberate call, the host must reacquire current access through shared SPAPS auth, apply any further cookie updates, and expose that new bearer. Neither adapter refreshes or replays the operation. CfoError exposes only bounded code, status, and optional request_id; it does not retain the raw HTTP error. Deployment still requires the real origin/CSRF/cookie bridge, safe host logging, external CFO executor adoption, and the human retirement gate. These checkout APIs do not establish published package availability.

Dashboard (checkout)

client.dashboard provides context(), list_applications(), get_application(id), list_records(kind, **filters), and get_record(kind, id). AsyncDashboardClient has matching asynchronous reads. Each read requires an application credential and a current user session. Results contain stored records for owned applications, or global-admin scope, with pagination and source freshness. Retained email content is detail-only; authentication content is restricted. See dashboard reads. Application dictionaries preserve entitlement_options for configured keys, sources and enabled flags, independently of overview.stored_grants. Missing/null catalog data from older servers is unavailable, not an empty catalog. Use list_records('email-templates') for template definitions and list_records('email-logs') for individual sends. Sync and async dictionaries preserve definition name/key, enabled state, stream, category and dates. Package publication and production deployment require separate evidence.

For the dedicated operator console, the sync and async clients also expose management_options(application_id), invite, send_recovery, grant, revoke, create_entitlement_option, and create_application. These require the console application's session and Viewer/Support/Admin entitlement checks; Viewer cannot write. Support handles approved ordinary app access, while Admin also manages console access and creates applications. See console roles and setup. Application option creation requires exact-app Admin authority and adds a manual key without granting user access. Existing console applications need the new exact POST route opt-in; checkout source does not establish production availability.

Application Billing uses billing_workspace(application_id) and billing_action(operation, application_id=..., expected_revision=..., data=...) in both clients. Actions use a fixed operation list and retain the caller's session; the server rechecks target-app authority. Account attachment requires portfolio Admin. An ambiguous provider failure is not retried automatically. See Billing configuration.

Metadata

Release files for spaps 0.8.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spaps 0.8.3
File Size Uploaded Provenance Actions
spaps-0.8.3.tar.gz 275.8 kB Without provenance Details for spaps-0.8.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for spaps 0.8.3
File Interpreter ABI Platform Actions
spaps-0.8.3-py3-none-any.whl Python 3 none any Details for spaps-0.8.3-py3-none-any.whl

Total release size: 467.1 kB

Release files / spaps-0.8.3.tar.gz

Download URL spaps-0.8.3.tar.gz
Size 275.8 kB
Tags Source
SHA-256 checksum
How to use checksums
b78880792d5a81322a07e429c31e31fa52ce57da712974eadc215392988a3557
BLAKE2b-256 checksum
How to use checksums
ac90c76ccd04fd1edbe3d9d0128704c3451e5c019c0aa27de80b7a2c4d8b8b2f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Release files / spaps-0.8.3-py3-none-any.whl

Download URL spaps-0.8.3-py3-none-any.whl
Size 191.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0d5da65c23f1b0c16a7a9ace4eecf91e169795b8877e63bf4d6e1b9d7962ab74
BLAKE2b-256 checksum
How to use checksums
517dc6bd507776587a8ba0fb46a04caf3c1394fc79441acfb29bfdee4c723335
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.15

Security for spaps 0.8.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page