AI assistant and services

Ops assistant (plan, diff, approve), docs search, model providers and a per-account OpenAI-compatible gateway.

In preview ai version 1.0.0 Part of AI assistant

Actions

80 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
ai.status AI status and setup state (read) low No dry run
ai.policy.get Get the AI policy (read) low No dry run
ai.policy.set Change the AI policy high
ai.killswitch.set Turn the assistant or its changes on or off high
ai.admin.overview AI adoption, spend, failures, plan approvals and safety events (30 days) (read) low No dry run
ai.consent.get Show what may be sent to each model provider (read) low No dry run
ai.sent.list List what was sent to model providers (read) low No dry run
ai.local.status What this server can run a local model on (read) low No dry run
ai.local.runtime.enable Install and start the local model runtime high
ai.local.runtime.start Start the local model runtime medium No dry run
ai.local.runtime.stop Stop the local model runtime medium No dry run
ai.local.runtime.remove Remove the local model runtime high
ai.local.model.pull Download a model into the local runtime medium
ai.local.model.remove Delete a downloaded model medium
ai.hub.status Show RespireSoft AI enrolment (read) low No dry run
ai.hub.credits Show RespireSoft AI credits and what the last calls cost (read) low No dry run
ai.hub.enrol Enrol this installation with RespireSoft AI high
ai.hub.key.rotate Rotate the RespireSoft AI key high
ai.hub.key.finish Finish the RespireSoft AI key rotation medium No dry run
ai.hub.leave Leave RespireSoft AI high
ai.hub.models.sync Refresh the RespireSoft AI model list medium No dry run
ai.provider.list List model providers (read) low No dry run
ai.provider.create Add a model provider high
ai.provider.update Change a model provider high
ai.provider.delete Remove a model provider high
ai.provider.test Test a provider (list models and a tiny completion) (read) low No dry run
ai.model.list List catalogued models and routes (read) low No dry run
ai.model.configure Add or change a catalogued model medium No dry run
ai.model.delete Remove a catalogued model medium No dry run
ai.route.set Choose the models a feature uses medium No dry run
ai.chat.send Send a message to the assistant low No dry run
ai.job.list List the built-in assistant jobs and what each may use (read) low No dry run
ai.turn.get Read an assistant turn (read) low No dry run
ai.turn.answer Answer a question the assistant asked before reading sensitive data low No dry run
ai.tools.list List the tools your assistant may use (read) low No dry run
ai.session.list List your conversations (read) low No dry run
ai.session.get Read (export) a conversation (read) low No dry run
ai.session.delete Delete a conversation low No dry run
ai.plan.get Get a plan with its diffs (read) low No dry run
ai.plan.approve Approve and run a plan medium No dry run
ai.plan.reject Reject a plan low No dry run
ai.plan.undo Undo an executed plan medium No dry run
ai.journey.list List the guide's journeys for you (active, suggested, the rest) (read) low No dry run
ai.journey.get A journey with every step checked now (read) low No dry run
ai.journey.start Start or resume a journey low
ai.journey.step Ask the guide about one step low No dry run
ai.journey.skip Skip a step of a journey low
ai.journey.confirm Mark a step you did yourself as done low
ai.journey.pause Pause a journey low
ai.journey.dismiss Stop offering a journey low
ai.guide.welcome What the guide shows on your first sign-in (read) low No dry run
ai.guide.anchors The screen controls the guide can point at (read) low No dry run
ai.guide.prefs.get How the guide explains and when it opens (read) low No dry run
ai.admin.request Ask the administrators to turn the assistant on low
ai.guide.prefs.set Change how the guide explains and when it opens low
ai.guide.setup.status Whether the panel set up RespireSoft AI by itself (read) low No dry run
ai.rag.search Search the documentation (read) low No dry run
ai.rag.status Documentation index status (read) low No dry run
ai.rag.reindex Rebuild the documentation index medium No dry run
ai.server.status What of this server is indexed (read) low No dry run
ai.kb.create Create a knowledge base medium No dry run
ai.kb.list List knowledge bases (read) low No dry run
ai.kb.delete Delete a knowledge base and all its data medium No dry run
ai.kb.doc.add Add a document to a knowledge base low No dry run
ai.kb.doc.delete Remove a document and its vectors low No dry run
ai.kb.doc.upload Upload a PDF, DOCX, Markdown, text, HTML or CSV file low No dry run
ai.kb.doc.list List the documents of a knowledge base with their status (read) low No dry run
ai.kb.doc.retry Retry a failed document low No dry run
ai.collection.create Create a vector collection low No dry run
ai.collection.list List vector collections (read) low No dry run
ai.collection.delete Delete a vector collection and its points medium No dry run
ai.collection.upsert Put points into a vector collection low No dry run
ai.collection.search Search a vector collection (read) low No dry run
ai.kb.query Ask a knowledge base (RAG) (read) low No dry run
ai.gateway.key.create Create an AI gateway key medium No dry run
ai.gateway.key.list List AI gateway keys (read) low No dry run
ai.gateway.key.revoke Revoke an AI gateway key medium No dry run
ai.gateway.key.update Change the limits of an AI gateway key medium
ai.gateway.logs AI gateway request log (read) low No dry run
ai.usage.get AI usage and credits (read) low No dry run

