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.
| Action | What it does | Risk | Preview |
|---|---|---|---|
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.useUse the assistantai.plan.approveApprove and undo assistant plansai.provider.manageManage model providers and routingai.policy.manageManage the AI policy and kill switchai.rag.adminManage the documentation indexai.usage.viewSee AI usage and creditsai.gateway.manageManage AI gateway keysai.kb.manageManage knowledge basesai.kb.useQuery knowledge bases
Plan limits
ai.credits_monthAI credits per monthai.assistant_msgs_dayAssistant messages per dayai.gateway_keysAI gateway keysai.kb_countKnowledge basesai.guideThe guide (1 = on, 0 = off)
Engineering notes
Generated from modules/ai/docs.md at build f3a3c074. 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 nerixchat/llm.py,common/providers.py):Model/Embedderinterfaces, oneClientfor 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 nerixlibrary/*.assistant/tools from the registry, the turn loop (quarantine), plan hash, step-wise execution with re-preview.cp/the plugin: actions, Postgres store (schemam_ai), pgvector or exact vector spaces, gateway listener.mockmodel/deterministic OpenAI-compatible mock (lab containerrc-w803-mock; a deliberately naive "model").tests/lab.shlab 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.listshows 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: eventai.turn.updated(ids only) ->ai.turn.get {id, after_seq}returns new parts + the streamingdraft. - 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>.getwhen it exists), inverse recorded ->ai.plan.undo {id}withinundo_days. - RAG: corpus = every installed module's
docs.md+ Markdown under/usr/share/respirecloud/docs(settingdocs_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 inm_ai, exact cosine otherwise. Refreshed every 30 min (ai.docs.refresh) and onai.rag.reindex. - Gateway:
POST /v1/chat/completions(stream or not),/v1/embeddings,GET /v1/models(also under/ai/v1) on127.0.0.1:7790; keyrcai_<id>_<secret>; modelauto= thegatewayroute. Usage read from the provider's final chunk (stream_options.include_usageforced) and metered. - Metering: every call ->
usage_ledger(4 token slots, credits = rate table per 1M tokens; estimates). Non-admins are capped byai.credits_month(assistant, KB answers, gateway) andai.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.listshows 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), aglobalThisbridge 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=fullstartslab/aistub(scripted OpenAI-compatible stub, never on the demo); register it as provider kindvllm, base URLhttp://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).
w11-32: first-use consent, per-key limits, fixtures
- First use asks first (AC-ai-13).
Policy.ConsentConfirmedlists 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.sendthen answersconsent_required: choose what may be sent to <id>. The UI opensConsentStepright after "Add provider", from the Providers tab and from Data sent; saving goes throughai.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 owntypeandRetry-After.ai.gateway.key.updatechanges them later (absent keeps, null removes the cap). Tests:gatewaylimits_test.go. - Fixtures.
lab/tools/ai-fixtures.shseeds a running lab (LAB=<id> lab/tools/ai-fixtures.sh [base|scenarios|restore]); the walk isweb/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, andinvestigate_test.gochecks every lookup it makes is one of the job'sreads.
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/v1added to a host-only base URL (defaulthttps://platform.nerix.ai; this box'shttp://127.0.0.1:7406or a lab address is allowed throughegress_allowlike any provider). It has no API key. Thenerixobject (opaque tenant = owner id orpanel, end user = principal id, feature, data categories) is added to chat and embeddings bodies (llm/hubcall.go); the answer'snerixobject gives the five token slots, the credits charged and the model that answered.ai.hub.models.syncmirrorsGET /ai/v1/modelsinto the catalogue asrespiresoft.<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-appsdk/go; why and how to update:VENDORED.mdthere). ASigningTransportsits 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.enrolmakes 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) underhub: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 settinghub_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-tokentorespirecloud-store, which checks the licence (known, not revoked, this machine, term open, entitlementrespiresoft-ai) and asks the hubPOST /ai/v1/enrolment-tokens(HUB.md 8.1) with its own signed key; the signed answer carries the token and the manager leaves it inai-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], envRC_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_invalidwith 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, thenai.hub.key.finishforgets it here.ai.hub.leavedestroys 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.getlists it), the assistant's categories also go innerix.categoriesso 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.creditsreads the balance from/credits/v1/tenants/panel(needs thecredits:readscope, 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 testedhubmodel.ts). - Tests:
hubstandinis a stand-in hub written from HUB.md without the SDK's signing code (modules/ai/hubstandin;cmd/hubstandinruns it in a lab network); its tests,llm/hub_test.goandcp/hub_test.goprove 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:11435on the node (--publish 127.0.0.1:...; the agent refuses any other port range). The container sits alone on its own networkrc-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 providerlocal-runtime(kindollama, basehttp://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.sliceplus 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,failedafter a failed job. A busy state accepts nothing (conflict, enforced by oneUPDATE ... WHERE state = ...). Enable / start / stop / remove are jobs with named steps (Call.Step,Call.Title), enable and pull are cancellable,NoCancelis set once the container has started. The stored state is corrected from the agent's view each time the screen asks. - GPU:
ai.gpu.detectoutput is parsed inlocalmodel; 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 withtitle/explain(locale maps, English required; 2-4 sentences), exactly one ofdone(a read + a condition:min,empty,equals,not,all, a dotpathwhere*fans out),wait(a done the guide comes back to:every,for,tell,expired) orack(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"),instructionsfor 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. Actionsai.journey.list|get|start|step|skip|confirm|pause|dismiss; shapes inschemas/journey*.jsonand the w11-122 handover. - Guide turns:
ai.chat.send {mode: "guide", journey?, step?, locale?}(orai.journey.step). The system block getsjourneys.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```guideJSON block;journeys.ParseAnswertakes 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 inturns.guide, returned asguideby the turn andai.turn.get; the streamingdrafthides a block that is still being written. Routeguide(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):explainsimple (customers, team members) or detailed (administrators, resellers),auto_open(on),welcomed. Policyai.guide:guide_off,guide_kindsin the AI policy (per role) and the plan limitai.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 theirfor(notice) and announces due checks once withai.journey.updated {principal_id, journey, step, status: "due"}. The person's own calls (ai.journey.liston 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.raisewithrecipient_id, declared system call: since w11-124 only that person sees it, not the account's owner, not the administrators) andai.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}}];matchcompares 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 subscribesai.journey.on_eventto 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'stell,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 alogsexcerpt;{module}.*reads and previewable fixes of the module whose work failed (refs.module, bound at turn time byjobs.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.sendtakes 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 therespiresoft-aientitlement unexpired, no provider exists and the panel is not air-gapped, through the existing licence token path; then routesassistantandguideto 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.statusshows 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.stepanswersmode: "checklist"with the step, its check and screen, andguide.messagesays how AI would help and how to get it. - Anchors:
ai.guide.anchors. The registryjourneys/anchors.jsonbelongs 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.
- 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 holdnomic-embed-text(localmodel.Fit) it installs the local runtime when missing (the existing enable path), downloads the model once, routesembeddingsto it and raises one notice. Only where it does not fit does it routeembeddingsto 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.statusandai.guide.setup.statusreportembeddings: {where: local|hub|provider|builtin, note, state, detail}in plain words. - 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 panelPUTs/ai/v1/tenants/{tenant}/consentwithallowed(prompt plus the consented categories),require_zero_retentionfor every one of them andpii_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 whosedata.zero_retentionis true (stored atmodels.capabilities; re-run "Refresh model list" once after updating). - Sensitive reads ask.
assistant/ask.go: a turn to a provider that is not local, about to put mail, file text or configuration (categoriesmail,file_excerpts,configs;recordshas no read action yet) into a prompt, pauses with anaskpart (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. - 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.
- 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. - Careful, in scope, guarded.
assistant.SystemRules(rules 7 to 10 of the system prompt, shared by the assistant and the guide). Local checks insafety/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 anai.guardrailaudit 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 therespirecloud-panel@1profile by service default (no request field). A422 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 insent_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 ownusage_ledgerrow (feature safety_checks), counted against that account'sai.credits_monthand 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.statusthen saysassistant_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 whenai.local.statushas 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 onecore.notice.raisefor the administrators, at most once a day per person (admin_requests, one atomic upsert), audited asai.admin.requested. A preview raises nothing.ai.statusgives administratorsrespiresoft: {entitled, enrolled, ready, credits_used_up}and everybodyair_gapped.- The guide keeps its checklist mode underneath; a step's "Show me" works without AI.