Skip to main content
personaai is the official Python client for everything on this page — auth header injection, typed resource methods for Agents/Skills/Knowledge/MCP/Providers/Threads/Files, and a streaming chat client, all wrapping the same REST + AG-UI API described in the API Reference. It ships both a sync client (PersonaClient) and an async client (AsyncPersonaClient) built on the same httpx-based transport — use whichever matches your framework. Every method on every resource client is a thin, typed 1:1 wrapper over a real endpoint — there’s no hidden behavior, so this page doubles as a complete method-by-method reference of the client surface.
Server-side only, same as everything else on this page. The SDK sends your Project’s credential on every call — a server-side secret. Never construct PersonaClient/AsyncPersonaClient in code an untrusted party can read (a Jupyter notebook you share publicly, a serverless function that logs its environment, etc.). See the Integration Guide for the full reasoning.

Install

Public PyPI package coming soon. personaai isn’t published to PyPI yet — pip install personaai won’t resolve until it is. Until then, build it locally from source:
Or install it editable into another local project you’re working on:
Once published, installing will be the usual one-liner:
Requires Python 3.9+. Depends on httpx only — no requests, no aiohttp, and no separate AG-UI protocol package (see Chat, streamed for why).

Configuration

PersonaClient (sync) and AsyncPersonaClient (async) take the same constructor shape: Constructing a client is cheap and stateless beyond these options, so it’s fine to construct a fresh, per-request client scoped to whoever is making the request (see Framework recipes) rather than trying to share a single instance across users.
Both clients also work as context managers — with PersonaClient(...) as persona: / async with AsyncPersonaClient(...) as persona: — which closes the underlying httpx client for you on exit.

Quickstart

Or async, identical shape:

Acting on behalf of one of your own end users

Construct a second client scoped to whoever is actually using your product right now — after your own auth has confirmed who that is. This maps directly onto the control-plane vs. runtime-plane distinction from the Integration Guide.

Authentication & principal context

persona.whoami() resolves the principal context your credential (and optional external_user_id) maps to — a side-effect-free way to sanity-check auth wiring before making real calls:
The returned dict’s shape depends on whether the client was constructed with external_user_id: There is no third ProjectAdmin variant reachable from the SDK — that context only comes from a Clerk-authenticated human admin session (Developer Studio), a completely different auth model the SDK deliberately doesn’t implement (see Out of scope).

Error handling

Every non-2xx response, or a {success:false} JSON envelope on a 2xx, raises a typed exception instead of returning normally. All error classes are exported from the package root:
code mirrors the backend’s own machine-readable error code (e.g. VALIDATION_ERROR, UNAUTHORIZED, PROJECT_NOT_ACTIVE, NOT_FOUND) — check the API Reference for each endpoint’s documented error responses. message (available via str(err) or err.args[0]) is the human-readable string from the same envelope, safe to log or (after your own review) surface to a user.

Retries and rate limits

Every resource call goes through the shared transport, which automatically retries 429 Too Many Requests responses: it reads the response’s Retry-After header (seconds), waits that long, and retries — up to max_retries times (default 2) before giving up and raising a PersonaApiError with status_code: 429. If Retry-After is absent it falls back to a 1-second wait. This is a fixed per-attempt wait, not exponential backoff, matching the API’s own Retry-After contract rather than guessing at a backoff curve. The same retry logic covers chat.stream()’s streaming requests, not just regular JSON calls.
Only 429 is retried automatically. Any other error (400, 401, 403, 404, 500, etc.) raises immediately on the first attempt, since retrying those wouldn’t change the outcome.
There is no request-cancellation parameter (no AbortSignal equivalent) in v1. For the async client, wrap a call in asyncio.wait_for(...) or cancel the enclosing asyncio.Task yourself if you need a timeout/cancellation — this is a documented gap, not a hidden one.

Resources

Two real backend quirks show up across the typed responses below, documented rather than papered over:
  • _id vs. id. Skill/Agent/Knowledge/Thread/Mcp responses use the raw MongoDB document shape (_id). Provider/File responses go through a clean formatted DTO (id). Check each type below for which one applies.
  • Bare lists, not a pagination envelope. Every list()-style method (skills.list(), agents.list(), knowledge.list(), mcps.list(), threads.list(), files.list()) returns a plain list, not {items, pagination} — despite accepting page/limit params. Fetch a page, check the returned list’s length against limit to infer whether there’s more.
All resource types are TypedDicts — plain dicts at runtime, typed purely for editor/type- checker support (mypy/pyright). Request bodies keep the API’s own camelCase field names (providerId, systemPrompt, …) even though method and parameter names are Pythonic snake_case — see Python types.

Providers

Control-plane only — there is no list/discover endpoint for Providers, and no ExternalUser ownership concept (an end user can never own a Provider).
CreateProviderInput/UpdateProviderInput fields: label, baseURL, apiKey (create only, required), defaultModel, isDefault (optional). apiKey is write-only — it’s never present on the Provider object returned from any call.

Skills

DiscoverSkillsParams: page, limit, search, scope: "mine" (restricts to the asserted end user’s own Skills — only meaningful on a runtime-plane client), all optional. Skill["isOwner"] is present only on the result of get(), not list()/create()/update().

Agents

Notes on the Agent shape:
  • visibility: "private" | "unlisted" | "public". category: "productivity" | "coding" | "creative" | "research" | "roleplay" | "other".
  • agents.get() populates skills/mcps/knowledgeBases as full objects; create()/update()/ list() return them as bare id strings (or omit them) — typed loosely (list[object]) to reflect that real difference.
  • systemPrompt and providerId are absent from the response entirely when the calling identity doesn’t own the Agent (e.g. browsing a public Agent that belongs to someone else) — both keys are optional in the Agent TypedDict for exactly this reason.
  • DiscoverAgentsParams adds category on top of the usual page/limit/search/scope.

