Developer documentation

Context Assistant documentation

Set up with Claude, install the WordPress plugin, or integrate through SDKs and APIs.

Setup with ClaudeWordPress quickstartSDK guidesRemote Tools

Connect with Claude

Choose a Claude Code task: connect the widget, populate the knowledge base or plan ongoing updates.

Task for Claude

Choose a task and paste the prompt into Claude Code in your website project. It includes API contracts and verification steps.

  1. Copy the prompt
  2. Paste into Claude Code
  3. Review the result

Connect chat with one script and a public key. Preserve existing private integrations.

The cabinet fills in your Assistant ID and site origin. Set the secret key only on your server. Requires access to Claude Code.

Before starting, replace YOUR_ASSISTANT_ID and https://your-site.example, or copy a filled-in prompt from the cabinet. Run claude in your project directory and paste the prompt. Getting started with Claude Code.

For integration, test a real chat reply. For knowledge upload and updates, Claude first proposes a plan. Reply Start work to approve it and begin implementation. Enabling scheduled synchronization requires separate approval after verification. Using Claude does not change the model your assistant uses.

API contracts (Markdown) · Complete text reference for Claude · Documentation index for AI tools. Claude can find the API reference and complete integration examples here.

WordPress · About 10 minutes

Connect WordPress

Install the ready-made plugin, connect your account, and test the assistant. No code is required.

01
Administrator accessPermission to install WordPress plugins
02
Context Assistant accountRegistration and a verified email
03
HTTPS site addressThe exact public URL without a page path
  1. Install the plugin

    In WordPress admin, open Plugins → Add Plugin, search for Context Assistant, then install and activate it. Its directory page: wordpress.org/plugins/context-assistant.

  2. Create your account

    Create a Context Assistant account and verify your email.

  3. Add your site

    In Context Assistant onboarding, name your assistant, choose WordPress, and enter the exact site URL, for example https://shop.example.com.

  4. Save the connection details

    Save the Assistant ID, public ca_pk_… key and secret synchronization ca_sk_… key from setup. The secret is shown once. The plugin pre-fills API URL.

  5. Connect the plugin

    Return to the WordPress wizard, paste both keys and Assistant ID, and select Save and check connection.

  6. Configure the widget

    Choose the title, pages where it appears, and guest access, then select Save and finish.

  7. Test the result

    Open your site and ask a test question. Knowledge sync, WordPress/WooCommerce actions, and lead capture can be enabled later under Advanced settings.

Your connection is ready

The widget opens on the selected pages and answers your test question.

Open account
Other platforms

Choose an integration

Ask Claude to connect it, add the ready-made widget, or build your own interface with the API.

Script embed

In assistant setup, allow your website origins and get both keys. Replace YOUR_PUBLIC_KEY with the public ca_pk_… key, then add this script once to the shared page template.

<script async src="https://contextassistant.io/assets/embed.js"
  data-public-key="YOUR_PUBLIC_KEY"
  referrerpolicy="no-referrer"></script>

The script opens the connection and mounts the widget automatically. Do not add a second mount(), a local SDK copy or a customer token endpoint. CA creates and renews guest sessions. Keep the synchronization secret out of page code.

Choose Lite/Advanced and greeting settings in the cabinet. Compatible widget updates arrive from the same URL when a page loads. Complete HTML example with page facts.

Use window.ContextAssistantWidget or event.detail.widget from context-assistant:ready to call open(), close(), setContext(), clearContext() or destroy(). Only send approved page facts, never full DOM or private data.

Optional: separately supplied embed SDK

This package is not on public npm and is not needed for the script above. If you have package access, the manual mount equivalent is:

import { mount } from '@context-assistant/embed-sdk';

const widget = mount({
  apiUrl: 'https://api.contextassistant.io',
  publicKey: 'YOUR_PUBLIC_KEY',
  title: 'Store Consultant',
  appearance: {
    variant: 'refined',
    theme: 'system',
    welcomeMessage: 'How can I help with this product?',
    starterPrompts: ['Is it in stock?', 'Compare the available options'],
    privacyUrl: '/privacy/',
  },
});

widget.setContext({
  page: { type: 'product', id: 184 },
});

Widget for React

Use the same hosted widget. For a chat available across the app, add the script above once in the shared layout. You do not need to install Context Assistant packages or add a server token route.

If the widget must mount and unmount with a component, copy react-widget.tsx into your project and pass the public key explicitly:

<ContextAssistantWidget
  publicKey="YOUR_PUBLIC_KEY"
  page={{ type: "product", id: 184 }}
/>

Choose either the automatic script or this component, never both. The component loads the same hosted file, mounts in the browser and calls destroy() on unmount. Update page when the displayed item changes. Omitting publicKey in this compatibility example selects the private server-token flow; do not omit it for public chat.

Optional: separately supplied React SDK

@context-assistant/react is not on public npm. Use this option only with separately supplied package access. The provider accepts a public key; useAssistant() supports a custom interface. Package versions are updated in your project, not by the hosted script.

import { ContextAssistantProvider, Assistant } from '@context-assistant/react';


export function App() {
  return (
    <ContextAssistantProvider apiUrl="https://api.contextassistant.io" publicKey="YOUR_PUBLIC_KEY">
      <Assistant
        title="Store Consultant"
        appearance={{
          variant: 'refined',
          theme: 'system',
          welcomeMessage: 'How can I help with this product?',
          starterPrompts: ['Is it in stock?', 'Compare the available options'],
          privacyUrl: '/privacy/',
        }}
      />
    </ContextAssistantProvider>
  );
}

JavaScript API & SDK

For the ready-made chat, use the script above. Use the API only when building your own interface: exchange the public key at POST /v1/embed/public-token, then use the temporary Bearer token with the Conversations API. No customer token endpoint is needed for public chat.

With a custom API client, handle expiry, preserve visitorToken for renewal, render SSE replies and respect Retry-After. The hosted widget handles this for you. The public key cannot upload knowledge; content sync uses the separate secret on your server.

Optional: separately supplied JavaScript SDK

The headless @context-assistant/js-sdk is not on public npm. If it has been supplied to your project, it also accepts publicKey:

import { ConversationSession } from '@context-assistant/js-sdk';

const session = new ConversationSession({
  apiUrl: 'https://api.contextassistant.io',
  publicKey: 'YOUR_PUBLIC_KEY',
});

await session.send('What pairs well with this processor?', {
  onToken: (delta) => console.log(delta),
  onDone: (event) => console.log('\n[done]', event.message),
});

Personal orders, private documents and Remote Tools require server-authenticated integration. Keep existing private connections unchanged; do not replace their keys with a public one.

Public chat session

POST /v1/embed/public-token. No Authorization header. Browser sends its Origin header; the server never trusts a body origin. Requires an active ca_pk_ key explicitly enabled in assistant setup and an exact allowed origin. An empty allowlist denies access.

{
  "publicKey": "YOUR_PUBLIC_KEY",
  "visitorToken": "OPTIONAL_PREVIOUS_VISITOR_TOKEN"
}

200, Cache-Control: no-store:

{
  "token": "SHORT_LIVED_ACCESS_JWT",
  "expiresAt": "2026-09-14T12:15:00Z",
  "visitorToken": "SIGNED_GUEST_RENEWAL_CREDENTIAL"
}

Omit visitorToken on first use; send the returned value to renew the same guest. It expires after 30 days without renewal and cannot be used as a chat bearer token. Use token as Bearer for chat/config/context requests, with the same Origin. The SDK handles caching, single-flight renewal and guest persistence. Never send an external user ID: the server generates it. Public access has no management or Remote Tools rights. 400 invalid_input; 401 visitor_expired; 403 origin_not_allowed/public_access_denied; 429 rate_limited; 503 not_configured. No endless retries. Revocation and origin changes take effect on subsequent requests.

Wizard-issued sync keys grant knowledge:read, knowledge:write and assistants:read in their workspace, not embed:token. Store CONTEXT_ASSISTANT_SYNC_KEY only in server .env; ignore .env in git. Public ca_pk_ keys never authorize knowledge uploads. Existing secret keys and POST /v1/embed/token are unchanged.

HTTP API: authentication and conventions

All contracts in one file (Markdown)

REST base URL: https://api.contextassistant.io/v1. Send JSON with Content-Type: application/json. Examples use placeholder identities: replace them with your own. Widget options use the origin without /v1; the SDK adds the prefix.

