---
summary: "Every ScaiPersona endpoint \u2014 personas, sources, publishing, avatars,\
  \ ScaiDrive shares."
title: API reference
path: reference/api
status: published
---

All endpoints are mounted at `/v1/modules/scaipersona/` and authenticate with the standard ScaiGrid bearer token (API key or JWT). Responses use ScaiGrid's standard envelope (`{ "data": ... }` for success, `{ "error": ... }` for failures). Once a persona is **published**, invocation happens through `POST /v1/inference/chat` — not through any endpoint listed below.

## Personas

### `GET /personas`

List personas in the caller's tenant. Cursor-paginated.

```bash
curl "$SCAIGRID_HOST/v1/modules/scaipersona/personas" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY"
```

Query parameters: `limit`, `cursor`, `order` (standard pagination).

### `POST /personas`

Create a persona.

| Field | Required | Notes |
|---|---|---|
| `name` | yes | Human-readable name. |
| `slug` | no | URL-safe, unique within tenant. Auto-derived from `name` if omitted. |
| `model_slug` | yes | Slug of the underlying frontend model to wrap. |
| `system_prompt` | no | Default `""`. Baked into the published frontend model. |
| `rag_enabled` | no | Default `false`. |
| `rag_strategy` | no | `single_step` (default), `multi_step`, `agentic`. |
| `rag_top_k` | no | 1-50, default 5. |
| `rag_min_score` | no | Float, optional. |
| `rag_context_template` | no | Template string with `$context` placeholder. |
| `rag_status_messages` | no | Map of status keys to template strings (used when `status_messages_mode: "custom"`). |
| `status_messages_mode` | no | `none`, `standard` (default), `custom`. |
| `default_params` | no | Arbitrary JSON merged into inference requests (`temperature`, `max_tokens`, etc.). |
| `status` | no | `draft` (default), `active`, `archived`. |
| `metadata` | no | Arbitrary JSON; not interpreted. |

Returns `201 Created` with the new persona.

### `GET /personas/{persona_id}`

Fetch one persona's full config. Returns `404 PERSONA_NOT_FOUND` if the persona doesn't exist or belongs to another tenant.

### `PUT /personas/{persona_id}`

Replace any subset of editable fields. Any non-null field in the body is applied; null fields are ignored. If the persona is currently published, the published frontend model is synced on save — `display_name`, `system_prompt_template`, `avatar_url`, `default_params`, and `metadata` are all updated in place.

### `DELETE /personas/{persona_id}`

Hard-delete the persona. If the persona is published, it's unpublished first (the frontend model is removed). Returns `204 No Content`. Sources cascade-delete.

## Publishing

### `POST /personas/{persona_id}/publish`

Materialise the persona as a frontend model with slug `tenant/{tenant_slug}/{persona_slug}`.

Body (optional):

```json
{ "group_ids": ["mg_xxx", "mg_yyy"] }
```

The publish call:

- Creates a new `FrontendModel` row inheriting modality, capabilities, context window, and pricing from the underlying model.
- Bakes the persona's `system_prompt` into the frontend model's `system_prompt_template`.
- Copies the underlying model's backend links so dispatch resolves identically.
- Stores `{persona_id, rag_enabled}` in the frontend model's metadata.
- If `group_ids` is present, adds the new model to those groups. Groups outside the caller's scope (global / cross-partner / cross-tenant) are silently skipped unless the caller is super-admin.

Idempotent: calling `publish` on an already-published persona returns the current state without error.

Returns `200 OK` with the persona (now `published: true`, `published_model_id` populated).

### `POST /personas/{persona_id}/unpublish`

Remove the persona's frontend model. The model row is deleted, its memberships in any model groups are dropped, and the persona row is set to `published: false`. Returns `200 OK` with the updated persona. Returns `409 PERSONA_NOT_PUBLISHED` if the persona wasn't published.

## Sources

### `GET /personas/{persona_id}/sources`

List sources attached to a persona, in creation order.

### `POST /personas/{persona_id}/sources`

Attach a knowledge source.

| Field | Required | Notes |
|---|---|---|
| `source_type` | yes | `collection` (ScaiMatrix) or `scaidrive` (ScaiDrive share). |
| `source_id` | yes | Collection id or share id. |
| `source_name` | no | Display label used in RAG context formatting. |
| `weight` | no | 0.0-10.0, default 1.0. |
| `search_config` | no | Per-source opaque config (`search_type` for collections). |

Returns `201 Created`. Returns `409 PERSONA_SOURCE_DUPLICATE` if the same `(source_type, source_id)` is already attached.

### `PUT /personas/{persona_id}/sources/{source_id}`

Update a source's `weight`, `search_config`, or `status` (`active`/`disabled`). Disabled sources are skipped at retrieval time.

### `DELETE /personas/{persona_id}/sources/{source_id}`

Remove a source. Returns `204 No Content`.

## Avatars

### `POST /personas/{persona_id}/avatar`

Upload an avatar image. Multipart form data: `file` (binary).

- Max size: 2 MB.
- Allowed types: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml`.
- Magic-byte validation runs on non-SVG uploads; mismatched declared/actual types are rejected.

Returns `{ "data": { "avatar_url": "/v1/modules/scaipersona/personas/{id}/avatar" } }`. If the persona is published, the published frontend model's avatar is synced on save.

Errors: `INVALID_FILE_TYPE` (400), `FILE_TOO_LARGE` (400), `STORAGE_ERROR` (502).

### `GET /personas/{persona_id}/avatar`

Serve the avatar image. **Public — no authentication required.** Cached for 1 hour. Returns `404 NO_AVATAR` if no avatar was ever uploaded.

## ScaiDrive integration

### `GET /scaidrive/shares`

List ScaiDrive shares accessible to the calling user. The endpoint exchanges the caller's bearer token for a ScaiDrive-scoped token, then asks ScaiDrive what the user can see. Use this to populate UI pickers for "which share should this persona read from?".

Errors:

- `503 SCAIDRIVE_NOT_CONFIGURED` — the module has no `scaidrive_base_url` configured.
- `502 SCAIDRIVE_TOKEN_EXCHANGE_FAILED` — the caller's token couldn't be exchanged (e.g. an API key without ScaiDrive scope).

## Invoking a published persona

Once published, callers invoke the persona through the standard inference endpoint — there is **no** `/test` endpoint on this module.

```bash
curl -X POST "$SCAIGRID_HOST/v1/inference/chat" \
  -H "Authorization: Bearer $SCAIGRID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "tenant/{tenant_slug}/{persona_slug}",
    "messages": [{"role": "user", "content": "..."}]
  }'
```

The persona enricher runs inside the inference pipeline — RAG retrieval and context injection happen automatically before dispatch.

## Errors

All endpoints return ScaiGrid's standard error envelope:

```json
{
  "error": {
    "code": "PERSONA_NOT_FOUND",
    "message": "Persona does not exist",
    "details": {}
  },
  "meta": { "request_id": "req_..." }
}
```

ScaiPersona-specific codes:

| Code | HTTP | Meaning |
|---|---|---|
| `PERSONA_NOT_FOUND` | 404 | Persona id doesn't exist, or it belongs to another tenant. |
| `PERSONA_SOURCE_NOT_FOUND` | 404 | Source id doesn't exist for that persona. |
| `PERSONA_ALREADY_PUBLISHED` | 409 | Publish called on an already-published persona (the route handles this as idempotent — current state returned). |
| `PERSONA_NOT_PUBLISHED` | 409 | Unpublish called on a persona that isn't published. |
| `PERSONA_SLUG_CONFLICT` | 409 | Another persona in the tenant already uses this slug. |
| `PERSONA_SOURCE_DUPLICATE` | 409 | Same `(source_type, source_id)` already attached to the persona. |
| `PERSONA_MODEL_NOT_FOUND` | 404 | The `model_slug` doesn't resolve to a frontend model — typically wrong slug, or the underlying model was deleted. |
| `INVALID_FILE_TYPE` | 400 | Avatar upload had an unsupported MIME type or failed magic-byte validation. |
| `FILE_TOO_LARGE` | 400 | Avatar upload exceeded 2 MB. |
| `NO_AVATAR` | 404 | Avatar requested for a persona that has none. |
| `STORAGE_ERROR` | 502 | S3 upload failed for an avatar. |
| `SCAIDRIVE_NOT_CONFIGURED` | 503 | The module has no ScaiDrive base URL set in config. |
| `SCAIDRIVE_TOKEN_EXCHANGE_FAILED` | 502 | Couldn't exchange the caller's token for a ScaiDrive token. |
