API reference
Ce contenu n’est pas encore disponible dans votre langue.
The server exposes a REST API consumed by the desktop client. All endpoints return application/json. Errors share a single shape:
{ "error": "error_code", "message": "Human-readable description" }| Status | Meaning |
|---|---|
| 400 | Validation failure (response also includes an errors array) |
| 401 | Missing, invalid, or expired JWT |
| 403 | The licence does not carry the route’s feature, or an account-level gate refused (e2ee_required, business_profile_incomplete) |
| 404 | Unknown — and also the answer for somebody else’s row, which is never a 403 |
| 409 | Sync conflict or quota violation |
| 429 | Rate limited (rate_limited), or the caller is over its group’s credit budget (Token Limit Exceeded, with period, unit — always "credits" — limit, used and resetsAt, plus poolLimit and poolUsed on a weekly refusal where the group has a shared pool; both pool keys are absent, not zero, when it has none). period names either the member’s own window (hourly, daily, weekly) or the ceiling on the whole group’s spend (group-daily, group-weekly), which never carries the pool keys. A server with free-tier access suspended answers a third shape: period is free_tier_suspended and only resetsAt comes with it — no unit, limit or used, since there is no allowance to report. Read period first. All carry Retry-After in seconds |
| 502 | Upstream AI provider connection failure |
| 503 | AI proxy not configured |
| 504 | Upstream AI provider timeout |
Each table below carries three columns worth reading before you call anything:
- Auth —
JWTneeds a bearer token,—is anonymous,AdminPolicyis the admin console’s cookie, andX-Pia-Service-Keyis the knowledge ingestion key. - Rate — which limiter budget the route draws on.
authandlocal-authare much tighter thanglobal; the numbers are in Configuration →RateLimit. - Feature — the licence feature the route requires. On an edition without it the route answers 403, so a Community server refuses everything marked
OAuth,GroupManagementorKnowledge. See Licensing.
Two wire conventions hold everywhere, and both will bite a generated client that assumes otherwise:
- Null fields are omitted, not sent as
null. Treat an absent key as “no value”, and do not require it. - Enums are integers, not strings — deliberately, so that no enum-bearing DTO ever moves. Each one’s numbering is given where it appears.
DTOs are defined in the Pia.Shared project (net10.0, no Windows dependency). Sync DTOs are prefixed with Sync, server entities with Server. Mapping between layers is explicit — no AutoMapper.
The generated description
Section titled “The generated description”Your own server can serve everything on this page as OpenAPI 3.1. Set OpenApi__Enabled=true
(Configuration → OpenApi), restart, and:
curl https://your-server/openapi/v1.jsonIt is generated from the route table, so paths, methods, parameters and request bodies cannot drift from the code — import it into Postman, Insomnia or a client generator instead of transcribing the tables below. The admin plane is deliberately absent from it.
Auth endpoints
Section titled “Auth endpoints”Every sign-in route ends in the same envelope:
{ "accessToken": "jwt...", "refreshToken": "opaque-string", "expiresIn": 900, "user": { "id": "…", "email": "…", "displayName": "…", "provider": "local", "requiresBusinessProfile": false }}POST /auth/refresh answers with the first three fields only — it renews a session rather than establishing one, so it carries no user.
user.requiresBusinessProfile is the flag to check right after sign-in: true means the account still owes its trader declaration and everything outside the auth surface will answer 403 business_profile_incomplete until it posts /auth/business-profile.
Access tokens are JWT (HS256, issuer pia-server, audience pia-client) and live 15 minutes. Refresh tokens are opaque strings stored hashed; rotation is mandatory on every refresh, with a 10-token-per-user cap.
Single sign-on
Section titled “Single sign-on”All three routes here require the OAuth licence feature, so a Community server answers 403 on every one of them and signs in locally instead.
| Method | Path | Auth | Rate | Body / Params |
|---|---|---|---|---|
GET |
/auth/login |
— | auth |
?provider=google|microsoft|entraid&redirect_uri=…&code_challenge=…&state=… |
GET |
/auth/callback |
— | auth |
The provider’s redirect target |
POST |
/auth/token |
— | auth |
{ "code": "…", "codeVerifier": "…" } |
A desktop client signs in with a PKCE-style code exchange, and no token ever travels in a URL:
- Keep a random
code_verifier. Call/auth/loginwithcode_challenge= base64url(SHA-256(verifier)), your loopbackredirect_uri, and an opaquestatenonce. - The provider returns the user to
/auth/callback, which redirects to{redirect_uri}?code=<one-time>&state=<yours>. Thestateis echoed verbatim so your listener can reject a callback fired by some other local process. - Redeem the code with
POST /auth/tokenplus yourcodeVerifier. The tokens come back in the response body.
The code is single-use with a two-minute lifetime, and it is burned on any failure — one guess each. Every failure is the same 400 invalid_grant, deliberately: which of the two halves was wrong is not something a caller gets to learn.
code_challenge is required with a loopback redirect_uri (400 code_challenge_required), 400 code_challenge_invalid when malformed, and 400 code_challenge_unexpected with any other target. Calling /auth/callback without a redirect_uri — a server-side flow — returns the token JSON directly.
Every loopback redirect carries your state back, including the three error= ones. A browser sign-in that fails instead lands on /auth/error?reason=…, which is a page in the admin console rather than an API route and is not described here.
Local sign-in
Section titled “Local sign-in”Always available — this is how the Community edition signs in, and it needs no licence feature. The tighter local-auth budget applies to the password and passkey login routes.
| Method | Path | Auth | Rate | Body / Params |
|---|---|---|---|---|
POST |
/auth/register |
— | auth |
{ "email", "password", "displayName?", "customerType?", "companyName?" } |
POST |
/auth/login/local |
— | local-auth |
{ "email", "password" } |
POST |
/auth/login/mfa |
— | local-auth |
The second factor, when login asked for one |
POST |
/auth/forgot-password |
— | auth |
{ "email" } |
POST |
/auth/reset-password |
— | auth |
The token from the mail, plus the new password |
GET |
/auth/verify-email |
— | auth |
?userId=…&token=… |
POST |
/auth/refresh |
— | auth |
{ "refreshToken": "…" } |
POST |
/auth/logout |
— | auth |
{ "refreshToken": "…" } |
GET |
/auth/me |
JWT | auth |
— |
customerType is an integer: 0 not declared, 1 business, 2 consumer. It defaults to 0 so older clients keep registering, and it only matters where the operator sets LocalAuth:RequireBusinessDeclaration — there 0 is refused, 1 additionally needs companyName, and an account that still owes the declaration is held at 403 business_profile_incomplete on every route except sign-in and /auth/account/*.
/auth/forgot-password answers 200 whether or not the address exists, so it cannot be used to enumerate accounts. It does not fail silently on a server with no mail set up, though: the SMTP check runs before the lookup, so an unconfigured server answers 400 smtp_not_configured for every address alike.
Multi-factor and passkeys
Section titled “Multi-factor and passkeys”| Method | Path | Auth | Rate | Purpose |
|---|---|---|---|---|
POST |
/auth/mfa/totp/begin |
JWT | global |
Start TOTP enrolment |
POST |
/auth/mfa/totp/verify |
JWT | global |
Confirm the first code |
POST |
/auth/mfa/disable |
JWT | global |
Turn it off |
POST |
/auth/mfa/recovery-codes/regenerate |
JWT | global |
New recovery codes; the old set stops working |
GET |
/auth/webauthn/credentials |
JWT | global |
List this account’s passkeys |
DELETE |
/auth/webauthn/credentials/{id} |
JWT | global |
Remove one |
POST |
/auth/webauthn/register/begin |
JWT | global |
Start adding a passkey |
POST |
/auth/webauthn/register/complete |
JWT | global |
Finish adding it |
POST |
/auth/webauthn/login/begin |
— | local-auth |
Start a passkey sign-in |
POST |
/auth/webauthn/login/complete |
— | local-auth |
Finish it — answers with the token envelope |
Sessions and account
Section titled “Sessions and account”| Method | Path | Auth | Rate | Purpose |
|---|---|---|---|---|
GET |
/auth/sessions |
JWT | global |
Every live refresh token on this account, with device and last-seen |
DELETE |
/auth/sessions/{sessionId} |
JWT | global |
Sign one device out |
POST |
/auth/sessions/revoke-others |
JWT | global |
Sign every other device out |
GET |
/auth/login-history |
JWT | global |
This account’s sign-in events |
POST |
/auth/business-profile |
JWT | auth |
Record the § 14 BGB trader declaration |
GET |
/auth/account/export |
JWT | global |
GDPR Art. 15 data export |
POST |
/auth/account/delete |
JWT | global |
GDPR Art. 17 account deletion |
Export and deletion are data-subject rights, so they stay reachable even while a business-profile gate is refusing everything else.
Sync endpoints
Section titled “Sync endpoints”Every route here requires the Sync licence feature and draws on the sync budget.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/sync/pull?since={ISO8601} |
JWT | Incremental pull. since is required — an omitted cursor is a 400, not a full dump |
POST |
/api/sync/push |
JWT | Push client changes |
GET |
/api/sync/status |
JWT | Sync status |
GET |
/api/sync/events |
JWT | This account’s sync event log |
GET |
/api/sync/quota |
JWT | Usage against the group’s quotas |
GET |
/api/sync/debug-state |
JWT | Diagnostic dump of everything synced for the calling account |
DELETE |
/api/sync/reset |
JWT | Destructive. Hard-deletes all of this account’s synced data and cursors |
Push request (SyncPushRequest):
{ "templates": [...], "providers": [...], "sessions": [...], "memories": [...], "todos": [...], "settings": { ... }}Push response (SyncPushResponse): server-assigned IDs, conflict list, resolution instructions. Conflicts use SyncConflict with strategy last-write-wins (default) or server-wins.
The pull response also carries managedPersonas — a SyncManagedPersonaSnapshot of { personas, recentlyRemoved }, not the { upserted, deleted } shape every other channel uses. It is replace-all and pull-only; a managed persona id sent to /api/sync/push is quarantined rather than applied. See the channel’s semantics.
Alongside it, clientPolicy is a SyncClientPolicySnapshot of { document, updatedAt } carrying the caller’s group client policy as a raw JSON string. Also replace-all and pull-only, with {} meaning “this group has no policy” and an absent key meaning “keep what you cached”. See the channel’s semantics.
Once an account is end-to-end encrypted the server refuses a plaintext push with 403 e2ee_required, and the flag is one-way. DELETE /api/sync/reset is the only way back — it wipes the synced data and the account’s E2EE state and key material, so treat it as an account reset rather than a sync repair.
See the Sync protocol page for the full request lifecycle.
Assistant chat endpoints
Section titled “Assistant chat endpoints”Conversations travel out of band rather than through /api/sync/*, so a long chat history never rides on a delta pull. Sync feature, sync budget.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/v1/chats |
JWT | List the caller’s conversations |
GET |
/api/v1/chats/{id} |
JWT | One conversation with its messages |
PUT |
/api/v1/chats/{id} |
JWT | Create or replace one — the client mints the id |
DELETE |
/api/v1/chats/{id} |
JWT | Delete one |
PUT is refused with 403 e2ee_required on an end-to-end-encrypted account unless the payload is ciphertext. Probe GET /api/capabilities for chatsSchemaVersion before writing.
AI proxy endpoints
Section titled “AI proxy endpoints”Every route here requires the AiProxy licence feature and a bearer token — none of them is anonymous.
| Method | Path | Auth | Rate | Purpose |
|---|---|---|---|---|
GET |
/api/ai/status |
JWT | global |
Proxy availability and the configured modes |
GET |
/api/ai/templates |
JWT | global |
Built-in template list |
POST |
/api/ai/optimize |
JWT | ai |
Text optimization |
POST |
/api/ai/chat |
JWT | ai |
A conversation turn, with knowledge bases and tools resolved server-side |
POST |
/api/ai/chat/completions |
JWT | ai |
The OpenAI-compatible name for the same endpoint |
POST |
/api/ai/generate-prompt |
JWT | ai |
Draft a prompt from a short description |
/api/ai/chat and /api/ai/chat/completions are two routes onto one handler — same body, same behaviour. Point an OpenAI-compatible client at the second and it works; /api/ai/chat is kept for clients that predate it.
Optimize request:
{ "text": "...", "templateId": "...", "providerId": "..." }text is capped at 10,000 characters. Input is sanitized server-side before forwarding to the upstream provider. The response either streams or returns { "result": "..." }.
The X-Pia-Mode header (values: optimize, assistant, research) selects which configured upstream provider to use. Per-mode overrides are documented under Configuration.
X-Pia-Persona carries the id of the managed persona driving the turn, and is omitted when none is selected. It mirrors X-Pia-Mode — client-selected, server-revalidated: the server confirms the persona is assigned to the caller’s group before honouring any knowledge base or connector bound to it, so a crafted header cannot reach another group’s resources. An unparseable or unassigned id simply resolves to the group-only resource set.
Knowledge ingestion endpoints
Section titled “Knowledge ingestion endpoints”Feed a server-side knowledge base from your own pipeline. This is the one
route family that does not use a user token: it authenticates with the X-Pia-Service-Key header, checked
in constant time against Knowledge:IngestApiKey. Leave that setting empty and the scheme is disabled, which
makes these routes unreachable rather than open. Knowledge licence feature, global budget.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/kb/{kbId}/documents |
List what has been ingested |
GET |
/api/kb/{kbId}/documents/{id} |
One document’s status — ingestion is asynchronous, so poll this |
POST |
/api/kb/{kbId}/documents |
Ingest or replace a document |
DELETE |
/api/kb/{kbId}/documents/{id} |
Remove one, and its chunks with it |
Client support endpoints
Section titled “Client support endpoints”The small routes a client needs around the main surfaces.
| Method | Path | Auth | Rate | Feature | Purpose |
|---|---|---|---|---|---|
GET |
/health |
— | global |
— | { "status": "healthy" }, no I/O. Answers even on an unlicensed server |
GET |
/api/capabilities |
— | global |
— | { "chats": true, "chatsSchemaVersion": n } — probe before writing chats |
GET |
/api/e2ee/status |
JWT | global |
— | Whether this account is end-to-end encrypted |
GET |
/api/certificates/trusted |
JWT | global |
— | Public code-signing certificates for plugin verification |
GET |
/api/plugins/{pluginId}/cab |
JWT | global |
— | Download a plugin package |
GET |
/api/plugins/{pluginId}/icon |
JWT | global |
— | Its icon |
POST |
/api/ai-feedback |
JWT | feedback |
— | File an AI Act Art. 50 complaint about an answer |
/api/ai-feedback has its own hour-long budget rather than the usual per-minute one — what is bounded is how
much free text one account can file, because each accepted report can send mail. A report is always stored
and readable under Admin → AI Feedback; whether a mail goes out as well depends on the configured
recipient and the SMTP relay.
Assignment endpoints
Section titled “Assignment endpoints”The Pia Mesh task plane. All six require the AiProxy licence feature and use the sync rate policy.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/assignments |
JWT | Enqueue an assignment |
GET |
/api/assignments?skip=&limit= |
JWT | List the caller’s assignments |
GET |
/api/assignments/{id} |
JWT | One assignment, with its event log and artifact |
POST |
/api/assignments/{id}/cancel |
JWT | Co-operative cancel |
GET |
/api/assignments/skills |
JWT | The skills this caller may enqueue, and what each accepts |
POST |
/api/assignments/{id}/collect |
JWT | “I have stored this locally” — drops the server’s plaintext copy |
Create request:
{ "skillName": "research", "inputJson": "{\"schemaVersion\":1,\"prompt\":\"what did we decide about storage?\",\"items\":[{\"entityType\":\"memory\",\"entityId\":\"…\",\"title\":\"Storage decision\",\"text\":\"we chose Postgres\",\"updatedAt\":\"…\"}]}"}inputJson is a string whose content is a versioned input envelope. It is required to be one — an opaque JSON object is refused, because the envelope is what lets the server see what a client decrypted and refuse anything the skill never declared.
| Field | Rule |
|---|---|
schemaVersion |
Must be 1. Missing or unknown is a 400, never a best-effort parse |
prompt |
Required, at most 4 000 characters |
items |
At most 20, each text at most 8 000 characters, at most 32 000 characters in total |
items[].entityType |
One of assistantChat, session, memory, todo, template, matched exactly — and it must also be one the target skill declares |
items[].entityId |
Round-tripped for the client’s own use; no server-side check reads it |
items[].title, items[].updatedAt |
Optional. Both are shown to the model as part of the item’s heading |
skillName is matched case-insensitively against the skills the caller’s groups have been granted — one per operator catalog row, selected on that row, never derived from its name. Two skills ship: research, which answers in one step, and brief, which takes three. There is no mode field: the skill declares its own chat mode, and a caller cannot override it.
Create responses:
| Status | Meaning |
|---|---|
202 |
Accepted — body is { "id": "<guid>" }, Location points at the assignment |
400 |
See the error codes below, or the skill is not granted to this caller |
503 |
The operator runtime is disabled or unavailable, or the workflow could not be started |
A 400 body is { "error": "<code>", "message": "…" }. Nothing is written when one is returned — that is the point of refusing at the boundary rather than after storing the payload.
error |
Cause |
|---|---|
invalid_envelope |
Not a readable envelope, or prompt missing |
unsupported_schema_version |
schemaVersion missing or not 1 |
envelope_too_large |
An item, the item total, or the prompt is over its cap |
unknown_entity_type |
An entityType outside the vocabulary above — including a wrong-cased one, which is refused rather than corrected |
undeclared_entity_type |
A real entity type this particular skill does not accept |
Skills answers with what the scoping UI should offer:
[{ "name": "research", "displayName": "research", "mode": "Research", "declaredInputTypes": ["assistantChat", "session", "memory"] }]The list is resolved through the caller’s own group grants, so it is per-user rather than a server-wide catalogue, and an empty array simply means nothing is granted. An empty declaredInputTypes is a real declaration too — that skill takes a prompt and nothing else, which is true of every pod-served skill — so a client must not read it as “offer everything”.
Assignment response:
{ "id": "…", "skillName": "research", "mode": "Research", "status": "Running", "stepCount": 1, "tokensSpent": 18776, "tokensAbandoned": 0, "createdAt": "…", "updatedAt": "…", "startedAt": "…", "completedAt": null, "artifactJson": "{\"choices\":[…]}", "artifactText": "the finished answer", "errorCode": null, "errorMessage": null, "plaintextDroppedAt": null, "events": [ { "id": "…", "kind": "started", "message": "…", "createdAt": "…" }, { "id": "…", "kind": "artifact", "message": "Step 0: research answer produced.", "detailJson": "{\"stepIndex\":0,\"tokens\":18776,\"toolCount\":1,\"tools\":[\"search_knowledge_base\"]}", "createdAt": "…" } ]}status is one of Queued, Running, Completed, Failed, Cancelled. Event kind is one of queued, started, step, continued, tool, artifact, deauthorized, completed, failed, cancelled, terminated, redacted. Treat an unknown kind as informational: kinds are added over time, and a client that switches exhaustively breaks on the next one.
Three fields are present only on the single-assignment route: events, artifactJson/artifactText, and plaintextDroppedAt. The list route omits them all rather than returning empty ones — an empty events array would read as “no progress”, and carrying artifacts on a page of up to 200 rows would return every artifact the caller owns as a side effect of polling. Fetching an artifact is a deliberate single-row read.
artifactText is the artifact’s assistant text, extracted server-side; prefer it for anything user-facing, and expect it to be absent when the raw response carries no text (a final response that ended on a tool call, for example). plaintextDroppedAt is non-null once the server’s copy is gone — see Collect below.
A multi-step skill produces one step per unfinished pass and one continued between each pair of them, then a single artifact for the pass that finished. A one-step skill produces only the artifact.
Two message fields are bounded and truncated rather than rejected, because both can carry text the server did not author — a pass summary is model output, and a failure message can list every tool a pod advertised. An event message is cut at 2000 characters and errorMessage at 1024; a truncated value ends in … [truncated], so a client can tell.
message is prose meant for a person. detailJson is the same event as data, and it is a JSON string, not a nested object — parse it. It is present on the step, artifact and deauthorized kinds and absent everywhere else, so treat it as optional rather than as a schema:
| Kind | Fields |
|---|---|
step, artifact |
stepIndex, tokens (that step’s delta), toolCount, tools |
deauthorized |
stepIndex, lostTools, toolCount, tools |
tools is truncated at 50 names; toolCount is always the real total, so compare the two before reading the list as complete.
tokensAbandoned is spend the assignment incurred after it had already ended — a step that was still in flight when the wall-clock deadline fired or the run was cancelled. That work is billed upstream, but its result is refused, so it is counted separately instead of being folded into tokensSpent (where it would contradict stepCount and the artifact beside it). The real cost of an assignment is tokensSpent + tokensAbandoned. It is 0 on every assignment that ended cleanly.
Progress is polled, not pushed. Another user’s assignment returns 404, never 403, on read, list and cancel. limit defaults to 50 and is clamped to 200.
Cancel returns 204 on success, or 404 for a missing assignment, another user’s assignment, or one with no live workflow behind it — the server does not distinguish those cases.
Collect is the client’s acknowledgement that it has its own copy. It clears the stored input, the artifact and every event message the server did not author itself, stamps plaintextDroppedAt, and appends a redacted event so the drop is visible on the row rather than inferred from fields having gone empty.
| Status | Meaning |
|---|---|
204 |
Dropped — and also the answer to every repeat call, so a retry after a lost response is safe |
409 |
The run has not finished. Its input is what it is still running on, so nothing is dropped |
404 |
Unknown assignment, or another user’s — indistinguishable, as everywhere else on this surface |
Admin endpoints
Section titled “Admin endpoints”The three admin families below are documented because a script may reasonably drive them. The rest of
/api/admin/** — users, groups, audit, anomalies, licence events, AI feedback, system — is the
admin console’s own backing API, not a contract: it is console-only on purpose and
is left undescribed here and in the generated document alike.
Assignments
Section titled “Assignments”Behind AdminPolicy.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/assignments?skip=&limit= |
Cross-user roll-up |
POST |
/api/admin/assignments/{id}/terminate |
Force a stuck assignment terminal |
The roll-up carries no artifactJson and no errorMessage — both are free-text content and the list spans users. errorCode is included, and so are both token counters.
Terminate takes an optional { "reason": "..." } (truncated at 1024 characters) and always answers 200 with { "changed": true|false }. false means the row does not exist or was already terminal — a deliberate no-op, not an error.
Client policies
Section titled “Client policies”Behind AdminPolicy. See Client policies.
The document lives in a catalog; a group holds only a reference to one. Those are two separate surfaces.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/client-policies |
List projection — { id, name, description, documentBytes, updatedAt, groupCount } |
GET |
/api/admin/client-policies/{id} |
The full ClientPolicy row |
GET |
/api/admin/client-policies/{id}/groups |
Live groups publishing it — [{ id, name }] |
POST |
/api/admin/client-policies |
Create — body { name, description?, document? }, answers 201 { id } |
PUT |
/api/admin/client-policies/{id} |
Replace name, description and document |
DELETE |
/api/admin/client-policies/{id} |
Soft delete; refused while assigned |
GET |
/api/admin/groups/{id}/client-policy |
The group’s assignment, resolved — { policyId?, name?, document, updatedAt? } |
PUT |
/api/admin/groups/{id}/client-policy |
Assign — body { "policyId": "…" }, or null to unassign |
The gate matches the rest of the admin API: reads need only AdminPolicy, writes additionally require the GroupManagement licence feature.
The server validates the document’s shape only — one JSON object, no sections beyond defaults and enforce (lower case), both objects, under 64 KB — and refuses the keys a client needs to reach the server (serverUrl, syncEnabled, trustSelfSignedCertificates) along with its sync cursors, credentials and migration markers. A failure answers 400 { "error": "validation_failed", "message": "…" }. Setting names are not validated: the desktop client owns that schema.
Other refusals: a duplicate name is 409 { "error": "name_taken" }; deleting an assigned policy is 409 { "error": "client_policy_in_use", "groups": ["…"] }; assigning an unknown or deleted policy is 400 { "error": "client_policy_not_found" }.
A blank document stores {} — the policy stays, and pins nothing. GET on a group whose policy has been deleted reads as unassigned: no policyId, document of {} — the same answer the pull gives.
Every accepted write bumps the pull’s catalog token, so members see the change on their next sync. Catalog writes are audited as ClientPolicy.Created / Updated / Deleted; a group’s assignment as Group.ClientPolicyUpdated.
Managed personas
Section titled “Managed personas”Behind AdminPolicy. See Managed personas.
| Method | Path | Purpose |
|---|---|---|
GET |
/api/admin/managed-personas |
List — a summary projection with group and plugin counts |
GET |
/api/admin/managed-personas/{id} |
One persona, in full |
GET |
/api/admin/managed-personas/{id}/groups |
Assigned group ids |
GET |
/api/admin/managed-personas/{id}/plugins |
Bound plugin ids |
POST |
/api/admin/managed-personas |
Create — 201 with { id } |
PUT |
/api/admin/managed-personas/{id} |
Update |
DELETE |
/api/admin/managed-personas/{id} |
Soft delete (tombstone; joins are left in place) |
PUT |
/api/admin/managed-personas/{id}/groups |
Replace the group assignment |
PUT |
/api/admin/managed-personas/{id}/plugins |
Replace the plugin bindings |
The gate is per-action: reads need only AdminPolicy, while every write additionally requires the GroupManagement licence feature.
Both PUT collection routes are replace-all — send the complete id list, not a delta. An unknown group id is rejected, as is a plugin id that no longer exists. Quota refusals answer 409: Quotas.ManagedPersonas caps assignments per group, and Quotas.KnowledgeBases still charges for a KB reached through a persona.
Update is full-replace for content fields — omit one and it is cleared — with one exception: an omitted isActive leaves the stored value alone, so a partial write cannot silently republish a persona that was deliberately disabled.
Pod uplink
Section titled “Pod uplink”/hubs/pod-uplink is a SignalR hub, not a REST endpoint, and it is the only inbound route on the tool plane. It authenticates against its own PodUplink scheme — a user or admin credential is rejected with 401. There are no REST endpoints for pods. Connection attempts are rate-limited per IP under the pod-uplink policy, covering both the negotiate request and the upgrade. See Pod uplink and connector credentials.
E2EE device endpoints
Section titled “E2EE device endpoints”E2EE licence feature, global budget, except .../status which needs no feature.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/e2ee/devices/register |
JWT | Register device as Pending |
GET |
/api/e2ee/devices |
JWT | List the user’s devices |
GET |
/api/e2ee/devices/{deviceId}/status |
JWT | Poll one device’s status while it waits for approval |
POST |
/api/e2ee/devices/approve |
JWT | Approve a pending device (wrap the UMK for it) |
POST |
/api/e2ee/devices/{deviceId}/revoke |
JWT | Revoke a device |
GET |
/api/e2ee/devices/{deviceId}/wrapped-umk |
JWT | Fetch the UMK wrapped for that device |
POST |
/api/e2ee/devices/{deviceId}/wrapped-umk |
JWT | Store a UMK wrapped for that device |
E2EE recovery endpoints
Section titled “E2EE recovery endpoints”| Method | Path | Auth | Rate | Purpose |
|---|---|---|---|---|
POST |
/api/e2ee/recovery/wrapped-umk |
JWT | global |
Store recovery-wrapped UMK |
GET |
/api/e2ee/recovery/wrapped-umk |
JWT | global |
Retrieve recovery-wrapped UMK |
POST |
/api/e2ee/recovery/activate |
JWT | global |
Activate device via recovery code |
All three require the E2EE licence feature.
Shared DTOs
Section titled “Shared DTOs”Located in Pia.Shared:
- Sync —
SyncPullResponse,SyncPushRequest,SyncPushResponse,SyncConflict,SyncTemplate,SyncProvider,SyncSession,SyncMemory,SyncTodo,SyncSettings,SyncManagedPersonaSnapshot,SyncClientPolicySnapshot. - Policy —
ClientPolicyContract: the client-policy document’s shape rules, so the server’s admin write path and the desktop client refuse the same input. - E2EE —
DeviceStatus(enum),DeviceInfo,DeviceRegistrationRequest/Response,DeviceApprovalRequest,WrappedUmkBlob,RecoveryWrappedUmkBlob,RecoveryActivationRequest. - Models —
BuiltInTemplate,AiFeedbackRequest(the body ofPOST /api/ai-feedback).
Serialization uses System.Text.Json with default options.