Skip to main content
@personaai/sdk is the official Node.js/TypeScript 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. If you’re integrating from Node.js, start here instead of calling fetch yourself. 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 in a browser bundle, a mobile app, or a Next.js Client Component. See the Integration Guide for the full reasoning.

Install

Public npm package coming soon. @personaai/sdk isn’t published to the npm registry yet — npm install @personaai/sdk won’t resolve until it is. Until then, build it locally from source:
Then consume it from another local project either by pointing npm install at the folder directly:
or by linking it, if you’re iterating on the SDK itself alongside a consumer:
Once published, installing will be the usual one-liner:
Requires Node.js 18+ (uses the built-in fetch/FormData/ReadableStream — no heavy HTTP client dependency). The package ships both ESM and CommonJS builds plus .d.ts types, so import and require both work.

Configuration

Every entry point takes the same options shape (PersonaClientOptions, re-exported as HttpClientOptions): Constructing a client is cheap and stateless beyond these options — there’s no connection pool or warm-up step, so it’s fine to construct a fresh, per-request client scoped to whichever user is making the request (see Configuration patterns per framework) rather than trying to share a single instance across users.

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 externalUserId) maps to — a side-effect-free way to sanity-check auth wiring before making real calls:
The returned shape depends on whether the client was constructed with externalUserId: 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, throws a typed error instead of resolving. 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 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 HttpClient, which automatically retries 429 Too Many Requests responses: it reads the response’s Retry-After header (seconds), waits that long, and retries — up to maxRetries times (default 2) before giving up and throwing a PersonaApiError with statusCode: 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.
Only 429 is retried automatically. Any other error (400, 401, 403, 404, 500, etc.) throws immediately on the first attempt, since retrying those wouldn’t change the outcome.

Cancelling a request

Every method that accepts an options object with a signal field (currently chat.stream() / chat.sendMessage()) supports a standard AbortSignal for cancellation:

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 arrays, not a pagination envelope. Every list()/discover()-style method (skills.list(), agents.list(), knowledge.list(), mcps.list(), threads.list(), files.list()) returns a plain array, not {items, pagination} — despite accepting page/limit query params. Fetch a page, check the returned array’s length against limit to infer whether there’s more.

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?. 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 ProjectRuntimeContext client). 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). Don’t assume one shape works for both.
  • systemPrompt and providerId are stripped 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 fields are typed optional for exactly this reason.
  • DiscoverAgentsParams adds category? on top of the usual page?/limit?/search?/scope?.

Knowledge

Limits: up to 10 files per uploadDocuments() 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. search()’s options.topK 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 getOwnerAuthorizeUrl() from a control-plane client and redirect your admin through it once. User-mode authorizes one specific end user’s own token — call getUserAuthorizeUrl() from a runtime-plane client (externalUserId set) and redirect that user through it; returnTo 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 externalUserId — 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 unknown[] 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 typed AG-UI events for anything you’re building a live UI around.

Files

Every method requires the client to have been constructed with externalUserId, 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 Response so you can call .arrayBuffer()/.blob(), or pipe .body (a ReadableStream) straight onward, e.g. into an HTTP response in a Route Handler, without buffering the whole file in memory.

Chat, streamed

chat requires the client to have been constructed with externalUserId — a chat run always executes as a specific end user.
SendMessageOptions: messages: { role: 'user' | 'assistant', content: string }[], threadId? (resumes a named Thread from threads.create() instead of the implicit deterministic one-conversation-per-Agent-per-user thread), resume? (see below), signal? (an AbortSignal).

AG-UI event types

stream() yields the standard @ag-ui/core EventType union (re-exported from this package as EventType so you don’t need a separate @ag-ui/core dependency just to compare event.type):

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. sendMessage() detects this for you and sets result.interrupt:
Resume on the next sendMessage()/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: AguiEvent[] — every raw event received during that call, in order, for anything text/interrupt don’t already surface.

TypeScript types

Every public type is exported from the package root (import type { ... } from "@personaai/sdk") — resource clients (ProvidersResource, SkillsResource, AgentsResource, KnowledgeResource, McpsResource, McpOAuthResource, ThreadsResource, FilesResource, ChatClient), every create/update/list-params/result interface referenced in the tables above, PrincipalContext and its two variants, AguiEvent/EventType, and the error classes from Error handling. There’s no separate @personaai/sdk/types entry point to remember — one import covers the client, every resource, and every type.

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

Express

Construct the client once at module scope and use it in your route handlers — no special handling needed.

NestJS

Wrap PersonaClient in an @Injectable() provider so it plugs into Nest’s DI container like any other third-party client — the SDK itself needs no Nest-specific support.

Next.js — read this one carefully

Next.js blurs server and client code in one codebase more than Express or Nest does, which makes it the one framework people actually leak the credential in by accident.
Only import this SDK in Server Components, Route Handlers (app/api/.../route.ts), or Server Actions. Never in a "use client" component. Constructing PersonaClient in client code bundles your Project credential straight into the JavaScript shipped to the browser.
Because the SDK uses native fetch, it also works in Next.js’s Edge runtime, not just Node — no extra configuration needed. For a live-streaming chat UI, a Route Handler can return a ReadableStream directly, relaying the AG-UI event stream onward to your own browser client: