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.
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: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.
with PersonaClient(...) as persona: /
async with AsyncPersonaClient(...) as persona: — which closes the underlying httpx client for
you on exit.
Quickstart
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:
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 retries429 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.
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:
_idvs.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 acceptingpage/limitparams. Fetch a page, check the returned list’s length againstlimitto infer whether there’s more.
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 noExternalUser
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()populatesskills/mcps/knowledgeBasesas full objects;create()/update()/list()return them as bare id strings (or omit them) — typed loosely (list[object]) to reflect that real difference.systemPromptandproviderIdare absent from the response entirely when the calling identity doesn’t own the Agent (e.g. browsing apublicAgent that belongs to someone else) — both keys are optional in theAgentTypedDict for exactly this reason.DiscoverAgentsParamsaddscategoryon top of the usualpage/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 withexternal_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 withexternal_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.
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 looseAguiEvent = 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 aCUSTOM
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 aDepends() 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 syncPersonaClient is the natural fit.
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.