> ## Documentation Index
> Fetch the complete documentation index at: https://dev-docs.persona.hasanraiyan.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Node.js SDK

> The official TypeScript/Node.js client — auth, resources, and streaming chat, wrapped.

`@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](/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.

<Warning>
  **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](/guides/integration-guide) for the full
  reasoning.
</Warning>

## Install

<Note>
  **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:
</Note>

```bash theme={null}
git clone https://github.com/hasanraiyan/agent-marketplace.git
cd agent-marketplace/sdk
npm install
npm run build      # compiles src/ -> dist/ (ESM + CJS + .d.ts)
```

Then consume it from another local project either by pointing `npm install` at the folder directly:

```bash theme={null}
npm install /path/to/agent-marketplace/sdk
```

or by linking it, if you're iterating on the SDK itself alongside a consumer:

```bash theme={null}
# in agent-marketplace/sdk
npm link

# in your project
npm link @personaai/sdk
```

Once published, installing will be the usual one-liner:

```bash theme={null}
npm install @personaai/sdk
```

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`):

| Option           | Type           | Required | Description                                                                                                                                                 |
| ---------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl`        | `string`       | Yes      | Base URL of the Developer Platform API, e.g. `https://api.personaai.com`. Trailing slashes are stripped automatically.                                      |
| `credential`     | `string`       | Yes      | Your Project credential, shaped `"<keyId>.<secret>"`. Sent as `Authorization: Bearer <credential>` on every call. Read from an env var — never hardcode it. |
| `externalUserId` | `string`       | No       | Asserts this client acts on behalf of one of your own end users (sent as `x-persona-external-user-id`). Omit for Project-level (control-plane) calls.       |
| `fetch`          | `typeof fetch` | No       | Overrides the `fetch` implementation. Defaults to the global `fetch`. Mainly useful for tests or non-standard runtimes.                                     |
| `maxRetries`     | `number`       | No       | Maximum automatic retries on `429 Too Many Requests` responses. Defaults to `2`. See [Retries & rate limits](#retries-and-rate-limits).                     |

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](#framework-recipes)) rather than
trying to share a single instance across users.

```ts theme={null}
import { PersonaClient } from "@personaai/sdk";

// Control-plane client — acts as the Project itself.
const persona = new PersonaClient({
  baseUrl: process.env.PERSONA_BASE_URL!,
  credential: process.env.PERSONA_CREDENTIAL!,
});

// Runtime-plane client — acts as one specific end user of yours.
const userClient = new PersonaClient({
  baseUrl: process.env.PERSONA_BASE_URL!,
  credential: process.env.PERSONA_CREDENTIAL!,
  externalUserId: currentUser.id,
});
```

## Quickstart

```ts theme={null}
import { PersonaClient } from "@personaai/sdk";

const persona = new PersonaClient({
  baseUrl: "https://api.personaai.com",
  credential: process.env.PERSONA_CREDENTIAL!, // "<keyId>.<secret>", minted via Studio
});

const who = await persona.whoami();
console.log(who.principalType, who.domain);

const agent = await persona.agents.create({
  name: "Career Launchpad",
  systemPrompt: "You help students find internships.",
  providerId: "...", // an existing Provider's id
  visibility: "unlisted",
});
```

### 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](/guides/integration-guide#2-the-same-endpoint-serves-two-different-purposes-and-you-choose-which)
distinction from the Integration Guide.

```ts theme={null}
const userClient = new PersonaClient({
  baseUrl: "https://api.personaai.com",
  credential: process.env.PERSONA_CREDENTIAL!,
  externalUserId: currentUser.id,
});

const thread = await userClient.threads.create({ agentId: agent._id });
```

## 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:

```ts theme={null}
const who = await persona.whoami();
```

The returned shape depends on whether the client was constructed with `externalUserId`:

| `principalType`  | Shape                                                     | When you get it                                           |
| ---------------- | --------------------------------------------------------- | --------------------------------------------------------- |
| `ProjectMachine` | `{ domain, principalType, credentialId }`                 | `externalUserId` omitted — acting as the Project itself.  |
| `ProjectRuntime` | `{ domain, principalType, credentialId, externalUserId }` | `externalUserId` set — acting on behalf of that end user. |

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](#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:

```ts theme={null}
import {
  PersonaApiError,
  PersonaAuthError,
  PersonaValidationError,
} from "@personaai/sdk";
```

| Class                    | When it's thrown                                                            | Extra fields                                                                             |
| ------------------------ | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `PersonaApiError`        | Base class — any other non-2xx/`{success:false}` response.                  | `statusCode: number`, `code: string`, `response: unknown` (the parsed error body)        |
| `PersonaAuthError`       | `401` or `403` — invalid/missing credential, or the Project isn't `ACTIVE`. | Same fields as `PersonaApiError` (subclass — `instanceof PersonaApiError` also matches). |
| `PersonaValidationError` | `400` — request validation failed (bad/missing fields).                     | Same fields as `PersonaApiError`.                                                        |

`code` mirrors the backend's own machine-readable error code (e.g. `VALIDATION_ERROR`,
`UNAUTHORIZED`, `PROJECT_NOT_ACTIVE`, `NOT_FOUND`) — check the [API Reference](/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.

```ts theme={null}
try {
  await persona.agents.create({
    name: "",
    systemPrompt: "...",
    providerId: "...",
  });
} catch (err) {
  if (err instanceof PersonaValidationError) {
    console.error(`Bad request (${err.code}): ${err.message}`);
  } else if (err instanceof PersonaAuthError) {
    console.error(`Auth problem (${err.statusCode}): ${err.message}`);
  } else if (err instanceof PersonaApiError) {
    console.error(`API error (${err.statusCode}/${err.code}): ${err.message}`);
  } else {
    throw err; // not a Persona error — a network failure, abort, etc.
  }
}
```

## 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.

```ts theme={null}
const patientClient = new PersonaClient({
  baseUrl: "...",
  credential: "...",
  maxRetries: 5, // retry up to 5 times on 429 before throwing
});
```

**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:

```ts theme={null}
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000); // give up after 30s

await userClient.chat.sendMessage(agent._id, {
  messages: [{ role: "user", content: "..." }],
  signal: controller.signal,
});
```

## Resources

| Client property           | Wraps                                                              | Ownership scoping                                                    |
| ------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `.providers`              | `/api/v1/developer/providers`                                      | Control-plane only — no per-user Providers.                          |
| `.skills`                 | `/api/v1/developer/skills`                                         | Project or, with `externalUserId`, that end user.                    |
| `.agents`                 | `/api/v1/developer/agents`                                         | Project or, with `externalUserId`, that end user.                    |
| `.knowledge`              | `/api/v1/developer/knowledge` (incl. document upload/search)       | Project or, with `externalUserId`, that end user.                    |
| `.mcps` (+ `.mcps.oauth`) | `/api/v1/developer/mcps` (incl. OAuth owner/user connection flows) | Project or, with `externalUserId`, that end user.                    |
| `.threads`                | `/api/v1/developer/threads`                                        | Requires `externalUserId` — a Thread always belongs to one end user. |
| `.files`                  | `/api/v1/developer/files`                                          | Requires `externalUserId` — a File always belongs to one end user.   |
| `.chat`                   | `/api/v1/developer/agui` (streaming)                               | Requires `externalUserId` — a chat run always runs as one end user.  |

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).