Permissions and limits

Permissions

  • ai.assistant.use Use the assistant
  • ai.plan.approve Approve and undo assistant plans
  • ai.provider.manage Manage model providers and routing
  • ai.policy.manage Manage the AI policy and kill switch
  • ai.rag.admin Manage the documentation index
  • ai.usage.view See AI usage and credits
  • ai.gateway.manage Manage AI gateway keys
  • ai.kb.manage Manage knowledge bases
  • ai.kb.use Query knowledge bases

Plan limits

  • ai.credits_month AI credits per month
  • ai.assistant_msgs_day Assistant messages per day
  • ai.gateway_keys AI gateway keys
  • ai.kb_count Knowledge bases
  • ai.guide The guide (1 = on, 0 = off)

Engineering notes

Generated from modules/ai/docs.md at build e0cdf300. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

Ops assistant, documentation RAG, model providers, customer AI gateway and knowledge bases. Threat model: docs/security/ai.md. Card: docs/tasks/w8/w8-03-ai.md. Edition core (works on every licence; customer services are gated by the plan's ai.credits_month, default 0).

The assistant's documentation corpus contains customer-facing module guides, installation and update guidance, and feature specifications. Internal security notes and findings are never included in a customer release or its corpus.

Layout

  • llm/ model layer (adapted from nerix chat/llm.py, common/providers.py): Model/Embedder interfaces, one Client for Anthropic Messages (prompt caching: system + last tool + turns[-2] breakpoints; usage reconciled to four slots) and the OpenAI wire (OpenAI, DeepSeek, Mistral, Gemini OpenAI endpoint, OpenRouter, any compatible URL, Ollama/vLLM/llama.cpp). Egress: retries 429/5xx with Retry-After, provider up/down board, body guard.
  • safety/ redaction, egress guard, injection scan, untrusted-data fence.
  • rag/ heading-aware chunking (1,800/200/200 chars, heading path carried forward), built-in feature-hash embedder (builtin-hash-256, offline), reciprocal-rank fusion (k=60). Adapted from nerix library/*.
  • assistant/ tools from the registry, the turn loop (quarantine), plan hash, step-wise execution with re-preview.
  • cp/ the plugin: actions, Postgres store (schema m_ai), pgvector or exact vector spaces, gateway listener.
  • mockmodel/ deterministic OpenAI-compatible mock (lab container rc-w803-mock; a deliberately naive "model").
  • tests/lab.sh lab proofs (up | run | down).

How it works

  • Turn: ai.chat.send {message, session_id?, context?, wait?} -> tools = registry actions the caller may run (ai.tools.list shows them and why others were skipped) -> model loop (<= 6 calls). Reads run as the caller; a mutating call runs the action's **dry run** and becomes a plan step. Live progress: event ai.turn.updated (ids only) -> ai.turn.get {id, after_seq} returns new parts + the streaming draft.
  • Plan: ai.plan.get (steps, params, previews, risk, expiry, reversible) -> ai.plan.approve {id, hash} (step-up for high/critical) -> each step re-previewed (must match), executed, verified (<module>.get when it exists), inverse recorded -> ai.plan.undo {id} within undo_days.
  • RAG: corpus = every installed module's docs.md + Markdown under /usr/share/respirecloud/docs (setting docs_dirs). Keyword pane (Postgres FTS, >= half the query terms) + semantic pane (active space) fused by rank; no relevant passage -> abstain. A space is one embedding model; changing the routed model builds a new space beside the active one and swaps when complete. pgvector (halfvec(dims) + HNSW m=16/ef 64, iterative scans) when the extension exists in m_ai, exact cosine otherwise. Refreshed every 30 min (ai.docs.refresh) and on ai.rag.reindex.
  • Gateway: POST /v1/chat/completions (stream or not), /v1/embeddings, GET /v1/models (also under /ai/v1) on 127.0.0.1:7790; key rcai_<id>_<secret>; model auto = the gateway route. Usage read from the provider's final chunk (stream_options.include_usage forced) and metered.
  • Metering: every call -> usage_ledger (4 token slots, credits = rate table per 1M tokens; estimates). Non-admins are capped by ai.credits_month (assistant, KB answers, gateway) and ai.assistant_msgs_day.

The assistant in every screen (w11-16)

  • Jobs (jobs/jobs.yaml, embedded): reviewable data, one per entry point: instructions, reads (lookups the job may call), fixes (previewable changes it may only PROPOSE as plan steps), needs (context it cannot work without) and the answer shape. A job turn gets the registry actions the person may run AND the job lists: it only ever takes tools away. ai.job.list shows each job with what it can use for the signed-in person. Tests validate every job against the manifests (reads are read-only, fixes are previewable).
  • Context: ai.chat.send {job?, context, excerpts}. context = typed references (site, domain, module...), short and shown as chips in the dock; excerpts = text the screen showed (log line, diff, bounce, file), at most 5 x 6000 chars. Excerpts are redacted, fenced with the turn nonce and scanned like a tool result; an instruction in one quarantines the turn before any tool runs, and values that come from an excerpt mark a step "from untrusted text". Turns remember job and context (turns.job/context) so [E1] citations reopen from the history.
  • UI (@rc/sdk-ui): useAssistantContext (what a screen shows), AskAssistant (the entry-point button; hidden by policy / permission, kept when no provider is set up), a globalThis bridge the dock reads. Entry points sit in // ---- w11-16 ---- blocks of each module screen; the shared Configuration screen carries "Check this configuration" for web, mail, DNS and databases at once.
  • States: no provider -> the dock says "Set up a model provider" (admin, with the settings link) or "Your host has not turned this on" and offers a keyword documentation search; disabled by policy -> entry points and the launcher disappear; over credits -> a notice and the limit's own message.
  • Lab: AISTUB=1 make lab-up LAB=<id> FAKENET=full starts lab/aistub (scripted OpenAI-compatible stub, never on the demo); register it as provider kind vllm, base URL http://10.77.<N>.42:8080/v1. Walk: web/apps/panel/scripts/lab-walk-assistant.mjs.

Verified (lab w8-03, 2026-10-10)

Docs question answered with citations (docs/specs/ai.md › … › Assistant: safety model); out-of-corpus question abstains; "add domain shop-alice.lab.test for alice" -> plan with the domains dry-run diff -> wrong hash refused -> approved -> executed, verified, audited (ai.plan.proposed/approved/executed, domains.create by admin) -> re-approve refused -> undo removed the domain; injection line in alice's log -> quarantined, naive domains_delete blocked, no plan, audit ai.injection.suspected (denied), 0 secrets at the model server; gateway key for alice: 429 insufficient_quota with plan credits 0, after assigning 1 credit: 200 + streamed 200, then 429 with Retry-After, ledger gateway 1.235; kill switch refuses chat; pgvector space built and swapped in when the extension exists.

Next

Reseller-scoped keys; running the local runtime on a server other than the panel's own (see "Local models" below).

  • First use asks first (AC-ai-13). Policy.ConsentConfirmed lists remote providers whose consent step was saved. route() skips a remote provider that is not listed (nothing is sent to it, not even a question); ai.chat.send then answers consent_required: choose what may be sent to <id>. The UI opens ConsentStep right after "Add provider", from the Providers tab and from Data sent; saving goes through ai.policy.set (dry-run plan, step-up). A local provider needs no step.
  • Gateway limits (AC-ai-32). Per key: requests/minute, tokens/day, credits_month (spend per calendar month), model allow-list, address allow-list; each answers 429 with its own type and Retry-After. ai.gateway.key.update changes them later (absent keeps, null removes the cap). Tests: gatewaylimits_test.go.
  • Fixtures. lab/tools/ai-fixtures.sh seeds a running lab (LAB=<id> lab/tools/ai-fixtures.sh [base|scenarios|restore]); the walk is web/apps/panel/scripts/lab-walk-assistant-fixtures.mjs. The stub (lab/aistub/investigate.go) names a cause only from text in a lookup result or the page excerpt, and investigate_test.go checks every lookup it makes is one of the job's reads.

RespireSoft AI: the central hub as one opt-in provider (w11-101)

RespireSoft's own AI service (the "hub", nerix) is one more provider an administrator can choose, paid in credits through the licence. Own keys and local models stay as they are; nothing is on by default. Contract: nerix-app/docs/HUB.md (sections 3, 5, 7, 8); where it and the SDK differ, HUB.md wins (differences: the handover of w11-101).

  • Kind respiresoft (llm.Kinds, Hub: true): the OpenAI wire client with /ai/v1 added to a host-only base URL (default https://platform.nerix.ai; this box's http://127.0.0.1:7406 or a lab address is allowed through egress_allow like any provider). It has no API key. The nerix object (opaque tenant = owner id or panel, end user = principal id, feature, data categories) is added to chat and embeddings bodies (llm/hubcall.go); the answer's nerix object gives the five token slots, the credits charged and the model that answered. ai.hub.models.sync mirrors GET /ai/v1/models into the catalogue as respiresoft.<instance>.<model> (rates are the hub's credits per million tokens). The customer gateway is never routed to it (third parties' traffic does not go through this installation's hub account), and it is never an implicit route: only a route an administrator wrote can name it.
  • Signing and verification are the SDK's: third_party/respiresoft-ai (nested module, unmodified copy of nerix-app sdk/go; why and how to update: VENDORED.md there). A SigningTransport sits under the hub egress (cp/hub.go, dynSigner): it signs every request (RFC 9421, Ed25519, nerix-hub-req-v1), verifies every answer against the pinned hub keys and a stream's tail signature before the stream's result is handed over, and fails closed. A refusal reaches the person as one plain sentence (llm.HubPlain); text shown while streaming is provisional, a stream that does not verify returns no answer, no usage, no tool calls and no ledger credit.
  • The key. ai.hub.enrol makes an Ed25519 key on this server and stores its PKCS#8 PEM in the host vault (AES-256-GCM under the panel's master key file) under hub:key:<keyid>: never shown, never in an answer, an audit row, a log or a setting, never exported, and its text is one of the module's known secrets, so the egress guard would refuse a body that carried it. It is stored before the hub hears of it. Pins: the module setting hub_keys (advanced) or the SDK's shipped production key before enrolment; after it, those plus the keys the hub's answer returned (an answer that verified under a pinned key).
  • Enrolment (HUB.md 8). The one-time token comes from RespireSoft's licence service by default: with the token field empty, the module drops <data dir>/licence/ai-enrolment-wanted; the licence manager (internal/licence/aitoken.go, served on its 5 s tick) posts /v1/ai/enrolment-token to respirecloud-store, which checks the licence (known, not revoked, this machine, term open, entitlement respiresoft-ai) and asks the hub POST /ai/v1/enrolment-tokens (HUB.md 8.1) with its own signed key; the signed answer carries the token and the manager leaves it in ai-enrolment-token.json (0600), which the module reads once and deletes. A refusal travels the same way in plain words. Pasting a token stays the fallback (and the only way when the licence service has no hub configured: respirecloud-store serve -ai-hub-url -ai-hub-key [-ai-hub-keys], env RC_AI_HUB_URL, RC_AI_HUB_KEY_FILE, RC_AI_HUB_KEYS). The panel sends the licence file as it is (<data dir>/licence/licence.json), the token, the public key, its keyid and the proof, and stores service id, scopes, limits and hub keys (hub_enrolment). Every step is audited (ai.hub.enrol.step: start, token_requested, token_received or token_refused, key_created, request_sent, registered or refused, models; never the token); refusals are in plain words with the hub's code (licence_invalid with its reason, enrolment_token_invalid, ...). Rotation (ai.hub.key.rotate) enrols a new key for the same licence with a new token; both stay registered until RespireSoft revokes the old one, then ai.hub.key.finish forgets it here. ai.hub.leave destroys the key and removes the provider, its models, its routes entries and its consent (the contract has no client-side revoke: RespireSoft revokes the keyid; the plan names it).
  • Consent, redaction, data-sent log, air-gapped mode are the ordinary ones: the provider is remote whatever its address, first use asks first (ai.consent.get lists it), the assistant's categories also go in nerix.categories so the hub applies its own policy, every call writes a data-sent row, and air-gapped mode refuses it in the routing, the egress layer and the screens' own reads.
  • Credits. The ledger stores what the hub charged (usage_ledger.credits = hub credits, reasoning_tokens, hub_request_id, hub_resolved). ai.hub.credits reads the balance from /credits/v1/tenants/panel (needs the credits:read scope, which the licence service's default token does not carry: the screen then says the balance is on the licence account) and lists the last calls. At 0 (402 credits_exhausted) the provider is refused with a plain message and skipped for a minute; the routing falls back only to a model the administrator listed behind it in the route.
  • Screen: Assistant settings, Providers, "RespireSoft AI" card (ui/src/pages/hub.tsx; wording in the tested hubmodel.ts).
  • Tests: hubstandin is a stand-in hub written from HUB.md without the SDK's signing code (modules/ai/hubstandin; cmd/hubstandin runs it in a lab network); its tests, llm/hub_test.go and cp/hub_test.go prove enrolment, rotation, leave, chat/stream/tools/ embeddings, every unverified answer refused, credits at 0 and the fallback rule, consent and air-gapped mode.
  • Not proven against the real hub: everything above ran against the stand-in only. A real enrolment needs a token from the hub's staff console for the installation's licence, the licence key registered there, the licence service's public key in the hub, and the hub's public key pinned (SDK default or hub_keys).

Local models (w11-44, AC-ai-38/39/13)

A model that runs on the customer's own server, for people who allow nothing to leave it. Screen: Panel -> AI services -> Local models (admin).

The runtime. One container, rc-ai-runtime (Ollama), on the panel's own server. The image is the version-catalogue entry localmodel.RuntimeCatalogue (release tag, size shown in the plan; its content digest is recorded after the first real install, see the checklist) unless the AI setting local_image names another (an offline mirror or an image loaded from a file; the plan then warns that it is not the pinned one). It is installed by the agent ops ai.local.runtime.apply|action|status (internal/agent/ai_local.go), which reuse the containers agent's engine plumbing (the same Docker with user-namespace remap, the same job table) but are NOT a hosting account's stack: no account, no compose file a person can edit, no bind mount. The argument list comes from one function, localmodel.RunArgs, pinned by tests.

  • Exposure: the only published port is 127.0.0.1:11435 on the node (--publish 127.0.0.1:...; the agent refuses any other port range). The container sits alone on its own network rc-ai-runtime (Docker does not route between bridge networks), cap_drop ALL, no-new-privileges, no privileged/host namespace/device/host mount. A customer's container cannot reach the node's loopback, nor the runtime's container address. Only the panel's provider local-runtime (kind ollama, base http://127.0.0.1:11435/v1) talks to it, so every use passes the gateway's keys, limits and logs.
  • Resources: its own slice rc-ai-runtime.slice plus Docker --cpus/--memory/--memory-swap/--pids-limit, sized from the sizing profile (35-70 % of memory, 50-70 % of cores, always leaving 1 GB and a core to the panel; a server under 2 GB is refused with the number).
  • States (localmodel.Transition): absent -> starting -> running -> stopping -> stopped -> removing -> absent, failed after a failed job. A busy state accepts nothing (conflict, enforced by one UPDATE ... WHERE state = ...). Enable / start / stop / remove are jobs with named steps (Call.Step, Call.Title), enable and pull are cancellable, NoCancel is set once the container has started. The stored state is corrected from the agent's view each time the screen asks.
  • GPU: ai.gpu.detect output is parsed in localmodel; a GPU node starts the container with --gpus all (needs the NVIDIA container toolkit).

Models. Allow-list only (localmodel.AllowList). ai.local.model.pull (dry run = the plan) shows the download size, where it would run, how much memory it needs against the runtime's own limit, and disk before / after; it refuses with the numbers when the model does not fit (localmodel.Fit with Node.LimitMB) or when disk would drop under download + 10 % + 2 GB. The real call needs confirm_download_mb equal to the size shown. Progress comes from the runtime's /api/pull stream; cancelling keeps the part already downloaded and a new pull continues from it. A finished pull is checked against /api/tags, then catalogued under the provider with its features (and 768 dimensions for the embedding model). ai.local.model.remove deletes it from the runtime, the catalogue and every route. Disk used by models is the sum of /api/tags sizes.

Use. local-runtime is a local provider kind: no consent step, never blocked by air-gapped mode. "Use it" on the screen sets the routes (assistant, knowledge-base answers and the customer gateway for a chat model; document / knowledge-base search for the embedding model). Queue = requests in flight counted by the egress layer (llm.LocalLoad; the runtime has no queue API), speed = output tokens over answer time, memory from docker stats, VRAM from /api/ps.

Air-gapped mode is enforced in the egress layer. llm.Egress.AirGap is read on every request: with the switch on, a request to a remote provider returns ErrAirGapped before any connection is opened, whichever code asked (assistant, gateway, knowledge-base or documentation embeddings, a provider test). The routing still skips remote models; this is the second wall. The switch is cached two seconds, applies at once on save, and an unreadable policy counts as air-gapped (fails closed). Without an embedding model, knowledge bases and documentation search keep working in air-gapped mode on the built-in hash vectors (finds words rather than meaning); pull Nomic Embed Text for meaning-based search. The screen says this in words.

What was and was not proven (read before a first run on real hardware)

Proven in the lab (w1144, no GPU, 23 GB host memory, Ubuntu 24.04 node): the whole product path with the lab stub standing in for the runtime (lab/aistub/runtime.go implements Ollama's /api/pull|tags|ps|delete|embed|version and the OpenAI endpoints; a "model" is a 256 KB placeholder file). Enable, start, stop, remove as jobs through the real agent and Docker; loopback-only publication; unreachability from another container on the lab network and from a customer's container stack; pull with progress, cancel and resume; fit and disk refusals; routing; air-gapped behaviour with a second provider's request counter as the witness; the screen at 1440/390, light/dark. Unit-tested: state machine, sizing, RunArgs exposure, allow-list, fit with the runtime's limit, plan numbers, egress air-gap switch, in-flight counting. NOT proven: that the real image starts and listens on 11434 with OLLAMA_HOST; real pull sizes and speed; real generation, tool calls with a real small model (quality of the assistant on a 1-3B model is unknown); GPU passthrough and ai.gpu.detect against a real card (parsers run on recorded output only); size_vram and /api/ps shape from the real runtime; the image digest. First-run checklist on real hardware: (1) docker pull the catalogue tag, record its digest in RuntimeCatalogue.Digest; (2) enable from the screen on a CPU server, ask the assistant a question after pulling llama3.2:1b; check docker stats against the limits and that /api/ps lists the model; (3) on a GPU server confirm --gpus all works, ai.gpu.detect shows the card and VRAM, size_vram > 0 after a question; (4) from a customer's container try the node's loopback port, the container address and the bridge gateway (all must fail); (5) check the real download sizes against AllowList and correct them; (6) tool-calling: run the assistant's built-in jobs against the small model and decide which models to keep on the list; (7) the agent reads /proc/meminfo, which inside a memory-limited container (as in the lab) shows the host's memory, not the container's. Also not done: running the runtime on a server other than the panel's own (the panel reaches it over that server's loopback; needs an agent proxy); a pull job that survives a panel restart mid-stream (the part kept makes a new pull cheap, but the job itself ends).

The guide (w11-122, ADR 0021)

The assistant walks a person from the first screen to a working result, in plain words. Contract: docs/adr/0021-the-guide.md.

  • Journeys (journeys/journeys.yaml, embedded; journeys/journeys.go): goals per role (admin, reseller, user, subuser), each step with title/explain (locale maps, English required; 2-4 sentences), exactly one of done (a read + a condition: min, empty, equals, not, all, a dot path where * fans out), wait (a done the guide comes back to: every, for, tell, expired) or ack (the person's own "I did it", never a change), reads/fixes (intersected with what the person may run; fixes previewable, proposed as plan steps), open (route + anchor for "Show me"), instructions for the model, capture (values later params use as {name}). Tests check every journey against every manifest (reads read-only, fixes previewable, the check's read runnable by every role of the journey by default, its path in the result schema or named by the module's code), routes per area and anchors.
  • State m_ai.journeys (per person: state, current step, step states with the evidence of the last read, captured values, the guide conversation, the last follow-up notice). A step is done only when its read says so (and undone when the read says otherwise); a wait starts when its step becomes current. Actions ai.journey.list|get|start|step|skip|confirm|pause|dismiss; shapes in schemas/journey*.json and the w11-122 handover.
  • Guide turns: ai.chat.send {mode: "guide", journey?, step?, locale?} (or ai.journey.step). The system block gets journeys.GuidePrompt (stable, cached with the assistant's) and the journey block (steps with their state, the current step's explanation and instructions, its screen, the anchors it may name); with a step the tools are narrowed to its reads and fixes like a job. The answer may end in one ```guide JSON block; journeys.ParseAnswer takes it out of the text and keeps only up to 4 quick replies (60 characters), registered anchors (max 3, unknown ones dropped and audited), a journey the person may start or a step of the current journey; a block that is not JSON is dropped whole. Stored in turns.guide, returned as guide by the turn and ai.turn.get; the streaming draft hides a block that is still being written. Route guide (ai.route.set, a chat model with the assistant feature) or the assistant's route.
  • Preferences m_ai.guide_prefs (ai.guide.prefs.get|set): explain simple (customers, team members) or detailed (administrators, resellers), auto_open (on), welcomed. Policy ai.guide: guide_off, guide_kinds in the AI policy (per role) and the plan limit ai.guide (1 on, 0 off). Off = no journeys, no welcome, guide turns refused.
  • Follow-ups: a background call cannot act as the person (ADR 0018), so the schedule ai.journey.tick (5 min) reads nothing: it closes waits past their for (notice) and announces due checks once with ai.journey.updated {principal_id, journey, step, status: "due"}. The person's own calls (ai.journey.list on that event or on load, ai.journey.get, ai.guide.welcome) run the due reads as the person: at most 3 per call and 30 per person per hour (m_ai.guide_checks). A completed or expired wait: a notice on the person's bell (core.notice.raise with recipient_id, declared system call: since w11-124 only that person sees it, not the account's owner, not the administrators) and ai.journey.updated.
  • Waits that notice events (w11-124): a wait may list module events (on: [ssl.cert.issued, {event: apps.deploy.finished, match: {status: ok}}]; match compares a top-level field of the event's data with a literal or a captured {var}, a list field must contain it, a {var} not captured yet does not narrow). The manifest subscribes ai.journey.on_event to exactly those events (a test keeps the lists equal). On delivery (as the system, with the event's account) every active journey of that account (administrators' journeys for events of no account) whose current wait listens for it is made due now, the person is told once ("Your padlock is ready. Next: ..." = the wait's tell, status: "ready"), and nothing is read; the step's read runs as the person on their next call and decides. When it agrees no second notice is sent (told); when it does not, the step keeps waiting and the journey's "ready" notice is withdrawn. Events used: ssl.cert.issued, apps.deploy.finished, backups.job.succeeded, dns.record.changed.
  • "Does this address point here?" (w11-124): the address steps of Put my website online and Move my website here wait for dns.domain.points_here {domain} (dns module; modules/dns/docs.md), then a separate padlock step waits for the certificate.
  • "Ask the guide what went wrong" (w11-124): job guide.what_went_wrong (jobs.yaml) for every failure without a job of its own: the error and, for a job, its last log lines as a logs excerpt; {module}.* reads and previewable fixes of the module whose work failed (refs.module, bound at turn time by jobs.Job.Bind; never a denied module), plus fixed reads (logs.query, the sites, the domains, a few status reads). Without a module: the fixed reads and no change. ai.chat.send takes it in guide mode (a journey step's turn still has no job). The screens send it from every error toast, failed job dialog and module "couldn't load" box.
  • Ready from the first screen: the same tick enrols RespireSoft AI by itself (guidesetup.go) when the licence file lists the respiresoft-ai entitlement unexpired, no provider exists and the panel is not air-gapped, through the existing licence token path; then routes assistant and guide to the hub's first chat model. At most once (m_ai.guide_setup): a provider that already exists, an earlier enrolment or an administrator leaving RespireSoft AI stops it for good; at most 3 attempts 15 minutes apart, then a notice. The first-use consent still applies. ai.guide.setup.status shows it.
  • Welcome / checklist: ai.guide.welcome (show once, suggested journeys as choices, "Just show me around"). Without a model (no provider, consent pending, no credits, daily limit, assistant off) every journey works as a checklist: ai.journey.step answers mode: "checklist" with the step, its check and screen, and guide.message says how AI would help and how to get it.
  • Anchors: ai.guide.anchors. The registry journeys/anchors.json belongs to the screens (w11-123); the package embeds its whole folder so the file is used as soon as it lands. Until then the journeys' own list (journeys.Used) stands in (registered: false).

Privacy, asking and guardrails (w11-133)

The owner's rule (2026-10-18): the customer's AI keeps everything on their own server, only RespireSoft's AI sees anything and even RespireSoft keeps nothing, dangerous things ask, sensitive things ask, and the assistant is careful and in scope. Already true and kept: vectors and knowledge bases live in m_ai on the customer's server, never in the hub's collections; the hub logs metadata only (HUB.md section 9); secrets are removed before anything leaves; every change is a plan the person approves (passkey for high and critical); air-gapped mode refuses every outside provider.

  1. Indexing stays on the server. cp/embedsetup.go: on the schedule tick (next to the auto-enrol) the panel decides where embeddings run. If the server can hold nomic-embed-text (localmodel.Fit) it installs the local runtime when missing (the existing enable path), downloads the model once, routes embeddings to it and raises one notice. Only where it does not fit does it route embeddings to RespireSoft AI (zero-retention embedding model, enrolled installation) and say so. A route an administrator wrote is never touched; at most 3 attempts, 30 minutes apart. ai.local.status and ai.guide.setup.status report embeddings: {where: local|hub|provider|builtin, note, state, detail} in plain words.
  2. Nobody keeps the content. cp/hubconsent.go: on enrolment, on every policy change that touches RespireSoft AI's categories and before the first call of each tenant, the panel PUTs /ai/v1/tenants/{tenant}/consent with allowed (prompt plus the consented categories), require_zero_retention for every one of them and pii_kinds (email, phone, iban, card, national_id, passport, mrz). A call whose consent could not be set does not go out (fail closed). The route only picks RespireSoft AI models whose data.zero_retention is true (stored at models.capabilities; re-run "Refresh model list" once after updating).
  3. Sensitive reads ask. assistant/ask.go: a turn to a provider that is not local, about to put mail, file text or configuration (categories mail, file_excerpts, configs; records has no read action yet) into a prompt, pauses with an ask part (what, why, which provider). ai.turn.answer {turn_id, qid, answer: once|conversation|no} answers it (only by the person who owns the turn). No answer in 3 minutes, a synchronous (wait) caller, or any other value is "no". "For this conversation" is stored on the session (sessions.grants, categories only). The administrator's consent switches are checked first and cannot be widened by the person; a Team member's tools are still their own permissions. Every answer is audited (ai.turn.answered) and shown in Data sent (sent_log.decision). Logs and metadata keep the old rule.
  4. Risky actions ask, as before: a mutating tool call only previews and becomes a plan step; no turn executes anything; a guide step is not an approval.
  5. Own keys allowed, RespireSoft AI recommended. The RespireSoft AI card (and the no-model state in the chat) shows "Recommended" with the reasons from ui/src/pages/recommended.ts. Price: "to be announced". Not claimed: that the model was trained on RespireCloud.
  6. Careful, in scope, guarded. assistant.SystemRules (rules 7 to 10 of the system prompt, shared by the assistant and the guide). Local checks in safety/rules.go, no model call: CheckRequest (out_of_scope, override_attempt, and for non-administrators cross_account and privilege_escalation) before anything leaves; CheckAnswer (destructive_output, unbacked_claim, secret_in_output) on the answer. Prompt injection in tool results stays quarantined. Each refusal is a safety event and an ai.guardrail audit row with the rule name; the person sees one plain sentence. Every rule has its own positive and negative test lines. Hub layer: the hub applies the respirecloud-panel@1 profile by service default (no request field). A 422 guardrail_refused (detail.code) shows one plain sentence (llm.GuardrailPlain), is never retried and never falls back to another provider; the profile and version of each answer are kept in sent_log.guardrails. Cost: the panel's checks are rules. The hub's classifier usage (nerix.usage.guardrails.credits) is metered to the account that made the turn as its own usage_ledger row (feature safety_checks), counted against that account's ai.credits_month and shown as "Safety checks" in its usage. Nothing is charged to the installation's house account.

The assistant is always there (w11-135)

  • Entry points (floating button, "Ask the assistant" buttons, the command palette line, "Ask the guide what went wrong", the guide's open button) hide only when AI is turned off on purpose: the administrator's policy for that kind of person, or air-gapped mode with no local model (ai.status then says assistant_enabled: false, off_reason: "air_gapped"). Not set up, consent needed, RespireSoft AI not available yet and "this month's AI is used up" no longer hide anything.
  • In those states the dock opens a setup panel (ui/src/setup/) instead of the chat. Administrators: "Use RespireSoft AI" (Recommended, the w11-133 reasons; "not available yet ... we will turn it on by itself" when the licence has it but no model that keeps nothing is ready; the consent step when it is ready but waiting), "Use your own provider" (/assistant/settings?tab=providers), "Use a model on this server" (/assistant/services?tab=local, only when ai.local.status has a model that fits); when credits are used up: "Get more" (the Store's More RespireSoft AI add-on) and "Use your own provider". Everyone else: one sentence ("AI is not set up on this server yet." / "This month's AI is used up.") and "Ask my administrator to turn it on".
  • ai.admin.request {reason: not_set_up|no_credits} raises one core.notice.raise for the administrators, at most once a day per person (admin_requests, one atomic upsert), audited as ai.admin.requested. A preview raises nothing.
  • ai.status gives administrators respiresoft: {entitled, enrolled, ready, credits_used_up} and everybody air_gapped.
  • The guide keeps its checklist mode underneath; a step's "Show me" works without AI.