Knowledge

Limits: up to 10 files per upload_documents() call, 20MB each, restricted to PDF/TXT/MD/JSON/CSV. providerId is required on CreateKnowledgeBaseInput — unlike Persona’s own (non-Developer) Knowledge routes, there’s no “use my default Provider” fallback for a Project or ExternalUser caller. Each file’s content is bytes or a binary file object (BinaryIO). search()’s top_k overrides the KnowledgeBase’s own configured topK for that one call only.

MCP connectors

transport: "http" | "sse". authType: "none" | "oauth" | "apiKey". authMode: "owner" | "user" (owner-mode shares one connection across everyone; user-mode gives each external user their own). apiKey is required when authType: "apiKey"; oauth (or useDynamicRegistration: True) is required when authType: "oauth".
Creating an authType: "oauth" MCP synchronously probes the target URL’s OAuth discovery endpoints — pointing it at a URL that doesn’t actually implement OAuth discovery will fail the create() call itself, not just a later connection test.

OAuth connection flows (mcps.oauth)

For authType: "oauth" MCPs, mcps.oauth drives the owner- and user-mode authorization flows: Owner-mode authorizes the MCP itself, shared by every user of your Project — call get_owner_authorize_url() from a control-plane client and redirect your admin through it once. User-mode authorizes one specific end user’s own token — call get_user_authorize_url() from a runtime-plane client (external_user_id set) and redirect that user through it; return_to is an optional client-chosen URL to send them back to once the flow completes.

Threads

Every method requires the client to have been constructed with external_user_id — a Thread’s Subject is always a specific end user, so a bare Project credential is rejected with a 400 EXTERNAL_USER_REQUIRED PersonaValidationError.
Thread["agentId"] is populated as {_id, name, avatar, slug} on list(), but a bare id string on create()/get() — check which call produced the value before assuming a shape. ThreadMessages["messages"] holds raw LangChain-style message objects (role/content/tool_calls vary by message type) and is intentionally typed as list[Any] rather than guessing at a shape the SDK doesn’t own — inspect it at runtime if you need to render history yourself, or prefer chat.stream()’s events for anything you’re building a live UI around.

Files

Every method requires the client to have been constructed with external_user_id, same requirement and rejection behavior as Threads — a File’s Subject is always a specific end user.
files.download() is the one method on the whole client that doesn’t return parsed JSON — it returns the raw httpx.Response so you can read .content/.text/.iter_bytes() as your application needs.

Chat, streamed

chat requires the client to have been constructed with external_user_id — a chat run always executes as a specific end user. ChatClient.stream() (sync) is a regular generator; AsyncChatClient.stream() (async) is an async generator — the natural per-language idiom for the same AG-UI event stream.
Async is the same shape:
messages: list[{"role": "user" | "assistant", "content": str}]. thread_id resumes a named Thread from threads.create() instead of the implicit deterministic one-conversation-per-Agent- per-user thread. resume — see Human-in-the-loop below.

AG-UI event types

Events are typed as a loose AguiEvent = dict[str, Any] rather than pulling in an AG-UI protocol package — this SDK doesn’t depend on one, mirroring the Node SDK’s own call to reject @ag-ui/client’s heavier dependency chain in favor of a hand-rolled parser. EventType is a plain class of string constants for comparing against event["type"] — not a real Python enum, so no .value unwrapping needed:

Human-in-the-loop: interrupts and resuming

If an Agent’s configuration requires confirming a sensitive tool call, or the run needs a clarifying answer from the user, the run pauses instead of finishing normally and emits a CUSTOM event instead of RUN_FINISHED. send_message() detects this for you and sets result["interrupt"]:
Resume on the next send_message()/stream() call for the same Thread, with messages=[] (no new user message — you’re answering the interrupt, not starting a new turn) and resume set:
ChatResult also exposes events: list[AguiEvent] — every raw event received during that call, in order, for anything text/interrupt don’t already surface.

Python types

Every public type is exported from the package root (from personaai import ...) — both client classes, every resource client (Providers/AsyncProviders, Skills/AsyncSkills, Agents/AsyncAgents, Knowledge/AsyncKnowledge, Mcps/AsyncMcps, McpOAuth/AsyncMcpOAuth, Threads/AsyncThreads, Files/AsyncFiles, ChatClient/AsyncChatClient), every create/update/list-params/result TypedDict referenced in the tables above, PrincipalContext and its two variants, AguiEvent/EventType, and the error classes from Error handling. There’s no separate submodule to remember — one from personaai import ... covers the client, every resource, and every type. Unlike a validating model library (Pydantic, etc.), these types are plain TypedDicts — zero runtime behavior, purely for mypy/pyright support — consistent with “every method mirrors the real REST endpoint 1:1, no hidden behavior.”

Out of scope

Project/Members/Credentials management (creating Projects, inviting Members, minting/revoking credentials) is intentionally not part of this SDK — those routes are Clerk-session (human-admin) authenticated, a structurally different auth model than the machine-credential calls this SDK makes end to end. Manage them from Developer Studio instead.

Framework recipes

Flask

No special handling — construct the client once at module scope and use it in your view functions.

FastAPI

Construct the client per-request via a Depends() provider so it plugs into FastAPI’s own dependency-injection system — the SDK itself needs no FastAPI-specific support, and this uses the async client since FastAPI route handlers are async def.

Django

Wire a module-level singleton, same idea as Flask — Django views are sync by default, so the sync PersonaClient is the natural fit.
Keep PERSONA_CREDENTIAL in your environment / secrets manager and read it into settings.py (os.environ["PERSONA_CREDENTIAL"]) — never commit it, and never expose it via a template context processor a browser can read.