| Method                                 | Wraps                                  | Returns                                 |
| -------------------------------------- | -------------------------------------- | --------------------------------------- |
| `providers.create(input)`              | `POST /providers`                      | `Provider`                              |
| `providers.get(providerId)`            | `GET /providers/{id}`                  | `Provider`                              |
| `providers.update(providerId, input)`  | `PATCH /providers/{id}`                | `Provider`                              |
| `providers.delete(providerId)`         | `DELETE /providers/{id}`               | `void`                                  |
| `providers.testConnection(providerId)` | `POST /providers/{id}/test-connection` | `{ success: boolean, message: string }` |
| `providers.getModels(providerId)`      | `GET /providers/{id}/models`           | `ProviderModel[]` (`{ id: string }[]`)  |

```ts theme={null}
const provider = await persona.providers.create({
  label: "OpenAI (production)",
  baseURL: "https://api.openai.com/v1",
  apiKey: process.env.OPENAI_API_KEY!, // encrypted at rest, never returned in any response
  defaultModel: "gpt-4o-mini",
  isDefault: true,
});

const { success, message } = await persona.providers.testConnection(
  provider.id,
);
const models = await persona.providers.getModels(provider.id);
```

`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

```ts theme={null}
const skill = await persona.skills.create({
  name: "Resume Reviewer",
  description: "Reviews a resume and suggests concrete edits.",
  instructions: "You are an expert resume reviewer...",
  isPublic: false,
  files: [{ path: "rubric.md", content: "# Scoring rubric\n..." }],
});