CredentialWhere it is used
Authorization: Bearer <server API key>Server-to-server calls. Keep the key in server secrets, never in a browser or AI chat. Access is scoped to the key's workspace.
Authorization: Bearer <embed token>Browser chat, conversations and context validation. The public SDK obtains it directly from CA; private integrations use their own backend.
Server-key scopeAllowed operations
embed:tokenIssue browser tokens
assistants:readRead assistant settings and resolve a Public ID to an internal UUID
knowledge:readList sources, documents and trash
knowledge:writeCreate sources, upload/update documents, delete and restore. Use a separate import key.

API-key scopes are not interchangeable. A key with only embed:token cannot upload documents. /v1/cabinet/* uses the account session and is not the integration API. /api/core and /api/embed-token belong to our own website.

curl --fail-with-body "https://api.contextassistant.io/v1/assistants" \
  -H "Authorization: Bearer ${CONTEXT_ASSISTANT_SYNC_KEY}"

The command reads a key already set in the server environment. Do not paste its value into a prompt or save it in source code. Management writes take effect immediately: preview/approval belongs in your importer or UI, not an imaginary API dry-run flag.

Find the assistant and its identifiers

GET /v1/assistants -> 200 { "items": [...] }. GET /v1/assistants/{publicId} -> 200 with one assistant object. Both require assistants:read; the path uses Public ID, not UUID.

{
  "items": [
    {
      "id": "11111111-1111-4111-8111-111111111111",
      "workspaceId": "44444444-4444-4444-8444-444444444444",
      "publicId": "asst_example",
      "name": "Store assistant",
      "profile": "general",
      "instructions": "",
      "modelConfig": {},
      "toolPolicy": {},
      "allowedOrigins": [
        "https://shop.example"
      ],
      "createdAt": "2026-09-10T12:00:00Z",
      "updatedAt": "2026-09-10T12:00:00Z"
    }
  ]
}
IdentifierUse
publicId / asst_...Token request assistantId; exact match when selecting an assistant
id / UUIDassistantId when creating a knowledge source or registering a backend integration
sourceId / UUIDGroups documents imported for a particular source
externalId / stringYour stable document ID inside that source
agentOptional behavior profile, not any of the IDs above. Omit it in a basic integration.

POST /v1/embed/token

Server key with embed:token. JSON body up to 4 KiB. Required assistantId is the Public ID. Set origin to the configured scheme, host and port from the assistant allowlist. Derive user.externalId from a verified server session or a signed per-guest cookie; user.name is optional.

{
  "assistantId": "asst_example",
  "origin": "https://shop.example",
  "user": {
    "externalId": "member_42",
    "name": "Alex"
  }
}

200 response:

{
  "token": "<temporary JWT>",
  "expiresAt": "2026-09-10T12:15:00Z"
}

Use expiresAt to schedule renewal. Return only these two fields to the browser with Cache-Control: no-store. The token selects the assistant and visitor. An allowed origin is not proof of user login; a public token endpoint still needs abuse protection. Keep guests separate.

Conversations and streaming replies

Every route below requires an embed token. Start with POST /v1/conversations, body { "title": "Delivery question" } (title is optional). Response 201:

{
  "id": "55555555-5555-4555-8555-555555555555",
  "assistantId": "asst_example",
  "title": "Delivery question",
  "createdAt": "2026-09-10T12:00:00Z",
  "updatedAt": "2026-09-10T12:00:00Z"
}

Send POST /v1/conversations/{id}/messages. Required content is a non-empty string. Optional context contains small structured page facts; no full DOM or personal data. The JSON body limit is 64 KiB. Omit agent; an assistant Public ID here causes unknown agent profile.

{
  "content": "How long does delivery take?",
  "context": {
    "page": {
      "type": "product",
      "id": 184
    }
  }
}
curl -N --fail-with-body "https://api.contextassistant.io/v1/conversations/${CONVERSATION_ID}/messages" \
  -H "Authorization: Bearer ${EMBED_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary @message.json

Save the JSON above as UTF-8 message.json. A successful response is HTTP 200 text/event-stream, not one JSON document. Parse SSE frames across arbitrary network chunks. Example:

event: token
data: {"delta":"Delivery takes 2-4 business days."}

event: done
data: {"conversationId":"55555555-5555-4555-8555-555555555555","userMessageId":"user-message-uuid","messageId":"reply-message-uuid","message":"Delivery takes 2-4 business days.","model":"model-id","agent":"general","title":"Delivery question"}

Events: token carries delta; done carries the final message; pending_action carries a proposed action; error carries code/error and terminates a failed stream. done may also contain suggestions and references. HTTP 200 alone is not a completed reply. A retry may create a second message: inspect history after an interrupted send.

Method and pathRequest / response
GET /v1/conversationslimit/cursor; 200 items of id, title, createdAt, updatedAt and optional nextCursor
GET /v1/conversations/{id}200 one conversation object
GET /v1/conversations/{id}/messageslimit/cursor; 200 items of id, conversationId, role, content, createdAt; optional toolCalls/usage and nextCursor
POST /v1/conversations/{id}/confirmcallId, tool, arguments and confirmed from the actual pending action; optional context. 200 SSE. Preview and user approval first; deletion needs a second confirmation.
DELETE /v1/conversations/{id}204 without a body. Permanently erases the conversation and messages; no restore endpoint.

For cursor lists, limit defaults to 20 and is capped at 100; a non-positive/invalid limit or invalid cursor gives 400. Follow nextCursor with URL encoding until omitted. Messages are listed oldest first; conversation lists are ordered by recent activity. Ask for confirmation before deleting a conversation.

POST /v1/context/validate

Embed token. Optional preflight for page facts; chat validates them again. JSON body up to 16 KiB, sanitized context up to 4096 bytes, maximum nesting depth 6. Allowed top-level fields: application, workspaceId, page, selection, entity, site. Other fields are dropped and secret-looking strings are redacted. These facts do not grant access or change the authenticated workspace.

{
  "context": {
    "page": {
      "type": "product",
      "id": 184
    }
  }
}

200 response, with optional droppedFields and redactedPaths arrays when sanitization changed anything:

{
  "context": {
    "page": {
      "type": "product",
      "id": 184
    }
  }
}

400 codes: invalid_input for an invalid JSON body; context_not_an_object, context_too_large, context_too_deep or invalid_context for invalid context. This endpoint does not add material to the knowledge base.

Knowledge API: create a source

POST /v1/knowledge/sources, server key with knowledge:write. Required: assistantId (internal UUID) and non-empty name. type is optional, defaults to manual and is only a label; it does not start a crawler or scheduler. Body limit: 256 KiB.

{
  "assistantId": "11111111-1111-4111-8111-111111111111",
  "name": "Store help",
  "type": "manual"
}

201 response. Save id as sourceId and retain its assistant association in your own settings:

{
  "id": "22222222-2222-4222-8222-222222222222",
  "name": "Store help",
  "type": "manual",
  "createdAt": "2026-09-10T12:00:00Z"
}

GET /v1/knowledge/sources requires knowledge:read and returns 200 { "items": [...] } for the workspace, without pagination. Source objects do not expose assistantId. Do not infer ownership by name or create a new source on every import.

POST /v1/knowledge/documents

Server key with knowledge:write. Create or update one document:

{
  "sourceId": "22222222-2222-4222-8222-222222222222",
  "externalId": "help:delivery:en",
  "title": "Delivery",
  "contentType": "markdown",
  "content": "# Delivery\nDelivery takes 2-4 business days."
}
FieldContract
sourceIdRequired, existing source UUID in this workspace
externalIdOptional string, up to 512 UTF-8 bytes. Always set it for imports and sync.
titleOptional string, trimmed
cardOptional presentation object: title (1–120 characters, defaults to document title), url (HTTP/S), image (HTTPS), subtitle (up to 100 characters), badges (up to 4 strings, 48 characters each). URLs up to 2048 UTF-8 bytes, without credentials. Omit image for a text card; omit url for a non-clickable card. Omit card to preserve existing presentation metadata on upsert; send card: null to explicitly remove it. A card object replaces all card fields.
contentTypeRequired: text, markdown, html or json (not a MIME type)
contentRequired non-empty string containing the actual material, even for JSON. Entire request JSON <= 256 KiB.
curl --fail-with-body "https://api.contextassistant.io/v1/knowledge/documents" \
  -H "Authorization: Bearer ${CONTEXT_ASSISTANT_SYNC_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @document.json

Save the example as UTF-8 document.json. New documents return 201:

{
  "id": "33333333-3333-4333-8333-333333333333",
  "sourceId": "22222222-2222-4222-8222-222222222222",
  "externalId": "help:delivery:en",
  "title": "Delivery",
  "contentType": "markdown",
  "status": "pending",
  "createdAt": "2026-09-10T12:00:00Z",
  "syncStatus": "created"
}

Identity is (sourceId, externalId). Changes return 200 updated with the same id; identical content and metadata return unchanged. Title/card-only changes update metadata without re-embedding ready documents. Content changes queue indexing. Without externalId every POST creates a new document. There is no PATCH document endpoint.

{
  "sourceId": "22222222-2222-4222-8222-222222222222",
  "externalId": "product:42:ru",
  "title": "Кресло Oslo",
  "contentType": "text",
  "content": "Кресло Oslo: светлый дуб, серая ткань. Цена 120 EUR.",
  "card": {
    "title": "Кресло Oslo",
    "url": "https://shop.example/products/42",
    "image": "https://shop.example/images/42.jpg",
    "subtitle": "120 EUR",
    "badges": [
      "Светлый дуб",
      "Серая ткань"
    ]
  }
}

Cards can be selected from knowledge results without Remote Tools. The server supplies stored fields through references in the chat response. Images are linked, not uploaded or analysed. Keep prices and availability current through synchronization; knowledge cards are not a live stock check. Cards currently appear in the live reply; reloading the conversation restores text, not cards.

syncStatus describes the write outcome, not indexing readiness. status is pending, processing, ready or failed. A URL in content is not fetched. Convert PDFs/images to reviewed text before upload. Upload only material the assistant's audience may access; a source is not a per-visitor access-control list.

POST /v1/knowledge/documents/bulk

The current API contract includes bulk upsert with knowledge:write: 1-100 documents, required externalId per item, 8 MiB maximum JSON body, and 256 KiB maximum content per item. Duplicate (sourceId, externalId) inside a batch is rejected. Check route availability in your environment; use the single-document route if bulk is unavailable.

{
  "documents": [
    {
      "sourceId": "22222222-2222-4222-8222-222222222222",
      "externalId": "help:delivery:en",
      "title": "Delivery",
      "contentType": "markdown",
      "content": "# Delivery\nDelivery takes 2-4 business days."
    }
  ]
}

200 response, one item per input in the same order:

{
  "items": [
    {
      "id": "33333333-3333-4333-8333-333333333333",
      "sourceId": "22222222-2222-4222-8222-222222222222",
      "externalId": "help:delivery:en",
      "title": "Delivery",
      "contentType": "markdown",
      "status": "pending",
      "createdAt": "2026-09-10T12:00:00Z",
      "syncStatus": "created"
    }
  ],
  "created": 1,
  "updated": 0,
  "unchanged": 0
}

All input and source references are checked before writes, but the batch is not one database transaction. A storage/queue failure may leave earlier items applied. Retry with the same stable identities; do not generate new externalId values. Indexing is still asynchronous.

Check indexing status and paginate

GET /v1/knowledge/documents?limit=100 requires knowledge:read. 200 items contain id, sourceId, optional externalId, title, optional card, contentType, status, optional error and createdAt. Raw content, checksum and syncStatus are not returned in this list.

{
  "items": [
    {
      "id": "33333333-3333-4333-8333-333333333333",
      "sourceId": "22222222-2222-4222-8222-222222222222",
      "externalId": "help:delivery:en",
      "title": "Delivery",
      "contentType": "markdown",
      "status": "ready",
      "createdAt": "2026-09-10T12:00:00Z"
    }
  ],
  "nextCursor": "<opaque cursor; omitted on last page>"
}

limit defaults to 20, caps at 100; invalid/non-positive values and invalid cursors return 400. Follow nextCursor, URL-encoded, until absent. Listings cover the workspace, not one assistant or source. Filter sourceId in your importer. There is no sourceId query filter or GET /documents/{id}; find the document by id in the paginated list.

Poll with pauses and a deadline. ready means the document is indexed; still test actual answers. On failed inspect error without logging private content. An unchanged repost of a failed document does not automatically retry indexing. Keep the original material locally and report the failure instead of an infinite retry loop.

Updates, trash and safe synchronization

Method and pathScope and response
DELETE /v1/knowledge/documents/{documentId}knowledge:write; 204, moves to trash and removes indexed chunks
GET /v1/knowledge/documents/trashknowledge:read; 200 {items: [...]}, latest 100, no cursor
POST /v1/knowledge/documents/{documentId}/restoreknowledge:write; no request body needed; 200 document with status pending; queues indexing again
DELETE /v1/knowledge/documents/{documentId}/purgeknowledge:write; 204; permanently deletes a document already in trash
DELETE /v1/knowledge/sources/{sourceId}knowledge:write; 204; irreversibly deletes the source and all its documents, including trash

Restore may return 409 conflict if an active document already owns the same (sourceId, externalId). Do not purge either version automatically. Deletion endpoints do not implement your approval workflow: show a preview and obtain separate double confirmation before a destructive operation. Never use source DELETE or purge as routine sync cleanup.

Plan sync around a persistent sourceId and stable externalId values. Compare deletion candidates only within that source, after a complete successful source export and all document pages. A source outage, empty partial export or missing list page must never trigger mass deletion. Use dry-run by default, retain original content and a run log, and stop at an unusual deletion count. Scheduling, webhooks and source reading run in the customer project; this API does not create a crawler.

Errors and request limits

{
  "code": "forbidden",
  "error": "the API key lacks the required scope"
}
Status / codeMeaning and action
400 invalid_inputInvalid fields, format, cursor or body size; correct the request rather than retrying it unchanged
400 invalid_contextInvalid structured page facts
401 unauthorizedMissing/revoked key or invalid/expired embed token
403 forbiddenKey lacks the required scope
403 origin_not_allowedDeclared origin is not in the assistant allowlist
404 not_found, assistant_not_found, source_not_found, document_not_found, conversation_not_foundObject absent or outside the authorized scope. Assistant management uses not_found; token issuance uses assistant_not_found.
409 conflictA restored document conflicts with an active identity
429Rate or quota limit. Honor Retry-After when present; use bounded backoff.
5xxService/storage/provider failure. A document write may already have happened: retry uploads with stable externalId. Do not blindly resend chat messages.

Body limits: token 4 KiB; chat 64 KiB; one knowledge request 256 KiB; bulk 8 MiB plus 256 KiB content per item. Reverse proxies can impose smaller limits and return non-JSON errors such as 413. Check HTTP status and content type before parsing. Browser requests use fetch (with Authorization), not EventSource, for POST-based SSE.

Remote Tools contract

Full HTTP contract · Connection registration, key scopes and identifiers.

Any backend can give the assistant typed actions and grounding by implementing four HTTP endpoints. Scope is passed as execution (workspaceId, externalUserId, permissions, requestId); callId identifies one specific tool call end-to-end.

Method & pathPurpose
GET {baseURL}/toolsList available tools for this execution scope
GET {baseURL}/contextGrounding context for the current user/session
POST {baseURL}/tools/previewHuman-readable preview — no side effects
POST {baseURL}/tools/executePerform the action, after confirmation

GET /tools response:

{
  "tools": [
    {
      "name": "create_task",
      "description": "Create a task in the current project",
      "verb": "create",
      "inputSchema": { "type": "object", "properties": { "title": { "type": "string" } }, "required": ["title"] }
    }
  ]
}

POST /tools/preview request:

{
  "tool": "create_task",
  "callId": "call_abc123",
  "arguments": { "title": "Ship the release" },
  "execution": {
    "workspaceId": "ws_123",
    "externalUserId": "user_42",
    "permissions": ["tasks:write"],
    "requestId": "req_xyz"
  }
}

POST /tools/preview response:

{ "preview": "Create a task “Ship the release” in Project Apollo" }

POST /tools/execute response (request is the same shape as preview):

{ "output": { "id": "task_789", "title": "Ship the release", "status": "open" } }
VerbConfirmation
getNone — auto-executed inside the turn
create, updatePreview, then explicit Apply
deletePreview, then a second, explicit confirmation

Live reference: the WordPress plugin implements this exact contract with 10 tools (6 WordPress + 4 WooCommerce), conformance-tested against a real host on every change.

Security model

The full model is on the homepage. In short: the public key only opens guest chat, while the sync secret stays server-side, every request is scoped to one tenant and workspace, the host re-checks real permissions before any action, and deletes require double confirmation with a recovery path.