Skip to content

API reference

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:

  • AuthJWT needs a bearer token, is anonymous, AdminPolicy is the admin console’s cookie, and X-Pia-Service-Key is the knowledge ingestion key.
  • Rate — which limiter budget the route draws on. auth and local-auth are much tighter than global; 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, GroupManagement or Knowledge. 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.

Your own server can serve everything on this page as OpenAPI 3.1. Set OpenApi__Enabled=true (Configuration → OpenApi), restart, and:

Terminal window
curl https://your-server/openapi/v1.json

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

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.

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:

  1. Keep a random code_verifier. Call /auth/login with code_challenge = base64url(SHA-256(verifier)), your loopback redirect_uri, and an opaque state nonce.
  2. The provider returns the user to /auth/callback, which redirects to {redirect_uri}?code=<one-time>&state=<yours>. The state is echoed verbatim so your listener can reject a callback fired by some other local process.
  3. Redeem the code with POST /auth/token plus your codeVerifier. 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.

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.

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

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.

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.

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.

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

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.

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

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.

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.

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.

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.

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

Located in Pia.Shared:

  • SyncSyncPullResponse, SyncPushRequest, SyncPushResponse, SyncConflict, SyncTemplate, SyncProvider, SyncSession, SyncMemory, SyncTodo, SyncSettings, SyncManagedPersonaSnapshot, SyncClientPolicySnapshot.
  • PolicyClientPolicyContract: the client-policy document’s shape rules, so the server’s admin write path and the desktop client refuse the same input.
  • E2EEDeviceStatus (enum), DeviceInfo, DeviceRegistrationRequest/Response, DeviceApprovalRequest, WrappedUmkBlob, RecoveryWrappedUmkBlob, RecoveryActivationRequest.
  • ModelsBuiltInTemplate, AiFeedbackRequest (the body of POST /api/ai-feedback).

Serialization uses System.Text.Json with default options.