const mine = await userClient.skills.list({ scope: "mine", search: "resume" });
const full = await persona.skills.get(skill._id);
await persona.skills.update(skill._id, { description: "Updated description." });
await persona.skills.delete(skill._id);
```

| Method                     | Wraps                 | Returns   |
| -------------------------- | --------------------- | --------- |
| `skills.create(input)`     | `POST /skills`        | `Skill`   |
| `skills.list(params?)`     | `GET /skills`         | `Skill[]` |
| `skills.get(skillId)`      | `GET /skills/{id}`    | `Skill`   |
| `skills.update(id, input)` | `PATCH /skills/{id}`  | `Skill`   |
| `skills.delete(skillId)`   | `DELETE /skills/{id}` | `void`    |

`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

```ts theme={null}
const agent = await persona.agents.create({
  name: "Career Launchpad",
  systemPrompt: "You help students find internships.",
  providerId: provider.id,
  description: "Your internship search co-pilot.",
  category: "productivity",
  visibility: "unlisted",
  webSearchEnabled: true,
  skills: [skill._id],
});

const discovered = await persona.agents.list({
  category: "productivity",
  search: "career",
});
const full = await persona.agents.get(agent._id); // skills/mcps/knowledgeBases populated as objects
await persona.agents.update(agent._id, {
  tagline: "Find your next internship.",
});
await persona.agents.delete(agent._id);
```

| Method                     | Wraps                 | Returns   |
| -------------------------- | --------------------- | --------- |
| `agents.create(input)`     | `POST /agents`        | `Agent`   |
| `agents.list(params?)`     | `GET /agents`         | `Agent[]` |
| `agents.get(agentId)`      | `GET /agents/{id}`    | `Agent`   |
| `agents.update(id, input)` | `PATCH /agents/{id}`  | `Agent`   |
| `agents.delete(agentId)`   | `DELETE /agents/{id}` | `void`    |

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

```ts theme={null}
const kb = await persona.knowledge.create({
  name: "Placement Office FAQ",
  providerId: provider.id, // required — no "my default provider" concept on this API
  chunkSize: 1000,
  chunkOverlap: 200,
  topK: 5,
});

// Upload from a Node Buffer (fs.readFile) or an already-built Blob.
import { readFile } from "node:fs/promises";
const pdf = await readFile("./faq.pdf");
const result = await persona.knowledge.uploadDocuments(kb._id, [
  { filename: "faq.pdf", content: pdf, contentType: "application/pdf" },
]);
// result: { documentCount, chunkCount, files: [{ fileName, fileSize, mimeType, chunkCount }] }

const docs = await persona.knowledge.listDocuments(kb._id);
await persona.knowledge.deleteDocument(kb._id, "faq.pdf");

const hits = await persona.knowledge.search(
  kb._id,
  "What's the deadline to apply?",
  { topK: 3 },
);
// hits: { text, source, score }[]
```

| Method                                       | Wraps                                           | Returns                   |
| -------------------------------------------- | ----------------------------------------------- | ------------------------- |
| `knowledge.create(input)`                    | `POST /knowledge`                               | `KnowledgeBase`           |
| `knowledge.list(params?)`                    | `GET /knowledge`                                | `KnowledgeBase[]`         |
| `knowledge.get(kbId)`                        | `GET /knowledge/{id}`                           | `KnowledgeBase`           |
| `knowledge.update(kbId, input)`              | `PATCH /knowledge/{id}`                         | `KnowledgeBase`           |
| `knowledge.delete(kbId)`                     | `DELETE /knowledge/{id}`                        | `void`                    |
| `knowledge.uploadDocuments(kbId, files)`     | `POST /knowledge/{id}/documents` (multipart)    | `UploadDocumentsResult`   |
| `knowledge.listDocuments(kbId)`              | `GET /knowledge/{id}/documents`                 | `KnowledgeDocument[]`     |
| `knowledge.deleteDocument(kbId, sourceName)` | `DELETE /knowledge/{id}/documents/{sourceName}` | `DeleteDocumentResult`    |
| `knowledge.search(kbId, query, options?)`    | `POST /knowledge/{id}/search`                   | `KnowledgeSearchResult[]` |

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

```ts theme={null}
const mcp = await persona.mcps.create({
  name: "Internal Ticketing",
  transport: "http",
  url: "https://mcp.example.com",
  authType: "apiKey",
  apiKey: process.env.TICKETING_MCP_KEY!,
});

const { tools, resources, resourceTemplates } =
  await persona.mcps.testConnection(mcp._id);
const result = await persona.mcps.callTool(mcp._id, "create_ticket", {
  title: "Bug report",
});
const { text, mimeType } = await persona.mcps.readResource(
  mcp._id,
  resources[0].uri,
);
```

| Method                              | Wraps                             | Returns                                              |
| ----------------------------------- | --------------------------------- | ---------------------------------------------------- |
| `mcps.create(input)`                | `POST /mcps`                      | `Mcp`                                                |
| `mcps.list(params?)`                | `GET /mcps`                       | `Mcp[]`                                              |
| `mcps.get(mcpId)`                   | `GET /mcps/{id}`                  | `Mcp`                                                |
| `mcps.update(mcpId, input)`         | `PATCH /mcps/{id}`                | `Mcp`                                                |
| `mcps.delete(mcpId)`                | `DELETE /mcps/{id}`               | `void`                                               |
| `mcps.testConnection(mcpId)`        | `POST /mcps/{id}/test`            | `{ tools, resources, resourceTemplates }`            |
| `mcps.readResource(mcpId, uri)`     | `GET /mcps/{id}/resource?uri=...` | `{ text: string, mimeType: string }`                 |
| `mcps.callTool(mcpId, name, args?)` | `POST /mcps/{id}/call-tool`       | `unknown` (shape depends on the underlying MCP tool) |

`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'`.

<Note>
  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.
</Note>

#### OAuth connection flows (`mcps.oauth`)

For `authType: 'oauth'` MCPs, `mcps.oauth` drives the owner- and user-mode authorization flows:

| Method                                             | Wraps                                      | Returns                  |
| -------------------------------------------------- | ------------------------------------------ | ------------------------ |
| `mcps.oauth.getOwnerAuthorizeUrl(mcpId)`           | `GET /mcps/{id}/oauth/owner/authorize`     | `{ url: string }`        |
| `mcps.oauth.getUserAuthorizeUrl(mcpId, returnTo?)` | `GET /mcps/{id}/oauth/user/authorize`      | `{ url: string }`        |
| `mcps.oauth.getUserConnectionStatus(mcpId)`        | `GET /mcps/{id}/oauth/user/status`         | `{ connected: boolean }` |
| `mcps.oauth.disconnectUserConnection(mcpId)`       | `DELETE /mcps/{id}/oauth/user/connection`  | `void`                   |
| `mcps.oauth.disconnectOwnerConnection(mcpId)`      | `DELETE /mcps/{id}/oauth/owner/connection` | `void`                   |

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`.

```ts theme={null}
const thread = await userClient.threads.create({ agentId: agent._id });
const mine = await userClient.threads.list({ page: 1, limit: 20 });
await userClient.threads.updateTitle(thread._id, "Summer internship search");
const { messages, state } = await userClient.threads.getMessages(thread._id);
await userClient.threads.delete(thread._id);
```

| Method                                 | Wraps                        | Returns          |
| -------------------------------------- | ---------------------------- | ---------------- |
| `threads.create(input)`                | `POST /threads`              | `Thread`         |
| `threads.list(params?)`                | `GET /threads`               | `Thread[]`       |
| `threads.get(threadId)`                | `GET /threads/{id}`          | `Thread`         |
| `threads.updateTitle(threadId, title)` | `PATCH /threads/{id}`        | `Thread`         |
| `threads.delete(threadId)`             | `DELETE /threads/{id}`       | `void`           |
| `threads.getMessages(threadId)`        | `GET /threads/{id}/messages` | `ThreadMessages` |

`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.

```ts theme={null}
import { readFile } from "node:fs/promises";

const resume = await readFile("./resume.pdf");
const file = await userClient.files.upload({
  filename: "resume.pdf",
  content: resume,
  contentType: "application/pdf",
  threadId: thread._id, // optional — associates the file with a conversation
});

const mine = await userClient.files.list({ page: 1, limit: 20 });

const response = await userClient.files.download(file.id); // raw Response, not JSON
const bytes = await response.arrayBuffer();

await userClient.files.delete(file.id);
```

| Method                   | Wraps                     | Returns                    |
| ------------------------ | ------------------------- | -------------------------- |
| `files.upload(input)`    | `POST /files` (multipart) | `PersonaFile`              |
| `files.list(params?)`    | `GET /files`              | `PersonaFile[]`            |
| `files.download(fileId)` | `GET /files/{id}`         | `Response` (raw, not JSON) |
| `files.delete(fileId)`   | `DELETE /files/{id}`      | `void`                     |

`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.

```ts theme={null}
// Full event stream, for building your own UI.
for await (const event of userClient.chat.stream(agent._id, {
  messages: [{ role: "user", content: "What internships are open right now?" }],
})) {
  if (event.type === "TEXT_MESSAGE_CHUNK" && event.delta)
    process.stdout.write(event.delta);
}

// Or the convenience wrapper — drains the stream, returns the final text.
const result = await userClient.chat.sendMessage(agent._id, {
  messages: [{ role: "user", content: "What internships are open right now?" }],
});
console.log(result.text);
```

| Method                               | Returns                     | Description                                                                     |
| ------------------------------------ | --------------------------- | ------------------------------------------------------------------------------- |
| `chat.stream(agentId, options)`      | `AsyncGenerator<AguiEvent>` | The raw event sequence, as it arrives — full control for a custom UI.           |
| `chat.sendMessage(agentId, options)` | `Promise<ChatResult>`       | Drains `stream()` for you; returns assembled text plus interrupt/events detail. |

`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`):

| Event type                                      | Meaning                                                                                                                                                                                |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RUN_STARTED`                                   | Emitted once, at the start of the run.                                                                                                                                                 |
| `TEXT_MESSAGE_CHUNK`                            | A streamed assistant text delta (`event.delta`).                                                                                                                                       |
| `REASONING_MESSAGE_START` / `_CONTENT` / `_END` | Streamed model reasoning, for models that expose it.                                                                                                                                   |
| `TOOL_CALL_CHUNK`                               | A streamed tool-call invocation (arguments arrive incrementally).                                                                                                                      |
| `TOOL_CALL_RESULT`                              | The result of a completed tool call.                                                                                                                                                   |
| `STATE_SNAPSHOT`                                | A full-state update.                                                                                                                                                                   |
| `CUSTOM`                                        | Persona-specific side-channel events — subagent traces, MCP structured content, and this SDK's own human-in-the-loop signaling (`hitl_request` / `clarification_request` — see below). |
| `RUN_FINISHED`                                  | Emitted once, at the end of the run (omitted if the run paused on an interrupt instead).                                                                                               |

```ts theme={null}
for await (const event of userClient.chat.stream(agent._id, { messages })) {
  switch (event.type) {
    case "TEXT_MESSAGE_CHUNK":
      process.stdout.write(event.delta ?? "");
      break;
    case "TOOL_CALL_RESULT":
      console.log("tool result:", event);
      break;
    case "RUN_FINISHED":
      console.log("done");
      break;
  }
}
```

### 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`:

```ts theme={null}
const result = await userClient.chat.sendMessage(agent._id, { messages });

if (result.interrupt) {
  console.log(result.interrupt.kind); // 'hitl' | 'clarification'
  console.log(result.interrupt.value); // whatever detail the interrupt carries
}
```

| `interrupt.kind`  | Triggering `CUSTOM` event name | Resume with                                                         |
| ----------------- | ------------------------------ | ------------------------------------------------------------------- |
| `'hitl'`          | `hitl_request`                 | `{ decisions: [{ action, decision: 'approve' \| 'reject' }, ...] }` |
| `'clarification'` | `clarification_request`        | `{ answers: [...], text?: string }`                                 |

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:

```ts theme={null}
if (result.interrupt) {
  await userClient.chat.sendMessage(agent._id, {
    threadId: thread._id,
    messages: [],
    resume: { decisions: [{ action: "delete_agent", decision: "approve" }] },
  });
}
```

`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](#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](https://app.personaai.com/developer) instead.

## Framework recipes

### Express

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

```ts theme={null}
// persona.ts
export function personaFor(externalUserId?: string) {
  return new PersonaClient({
    baseUrl: process.env.PERSONA_BASE_URL!,
    credential: process.env.PERSONA_CREDENTIAL!,
    externalUserId,
  });
}

// routes/chat.ts
app.post("/api/chat", async (req, res) => {
  const persona = personaFor(req.user.id);
  const result = await persona.chat.sendMessage(req.body.agentId, {
    messages: req.body.messages,
  });
  res.json(result);
});
```

### 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.

```ts theme={null}
// persona.service.ts
import { Injectable } from "@nestjs/common";
import { PersonaClient } from "@personaai/sdk";

@Injectable()
export class PersonaService {
  forUser(externalUserId: string) {
    return new PersonaClient({
      baseUrl: process.env.PERSONA_BASE_URL!,
      credential: process.env.PERSONA_CREDENTIAL!,
      externalUserId,
    });
  }
}
```

### 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.

<Warning>
  **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.
</Warning>

```ts theme={null}
// app/api/chat/route.ts — Route Handler, runs server-side only
import { PersonaClient } from "@personaai/sdk";

export async function POST(req: Request) {
  const { agentId, messages } = await req.json();
  const userId = await getCurrentUserId(req); // your own auth
  const persona = new PersonaClient({
    baseUrl: process.env.PERSONA_BASE_URL!,
    credential: process.env.PERSONA_CREDENTIAL!,
    externalUserId: userId,
  });
  const result = await persona.chat.sendMessage(agentId, { messages });
  return Response.json(result);
}
```

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:

```ts theme={null}
export async function POST(req: Request) {
  const { agentId, messages } = await req.json();
  const persona = new PersonaClient({
    /* ... */ externalUserId: await getCurrentUserId(req),
  });

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const event of persona.chat.stream(agentId, { messages })) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(event)}\n\n`),
        );
      }
      controller.close();
    },
  });
  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}
```
