# QBit Assistant An in-app AI assistant for any QQQ application. The user asks a question in a chat panel in the Next UI. The assistant reads the application's data **with exactly that user's permissions**, answers with numbers taken from the data, and links to the records and lists it used. This first version only reads data and opens screens. It cannot change anything. Design spec: `QRun-IO/qqq` `docs/specs/2026-10-11-qqq-assistant-qbit-design.md`. ## Modules | Artifact | What it is | |---|---| | `com.kingsrook.qbits:qbit-assistant-provider-api` | Provider SPI, connection settings, messages, usage and pricing types; only Jackson annotations at runtime. | | `com.kingsrook.qbits:qbit-assistant` | The QBit: conversation tables, turn engine, `/qqq/v1/assistant` routes (JSON + Server-Sent Events), budgets, kill switch, the provider registry, and a scripted provider for tests and demos. | | `com.kingsrook.qbits:qbit-assistant-provider-anthropic` | The Claude provider (`com.anthropic:anthropic-java`). Add it to use Claude. The core QBit has no LLM SDK dependency. | | `com.kingsrook.qbits:qbit-assistant-provider-openai` | OpenAI-compatible chat and embedding providers. | | `com.kingsrook.qbits:qbit-assistant-provider-bedrock` | Claude through AWS Bedrock. | | `com.kingsrook.qbits:qbit-assistant-provider-vertex` | Claude and Gemini through Google Vertex AI. | | `com.kingsrook.qbits:qbit-assistant-rag-pgvector` | JDBC vector store; retains the runtime dependency for `VectorStore` and `ChunkVector`. | ### Implementing a provider outside qbit-assistant Depend on `com.kingsrook.qbits:qbit-assistant-provider-api` at the same version as QBit Assistant, and implement `AssistantModelProvider` and optionally `AssistantProviderType`. No assistant runtime dependency is needed. All API types, including `ModelPricing`, live exclusively in `com.kingsrook.qbits.assistant.spi`. External consumers such as qbit-marketing allow that package in their G1 guardrails; qbit-bom will add it to the G1 defaults. See [migration imports](UPGRADE.md#provider-api-extraction-30). `AssistantProviderType.validate(ProviderSettings)` receives unresolved settings (including `apiKeyEnvName`), without a database entity or resolved credentials. Hosts register providers through `AssistantQBitConfig` in `qbit-assistant`. Data tools use the QQQ UI API (`/qqq/v1`) with the signed-in user's session. The UI API enforces permissions and record locks; assistant data-access policy and field exposure can only narrow access. Only record-free navigation and help use the core AI tool kernel. Every tool result reaches the model wrapped as untrusted data. ## Transcript privacy QQQ **4.1.0-RC.7** supplies the secured-data tokens and log hook (`qqq#1182`). Stored transcripts never contain the raw PII/PHI values read by a turn or supplied in classified tool criteria/fields. Messages, tool logs, replay events, knowledge snippets and pending payloads store `ClassifiedValueTokens` tokens; scheduled summaries use the redacted message. The model and live user stream retain the values permitted by PHI routing. History and replay show tokens instead. Content is finalized at turn completion; interrupted servers leave value-free placeholders. Proposals requiring redacted parameters are rejected and must be completed in the application. Existing transcripts are not migrated. See [transcript redaction](docs/DESIGN.md#transcript-redaction-secured-data-15). The dependency upgrade stays draft until RC.7 is published; local verification: ```powershell .\mvnw.cmd -q verify "-Dqqq.snapshot.version=4.1.0-RC7PRE-SNAPSHOT" ``` ## Host audit attribution (X1/Y1) Use QQQ **4.1.0-RC.6 or later**. The QBit explicitly denies every assistant configuration table, process, report and widget to the `assistant` channel; runtime initialization fails if an assistant object is exposed. The same human can still administer it through Next UI (`next-ui`). Core's security-surface deny patterns remain in place. Enable the audit client field in the host's core audit provider **after adding the backing column**: ```java new AuditsMetaDataProvider().withClientChannelField(true) .defineAll(qInstance, backendName, null); ``` Add nullable `audit.client_channel VARCHAR(100)` using the host's migration and column-naming conventions. For kof22 this requires a feature-named changeset under `changesets/features/`; this QBit does not migrate the host's audit table. The actor remains the signed-in human; `clientChannel` distinguishes `assistant/`, `assistant/scheduled/` and `next-ui`. Apply the X1/Y1 DDL in [UPGRADE.md](UPGRADE.md) to widen the existing `assistant_tool_call.audit_href` column to TEXT for encoded filters. Successful record writes store `assistant_tool_call.auditHref` as an `/app/audit` filter link over `recordId`, `auditTable.name` and `clientChannel`. The human opens this link with their UI permissions; the assistant cannot read audits by default. Without the audit channel field, the QBit leaves `auditHref` empty. Confirmed scheduled actions use their original turn id when the human approves. ## RAG setup Knowledge retrieval is optional. The vector index stores **no record content**: only source/table/key/field/chunk identifiers, SHA-256 hashes and embeddings. Indexing reads through the UI API as the clicking administrator. Retrieval re-reads each hit through that same API as the asking user, silently filters 403/404 results, applies `get_record` field exposure/masking/redaction and returns fresh snippets with `/app/...` citations. `search_knowledge` follows `allowedTools` and is offered only with an enabled source the user may read in the UI. 1. Register the OpenAI-compatible provider module and configure the embedding endpoint through the existing provider settings/URL allowlist. Discover or add a model, set **Model kind = EMBEDDING** and its dimensions. Known discovery defaults are `bge-m3` (1024) and `nomic-embed-text` (768); these remain editable. CHAT models cannot be selected for embedding, and EMBEDDING models cannot answer chat turns. Each embedding batch contains at most 64 inputs. 2. Use the default `InMemoryVectorStore` for tests/small hosts, or add `com.kingsrook.qbits:qbit-assistant-rag-pgvector` at the QBit's version for persistence. The host supplies its JDBC driver and DataSource; this module does not depend on QQQ RDBMS internals. 3. For pgvector, have the DBA install the extension once in the chosen database: `CREATE EXTENSION IF NOT EXISTS vector`. Give the application a distinct schema per QQQ instance. Configure the store before producing the QBit: ```java PgVectorStore vectors = new PgVectorStore(dataSource, 1024) .withSchema("assistant_rag_my_instance"); vectors.ensureSchema(); config.withVectorStore(vectors); config.withUiApiBaseUrl("http://127.0.0.1:8000"); ``` The UI API URL is this server's own listening address. Configure it explicitly so Index now works before any chat request has taught the runtime its local port. The store creates a content-free `assistant_rag_chunk` table and an HNSW cosine index. Its fixed dimension must match every source using the store; different models are searched separately. No credentials go in source rows. See the [pgvector installation/index documentation](https://github.com/pgvector/pgvector) and the reference DDL in [schema-postgres.sql](docs/schema-postgres.sql). 4. Open **Assistant → Knowledge sources** as an `assistant.admin`. Add the table, comma-separated text fields, optional title field and QQQ query-filter JSON, embedding model, and chunk size/overlap (defaults 1500/200). Tables with AI data access NONE, `assistant_*` tables, and fields with NONE/METADATA_ONLY exposure are refused. MASKED fields are masked before embedding and in snippets. 5. Select **Index now**. Review makes no embedding calls; Result reports records seen, chunks embedded, unchanged, removed and errors. Re-running unchanged content sends no embeddings. A complete successful scan removes disappeared records; failed scans preserve unvisited entries. **Clear index** removes only that source's vectors. Changes after indexing are read fresh, but semantic ranking becomes current only after re-indexing. Embedding usage has purpose `EMBED`, counts toward budgets, and observes the kill switch and provider concurrency limits. PHI sources and queries in PHI conversations require an eligible embedding provider; the eligibility flag is an operator assertion, as for chat. Automatic budget downgrades cannot change an embedding model's vector space, so an incompatible downgrade stops the call. Scheduling is deferred to P-g. Its designated-user runner can call `RagService.index` within that user's authenticated context; P-f creates no system session, stored admin credentials, scheduler or background content crawler. Data tools call the QQQ UI API through `UiApiToolbox` and `UiApiGateway` with the signed-in user's session. The UI enforces permissions and record security; the assistant further narrows access and caps and redacts returned values. Data tool results reach the model wrapped as untrusted data. Only record-free navigation and help use the core AI tool kernel. ## Requirements - Java 21. - **QQQ 4.1 with the AI tool kernel (QRun-IO/qqq#1078) and the developer-panel contract (QRun-IO/qqq#1076).** The first QQQ 4.1 RC that contains both is the minimum. `qqq-middleware-javalin` is needed for the routes. Until that RC exists, build against a local QQQ build. See [Build](#build). - A backend for the assistant's tables, e.g. the app's RDBMS backend. - For Claude: an Anthropic API key held in the application's secret store. ## Install ```xml com.kingsrook.qbits qbit-assistant ${qbit-assistant.version} com.kingsrook.qbits qbit-assistant-provider-anthropic ${qbit-assistant.version} ``` ```java AssistantQBitConfig config = new AssistantQBitConfig() .withBackendName("rdbms") // where the assistant_* tables live .withTableMetaDataCustomizer(snakeCaseCustomizer) // optional: backend names for your schema .withModelProvider(AnthropicModelProvider.fromEnvironment()) // fallback while no provider row exists .withProviderType(new AnthropicProviderType()); // offered on the Providers settings screen new AssistantQBitProducer().withQBitConfig(config).produce(qInstance); ``` `produce` does the following: - Registers the QBit. - Adds the tables, the `assistant_user_id` security key type, the `assistantRetention` and **Test connection** processes, and the **Assistant** app with its home. - Builds the runtime. - Registers the `/qqq/v1/assistant/*` routes when Javalin is on the classpath. - Registers the record Developer-screen panel when QQQ core has the developer-panel contract. Create the tables in your database: see [Tables](#tables). Then grant the permissions. ### The API key The key never goes in config, tables or logs. It lives in the application's secret store and reaches the server as an environment variable (for kof22: Infisical, then an ExternalSecret, then the pod env). - **Provider settings rows** (recommended) name the variable: `assistant_provider.apiKeyEnvName`, default the type's own (`ANTHROPIC_API_KEY`). The server reads the variable when it builds the client. - Without a row, the provider registered in code is used: `AnthropicModelProvider.fromEnvironment()` reads `ANTHROPIC_API_KEY`, and `AnthropicModelProvider.builder().apiKey(valueFromYourSecretStore).build()` takes the key from your own secret store (it also accepts `"${env.NAME}"`). The provider's `toString` never contains the key. Its errors carry only the HTTP status and the API error type. ### Starter defaults Include `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql` (shipped in the jar) after your assistant DDL. A new installation then has a policy row, a default profile with four starter skills, a $50/month instance budget, current Anthropic prices and a disabled example scheduled task. Every row is insert-only, so administrators can edit them freely. The home's **Setup** card shows what is set up and the next step for the rest (usually: add a provider). ## Provider settings (the Providers table) Admins set up providers on the **Providers** screen (`assistant_provider`), a standard QQQ table: | Field | Meaning | |---|---| | Name, Type, Model | The type is one of the registered provider types (`withProviderType`; Anthropic now, others plug in the same way). The model list comes from the type and the price table. An empty model uses the policy's main model. | | Enabled, Default | The assistant uses the default enabled provider, else the first enabled one. Marking one default unmarks the others. With no enabled row, the provider registered in code is used. | | API Key Variable | The **name** of the environment variable that holds the key, never the key. Only allowed names are accepted: each registered type's default plus `allowedApiKeyEnvNames`. A pasted key (or `DATABASE_PASSWORD`) is refused and nothing is stored. | | API Key | Read-only, computed on every read: `Set • …abcd (from ANTHROPIC_API_KEY)` or `Not set (...)`. It shows the last four characters at most. No read path (screen, API, query, export) returns the key. | | Base URL Override | Empty uses the provider's own API. Any other URL must be listed in `allowedBaseUrls`, so a row cannot send the key to a host of an admin's choosing. | | Monthly Cost Cap (USD) | When this provider's cost this month (UTC) reaches the cap, new requests are refused (429 `BUDGET`) and a running answer stops before its next model call. | | Max Output Tokens Per Request, Max Concurrent Requests | Caps one model call's output, and how many calls run at once on this server. | | Last test | Status, time, latency, the model that answered, and the error text, from **Test connection**. | **Why a reference and not an encrypted column.** QQQ core has no encrypted or secret field type. The key-provider and cipher work in the QQQ 4.1.0-RC.6 batch (QRun-IO/qqq#1123, #1107) encrypts backup artifacts, not table columns, and is not released. The design spec (§2.8) also says keys never go in tables. A reference keeps the key in the secret store that already holds it, so rotating it there is the "replace key" step, and the database never holds one. **Test connection** is a record action on a provider (select one or more). It is a standard streamed ETL: Review lists what will be called and anything wrong with the row (missing key, disallowed variable or URL) before any call. Result has one line per provider: `connected to claude-sonnet-5-5 in 812 ms`, or the error with its kind and HTTP status. It makes one short live call (a 1-sentence prompt, at most 32 output tokens, effort low), saves the result on the row, and records the call in `assistant_usage` with purpose `TEST`. ## Health checks The producer registers dynamic central checks in component `assistant`, category `AI`: | Name | Kind | Interval | Timeout | | --- | --- | --- | --- | | `assistant.provider.` | DEPENDENCY | 300s; 60s for LOCAL providers | 10s | | `assistant.model.` | DEPENDENCY | 21600s (6 hours) | 30s | Suppliers include enabled provider/model rows and refresh with the central registry. Provider checks reuse `AssistantProviderType.checkHealth`. Unsupported probes use circuit-breaker state only when less than ten minutes old, otherwise return UNKNOWN. Model checks reuse `ConnectionTest` (embeddings for embedding models), respect the provider's monthly cost cap and the kill switch, and leave manual `lastTest*` fields unchanged. A capped check returns UNKNOWN, `skipped: cost cap`, even on demand. The automated runner records usage under its own owner key. Provider caps use the existing shared accounting projection without access to other users' usage records. Usage purpose is a string, so health diagnostics reuse `TEST` (not a new `HEALTH` enum). All checks honor their deadlines; messages and details omit provider error text, credentials, endpoints, prompts and completions. The LOCAL circuit breaker consults central results less than 60 seconds old before its lazy probe. UP/DEGRADED avoids a duplicate probe, DOWN rejects the call, and UNKNOWN or stale/missing results retain the existing probe fallback. The breaker and manual Test connection actions remain available. This integration uses the core health SPI released in QQQ 4.1.0-RC.8. The POM pins that release; verify with `./mvnw -o verify`. ## The app home The **Assistant** app's home shows configuration, usage and cost read as the viewing admin. A generic status card summarizes the central runner's latest results and links to System Health filtered to `assistant`; it shows UNKNOWN before results are available. Health history and check lists live in System Health. - **Configuration and usage:** default provider, key status, the 7-day error rate of model calls, cost and requests this month against the instance's monthly limit. - **Providers:** one row per provider: type, model, masked key status, cost this month against its cap, limits, and any problem with its settings. - **Usage, last 30 days:** bars of model requests per day, totals (conversations, requests, tokens in and out, cost) and the last 7 days in words. - **Costs this month:** month to date against the policy limit and each provider cap, then by model. Cost is tokens times the model's price from **Model prices**. - **By user, this month**, **Top tools** (calls, failures, average time) and **Recent failures** (model calls and tool calls, linked). The nav lists the home and the main tables: **Providers**, **Conversations**, **Model prices** and **Policy**. Messages, turns, tool calls, feedback and model calls are child lists on a conversation; model calls are also listed on their provider. Retention and Test connection are table and record actions, never nav entries. ## Model prices `assistant_model_price` holds USD per million tokens per provider type and model: input, output, cache read, and cache writes (5 minute and 1 hour). Empty cache prices default to 0.1x, 1.25x and 2x the input price. The reference DDL seeds the Anthropic list prices of 2026-10. Edit a row to change what later calls cost; past rows keep the cost they were recorded with. A model with no row uses the `pricing` config map, else costs 0. ## Configuration | Setting | Default | Meaning | |---|---|---| | `enabled` | `true` | Per-app switch. The environment variable `QQQ_ASSISTANT_ENABLED=false` also turns it off. | | `backendName` | (required) | Backend of the `assistant_*` tables. | | `providerName` | `anthropic` | Which registered provider to use. | | `models` | Sonnet 5.5 at effort `medium` | `mainModel`, `mainEffort`, `mainMaxOutputTokens` (16000). The escalation (Opus 5.5, `high`) and summary (Haiku 5.5) slots are reserved for phase 2. Model ids are the exact strings `claude-sonnet-5-5`, `claude-opus-5-5` and `claude-haiku-5-5`. | | `defaultExposure` | `VALUES` | The kernel's default exposure. Use `METADATA_ONLY` for high-sensitivity apps. | | `maxToolCallsPerTurn` | 25 | Tool calls allowed in one answer. | | `maxParallelToolCalls` | 4 | Independent read tools that run at the same time. | | `maxRowsToModel` / `maxCellChars` | 50 / 500 | Caps on what the model sees (the kernel applies them). | | `maxTurnSeconds` | 300 | Wall-clock limit for one answer. | | `budgets` | none | `userDaily{Tokens,CostMicros}{Soft,Hard}` and `instanceMonthly{Tokens,CostMicros}{Soft,Hard}`. A soft limit adds a warning. A hard limit refuses new turns (429) and stops a running turn after its current step. Tokens counted are uncached input, cache writes and output. Cost is in micro-USD. | | `maxTurnsPerUserPerMinute` / `maxConcurrentTurnsPerUser` | 10 / 1 | Rate limits. | | `conversationRetentionDays` / `toolResultRetentionDays` / `approvalRetentionDays` | 90 / 30 / 365 | Used by `assistantRetention`. `approvalRetentionDays` is how long a pending action (a proposed change and what became of it) is kept after it was decided or lapsed, whether or not it belongs to a conversation; one in a conversation also goes when its conversation does. | | `pendingActionTtlSeconds` | 900 | How long a proposed change waits for the user's confirmation before it lapses. | | `policyCacheSeconds` | 30 | How long the settings rows are cached. **Tool calls and confirmations do not use this cache for the policy row**: the kill switch, the allowed tools, the row cap and the redaction switch are read fresh on every tool call and every confirmation, so switching a tool off takes effect on the next call. The cache still applies to what a turn reads when it starts (model, budgets from the policy row) and to the data access rows, profiles and providers: a table narrowed in `assistant_data_access` is narrowed at once on the server that saved the row and within this many seconds on the others. Without a policy row the defaults declared in code apply (every registered tool allowed, still narrowed by data access and the user's own permissions); a row only narrows them. | | `retentionSchedulerName` | none | Runs `assistantRetention` daily on that scheduler. | | `ownerSecurityKeyType` | `assistant_user_id` | Key type of the owner record locks. | | `pricing` | Anthropic list prices, 2026-10 | USD per million tokens per model, used for cost and budgets. | | `allowedWidgetNames` | none | Dashboard widgets `render_widget` may render. The kernel denies every widget not on this list. This config list is the **only** way to allow a widget: QQQ widget meta-data has no supplemental hook, so there is no per-widget "assistant-safe" flag. Allow only widgets that render the same data for every user who can see them. | | `hiddenWidgetNames` | none | Widgets that are never rendered, even when allowed. | | `customRedactionPatterns` | none | Extra regular expressions redacted from tool results and from the input kept in the tool-call log. | | `killSwitchPollMillis` | 2000 | How often a running turn re-checks the kill switch while it waits on the model. | | `providerTypes` | none | `withProviderType(...)`: the types the Providers screen offers (`AnthropicProviderType`). | | `toolFamilies` | none | `withToolFamily(...)`: a tool family (`tools.registry.ToolFamily`) of the host or of another QBit. Its tools join the assistant's own in the tool registry when the QBit is produced, so call it before that: a call after the QBit is produced throws `IllegalStateException` (it used to be ignored). This is the only way a tool gets in from outside the QBit: a family class that is only on the classpath is never registered. The runtime refuses to start when two tools share a name, and logs the registered tool names once. | | `allowedApiKeyEnvNames` | none | Extra environment variable names a provider row may name as its key. Each type's default is always allowed. | | `allowedBaseUrls` | none | Base URL overrides a provider row may use. The type's own URL is always allowed. | | `secretResolver` | `System::getenv` | How a key variable name becomes the key (a host secret store, or tests). | | `providerTestTimeoutSeconds` | 30 | How long Test connection waits for the model. | | `homeMaxRows` | 20000 | Rows per table the app home reads for its period; it says when totals are partial. | | `tablePathPrefix` | `/app` | The front of the links the app home makes to tables and records. | | `emailExternalApproved` | `false` | Stands in for the EXTERNAL approval rule (not built yet): scheduled-task result email is sent only when `true`. | | `emailRecipientResolver` | owner id if it is an address | Hook: task owner user id to email address. | | `attachments` | on; 10 MB per file, 25 MB per question | Files attached in the panel (see [Attachments](#attachments)): `enabled`, `maxFileBytes`, `maxTurnBytes`, `maxFileChars` (50,000), `maxTurnChars` (150,000), `maxPdfPages` (50), `previewRows` (20), `maxImageBytes` (5 MB), `maxImageDimension` (8000 px). | At runtime, an admin can override the kill switch, the main model and effort, and the budgets in the single **`assistant_policy`** row. A running turn notices the kill switch in two ways: - It re-reads the switch before every model call and tool step. - A watcher polls it every `killSwitchPollMillis` while the turn waits on the model. When a turn sees the switch on, it cancels every turn running on that replica. If the policy row cannot be read, the switch counts as **on**: it fails closed. ## Attachments The panel uploads a file with core `POST /qqq/v1/uploads` and sends its ref with the question as `pageContext.attachments` (`[{uploadRef, fileName}]`, at most 5). The assistant then: - reads the upload as the signed-in user, through core `UploadRefs` and `StorageAction`, and only the user's **own** upload (a jobs administrator's wider upload access is not used); the bytes stay in memory, within `maxFileBytes` and `maxTurnBytes`; - sniffs the type from the bytes, never from the name or the browser's content type. It reads text and Markdown, JSON, CSV, TSV, Excel `.xlsx` (first sheet), PDF and PNG/JPEG/GIF/WebP images. Executables, archives (ZIP, gzip, 7-Zip, RAR...), legacy Office files, `.docx`/`.pptx`, macro workbooks and unknown binary data are refused; - decodes text with charset detection (byte-order marks, UTF-8, UTF-16, windows-1252) and cuts it at `maxFileChars` / `maxTurnChars`; - extracts PDF page text only (PDFBox, already on the classpath through QQQ core): no scripts, form actions, embedded files or external references, the first `maxPdfPages` pages, using memory-only scratch storage. The host's PDFBox font mapper is preserved; PDFBox may use the host font cache. Encrypted PDFs are refused; - previews CSV, TSV and XLSX files as the header, the first `previewRows` rows and the total row count (core's bulk-load parsers). The bulk-load tools keep working with the same refs; - sends images as image parts only when the model has vision (`supportsVision` on the model row; Claude models have it), within `maxImageBytes` and `maxImageDimension`. ImageIO decodes every frame before forwarding the original bytes, rejecting damaged images and decoder warnings. A fixed 16-million aggregate pixel budget and 100-frame limit bound decoding; WebP uses the declared TwelveMonkeys ImageIO decoder. Unsupported decoder variants produce a per-file notice. Otherwise the user is told the current model cannot read images. Extracted content goes into the question's user message, inside an untrusted block, after both cached system blocks: ``` ...content; delimiter-like tags inside it are escaped... ``` The system prompt tells the model the block is data, never instructions. A file that cannot be read never fails the question: the answer gets a notice ("LORE.txt could not be read: ...") and the model gets a `status="not_read"` block. Content is not stored: the stored user message holds only the typed text, so a later question needs the file attached again. Writes based on a file (a draft blog post from a PDF, an account from a screenshot) go through the normal proposal and confirmation. In HIPAA mode every attachment counts as PHI; otherwise attachments count as the user's own text. `GET /config` reports `features.attachments` and, when it is true, `limits.maxAttachmentBytes`, `limits.maxAttachments` and `limits.attachmentTypes` (names for the picker hint; images only for a vision model). Attachments need durable jobs with an uploads storage table in core. ## Permissions | Permission | Grants | |---|---| | `assistant.hasAccess` | Use the panel. It grants **no data access**: the assistant reads only what the user can read anyway. | | `assistant.admin` | The Assistant app (home, settings and audit tables), Test connection and the retention process. | There is no assistant-specific data permission. Everything the assistant reads or proposes is checked against the user's normal table permissions and record locks. The QBit never uses a system session (an ArchUnit test enforces this). When the QBit is enabled, it registers the supplemental instance meta-data `assistant`. The v1 `/metaData` response publishes it as `supplementalInstanceMetaData.assistant: {"available": true, "version": 1, "basePath": "/qqq/v1/assistant"}`. The Next UI only probes `/config` when that object is present. ### What the assistant may do with a table The assistant may read what the signed-in person may read, and propose any change that person may make themselves. Nothing needs opening first: that default is declared in code, and the database only ever narrows it. | What decides, in this order | Where | Can it widen? | |---|---|---| | The assistant's own tables and processes (`assistant_*`) are never available to it | a pin in the QBit's code | No, by nothing. | | A table whose metadata keeps it from AI (AI exposure NONE) is never available | table metadata | No. | | The application's code: a pin for the object, else the default | `AssistantQBitConfig.withTableAccess(table, mode)` / `withProcessAccess(process, mode)`, else `withDefaultTableAccess` / `withDefaultProcessAccess` (both `READ_WRITE`) | It is the ceiling. | | An administrator's row on **Assistant > Data Access** | `assistant_data_access` | No. `READ` makes a table read-only for the assistant, `NONE` hides it; `READ_WRITE` is "no restriction". A row never raises what the code says. | | The person's own permissions, record locks and validation | the UI API, on every call | They are the boundary. | `READ_WRITE` grants nothing. The write tools (`create_record`, `update_record`, `delete_record`, and the save tools of the optional extensions) are offered to a person only when the UI API says that person may create, edit or delete records of some table; a person who may only read is not offered them. A change is only ever proposed: the person confirms it on a card, it then runs as that person, and a change they may not make is refused with the status and message the screen gives. The **Data access** card on the Overview shows, for the person looking, the access of every table and what decided it. To keep the assistant read-only, or to open only some tables, say so in code (`withDefaultTableAccess(DataAccessMode.READ)`, then `withTableAccess("orders", DataAccessMode.READ_WRITE)`); see [UPGRADE.md](UPGRADE.md). ### What the assistant may do with a process The assistant may run a process the signed-in person may run themselves, after that person confirmed the run on a card, when the application's code named that process as one it reviewed for the assistant (`AssistantQBitConfig.withReviewedProcesses`, below). An application that named none has exactly two kinds of runnable process: a table's own bulk edit and bulk delete, which act on records of a table the person may already change through the assistant. Nothing in the database can widen any of this. | Tool | What it does | |---|---| | `list_processes` | The processes this person may run through the assistant, from the person's own metadata (`GET /metaData`, asked as them). | | `describe_process` | One of them: its inputs as its screens declare them (name, label, type, required), the fields that cannot be set through the assistant, the records it needs, where it stops for the person. | | `run_process` | Proposes a run. It checks the inputs against the declared fields first (an unknown input, a wrong type and a missing required input are refused before a card exists; a value is never made up), finds the records as the person, and stores one pending action (`actionKind` `process_run`). The card shows the process, every input, what the first screen says, the records (how many, of which table, which) and the steps that stop for the person. Nothing runs until the person confirms. | | `get_run_status` | Where a run stands (a run still working or stopped, or a background job). It reads; it never moves a run on. | The four are offered to a person only when they may run at least one process through the assistant. A person is never told about a process they may not run: it is not listed, and by name it is refused in the same words as a process that does not exist. **Never runnable through the assistant**, whoever asks and whatever a setting says (`tools.process.ProcessRunnability`): 1. a process denied to the `assistant` client channel: by its author (`QPermissionRules.withDeniedClients(Set.of("assistant"))` on the process), by the application (`ClientChannelsMetaData`, by name or pattern) or by core's default patterns. Core refuses every operation on it beneath the assistant as well; 2. the assistant's own processes (a pin in code that nothing lifts), and any process whose data access is not `READ_WRITE`: `withProcessAccess(name, DataAccessMode.NONE)` or `withDefaultProcessAccess(DataAccessMode.NONE)` in code, or an administrator's row; 3. a hidden process, except a table's own bulk edit and bulk delete (the record list offers those); 4. a process no screen offers (in no app, and no table's action): an operation the application's screens call for themselves; 5. a process on a table the assistant may not change (data access `READ` or `NONE`, or a table kept from AI); 6. a process with a screen the assistant cannot present: a file upload, a required password, a file download, a bulk-load mapping form, a folder picker, an embedded widget, custom content, or a component the assistant does not know. The person is told why, and where to run it themselves; 7. a process that is not on the application's reviewed list, unless it is a table's own bulk edit or bulk delete. **The reviewed list decides what runs, when the application runs.** An application names the processes it reviewed for the assistant in its code, as names or as a file on its classpath: ```java new AssistantQBitConfig() .withReviewedProcessesResource("assistant-runnable-processes.txt") // one process name per line; # comments and blank lines are ignored // or: .withReviewedProcesses(List.of("closeMonth", "sendDigest")) ``` | The application registered | The assistant runs, for a person who may run it themselves | |---|---| | no list | a table's own bulk edit and bulk delete, and no other process | | a list (an empty one is a list) | exactly the listed processes, and those two | The list names; it grants nothing. A listed process is still never run when one of the rules 1 to 6 keeps the assistant from it, when an administrator's row narrows it, or for a person who may not run it. An administrator's row, and anything else in the database, adds no process. **An input is only a field a screen asks a person for.** The inputs of a process are the form fields of its screens, taken from the person's own metadata. A field whose name the process keeps for its own working is not an input: the inputs its backend steps declare, and the fixed set `tools.process.ProcessControlNames.RESERVED` (the names QQQ's streamed and bulk steps and its process runner read from a run's values, and the names the process operations carry a run's records in). `describe_process` lists such a field under `cannotBeSetThroughTheAssistant`, a value for it is refused before a card exists, and the process stays runnable for its other fields. A process that requires such a field is not run through the assistant, and the person is told where to run it. **The confirmed run is the person's.** It is started through the UI API with the confirming request's session, so the application decides with the person's permissions as they are then (a permission revoked after the proposal refuses the run), and what it answers is what the person is told. **A run is over exactly the records its card counted.** The records are fixed when the run is proposed: the ones named, or the ones a filter finds for the person then, kept by primary key. A filter that finds more than 500 records is refused, for the person to narrow. The confirmed run is sent those primary keys, never the filter, and does not start when one of them is no longer there for the person. - *A durable job*, when the application says the process may run as one (`durableJobEligible`): the confirmed run creates the job (`POST /processes/{processName}/jobs`). Core creates a job launched from the `assistant` channel waiting for the person and refuses to start it from that channel, so the assistant never starts a job: the person starts it on its job record, from their own session. The assistant sends no expected row count, so core's "small assistant jobs start by themselves" setting never applies to a job it created. Where the application does not offer the operation that creates a job, such a process is not run through the assistant at all: it is never run on confirmation instead. - *Otherwise* the process's own `init` and `step` operations. The run goes on until it ends or reaches a screen with something for the person that the card they confirmed did not show: a review (nothing is stored yet), a form (also when no field is required: a step may have computed its values), values to read, a text such as "are you sure", or a list of records. It stops there. Going on is a new proposal and a new card (`run_process` with the run's reference) that shows what the screen shows: its text, its fields with their values, the first ten records of its list, less every field the assistant may not see the values of. What the card says about the run is what the server kept, never what the model passed. Only a screen with nothing to show is passed. The screen a run starts at is on the first card (its text, and the defaults the person did not replace). A card goes on only with the run as it stood when the card was made: once the run moved (another card went on with it, or validated it again), an older card is refused. A run that stops at a list of records the assistant cannot show does not go on through the assistant. A confirmed run never keeps its pending action `RUNNING`: the action ends when the job is created, or when the run ended, stopped, failed, or was still working after the time the confirmation waits (15 seconds). Where a run stands after that is asked with `get_run_status`. A run that stopped or is still working is kept in the memory of the server that started it, for an hour, under a reference only its person can use; a restart loses it, as it loses the run. Long work belongs in a durable job. What a result carries: the process's own summary lines (status, count, message), what a screen the run stopped at shows, and the application's own message for a refusal or a failure. All of it is text of the application, which can repeat what a record says: it is redacted like every tool result, and a model reads it only inside the data envelope every tool result uses (``). That holds for a result, for the lines of a card, for the summary a later request keeps of a process step, for what a later request is told became of a confirmed run, and for the thread's own message about it. A person reads the same words as they are. **Noticing drift, in the application's build.** The registered list is what runs. `RunnableProcessReview` is how an application notices that the list and the application drifted apart, in a test of its own: ```java @Test void theProcessesTheAssistantCanRunAreReviewed() throws Exception { // person: a session with the permissions of the roles to review, or null for a person who holds every permission RunnableProcessReview.assertReviewed(qInstance, person, Path.of("src/main/resources/assistant-runnable-processes.txt")); } ``` The test fails, naming each process, when a process could be run through the assistant and is not in the file (it is not runnable until somebody adds its line, or says in code that the assistant stays away from it: the ways in the list above); when a line names a process that is gone or that a rule keeps the assistant from; and when the file is not the list the application registered (so a reviewed file that was never registered, which opens nothing, is noticed too). A table's own bulk edit and bulk delete need no line. `RunnableProcessReview.reviewableProcesses(qInstance, person)` returns what could be listed, `runnableProcesses` what runs with the registered list, and `render(list)` the file's text, for the first commit. All of it is what versioned code allows; it needs the instance with the QBit produced, and no server or database. An application that never adds this test is no more open for it: without a registered list only the two bulk actions run. ### Record locks on the assistant's own tables Conversation data belongs to its user. Every user-owned table has a record lock on `userId` with key type `assistant_user_id`: - The panel routes add the user's own id to their session, so users only ever see their own rows. - Admins see everyone's rows in the Assistant app through the key type's all-access key, `assistant_user_id_all_access`. Grant that key to `assistant.admin` holders from your authentication customizer: ```java // in your QAuthenticationModuleCustomizerInterface.customizeSession(...) AssistantSecurity.applySessionKeys(qSession, assistantConfig); ``` ## What a host's code can call Every tool call goes through `engine.ToolCallService`, and a host cannot run a tool or a change around it: the ways to do so do not compile from outside the assistant's packages. | A host or another QBit can | With | |---|---| | Register its own tools | `AssistantQBitConfig.withToolFamily(ToolFamily)`, before the QBit is produced; implement `tools.registry.AssistantTool` (`execute(ToolCallRequest)`, and `executeConfirmed(ConfirmedToolCall)` for a tool that proposes). | | Run one tool call for a signed-in person, logged | `runtime.getToolCalls().executeAndLog(new ToolCall(person, id, name, input), conversationId, hipaaRoute)` | | Ask whether a person is offered a tool now | `runtime.getToolCalls().offers(person, toolName)`, or `offersAny(person, toolNames)` for several as one question | | Make one of the assistant's own reads as a person (`list_tables`, `describe_table`, `count_records`, `get_record`; never a change, no log row) | `runtime.getToolCalls().readContext(person, timeout)`, then `UiApiToolbox.read(context, ...)`. The service checks the person, assistant access and the kill switch, and reads the policy itself. | | Confirm or cancel a person's pending action | `runtime.getToolCalls().confirm(person, id)` / `cancel(person, id)` (inside this repository only `AssistantApi` does; the routes are `POST /actions/{id}/confirm` and `/reject`). | | Name the signed-in person | `SignedInPerson.ofRequest(context)` in a route after session setup (it forwards only the credential the session was resolved from), or `SignedInPerson.ofSession(qInstance, session)` where there is no HTTP request: the authentication module resolves the session's own id, the person is made only when that is the user the session names, and the person is the session the module resolved (what it may do is what the module says now, not what the object passed in says). | What is not available outside the assistant's packages: the tool registry, the UI API gateway and the tool families of the runtime (no public getter); running a call without its log row or writing a row without a call (`execute`, `log` and `withdraw` are package-private); constructing a `ToolCallRequest`, a `ConfirmedToolCall` or a tool `Context` (no public constructor; their `of(...)` factories need the service's permit, which only the service has, and refuse without it; there is no way to make a context from a session id, a gateway and a policy of the caller's choosing). A tool is handed its request by the service, so a tool that runs knows the checks ran first. This is a compile-time guarantee for code in other packages; it does not stop code that declares itself inside the assistant's own packages or uses reflection. These are guarantees of the service the runtime built, `runtime.getToolCalls()`. The service's constructor is public (the runtime is in another package), so other code can construct a service of its own, with a registry, a store and a gateway of its own choosing. Such a service checks what those let it check; it does not run the runtime's checks, and it is not a way around them: it cannot be given the runtime's registry, gateway or toolboxes, and whatever it calls is still the UI API as a signed-in person, with that person's permissions. The single decision of a pending action cannot be replaced by a subclass of the store (`AssistantStore.decidePendingAction` is final). ## Data policy What reaches the model provider: - the tool definitions - a fixed safety prompt - an app digest (labels and descriptions, no data) - the user's name, timezone and readable-table list - the conversation's text - tool results produced by the kernel. The kernel applies these rules to tool results: - Hidden fields, password fields and fields with a security lock are never sent. - Exposure `METADATA_ONLY` and `MASKED` fields are filtered or masked. - Rows are capped at 50 and cells at 500 characters. - Secrets such as keys, JWTs and card numbers are redacted. - The `assistant_*` tables are never visible to the model. Old tool results are cleared from the context of later turns; only one-line summaries remain. Stored in your database: - every message - every tool call: sanitized input, outcome, permission check, row count, duration, redactions, and the model-visible result. After `toolResultRetentionDays`, the result is cleared, stream events are deleted, and render payloads and record references are removed from stored messages. Summaries and answer text stay until `conversationRetentionDays`. - turn accounting: tokens, cache reads and writes, and cost - streamed events, kept for replay - feedback - pending actions: the proposed values, what the card showed of the record before (after the exposure rules), a version digest of the record, and the outcome. Kept for `approvalRetentionDays`. - daily usage. Logs never contain message content. ## HTTP API Base path `/qqq/v1/assistant`. Every request is authenticated through the app's normal session (cookie, bearer token or API key). Request bodies must be JSON. | Method | Path | | |---|---|---| | GET | `/config` | Feature probe: `{enabled, reason?, shortcut, model, limits, features}` (`features.attachments` with `limits.maxAttachmentBytes`, `maxAttachments` and `attachmentTypes`). A 404 means the QBit is not installed. | | GET / POST | `/conversations` | List your conversations (`?status=ARCHIVED`), or create one. | | GET / PATCH / DELETE | `/conversations/{id}` | Load with paged messages (`?before=&limit=`), rename / pin / archive, or delete. | | POST | `/conversations/{id}/turns` | Ask a question. The response is an SSE stream. | | GET | `/turns/{id}/events?after={seq}` | Replay a turn's events after a sequence number, then follow it live. | | POST | `/turns/{id}/cancel` | Stop an answer. | | POST | `/messages/{id}/feedback` | Rate an answer: `{rating: 1 or -1, reason?, comment?}`. | | GET | `/actions` | Your own pending actions, newest first (`?status=PENDING` for the ones still waiting for you, `?conversationId=`). | | GET | `/actions/{id}` | One of your pending actions: its state and what its card shows. | | POST | `/actions/{id}/confirm` | Confirm it: the stored change runs once, with your session. | | POST | `/actions/{id}/reject` | Cancel it: nothing runs. | The stream sends these events, each with a `seq`: - `message.start`, `text.delta` - `tool.start`, `tool.result` (with `render` and `recordRefs`) - `presentation` (`messageId`, `part`: summary, blocks and server-built GFM markdown) - `ui.navigate`, `notice` - `usage`, `message.end` - `error` - `ping` every 15 seconds. The stream ends after `message.end`. Thinking is never streamed. ### Confirmation cards (pending actions) A tool that changes something never changes it: it stores a pending action and the panel shows a card. Nothing runs until the user confirms the card, and no setting switches that off. - **What the card shows.** `actionKind` says what is proposed: `record_create`, `record_update`, `record_delete`, `process_run` (`changes` then holds the card's lines: the process, each input, the records, how it runs, where it stops; `processName` and `processLabel` name the process), or the name of the proposing tool (`save_analysis`, the bulk-load tools, a host's own). For a record change the card has the table and the record, and one line per field: an update shows the value before and after, a create the value, a delete what the record holds. The values before go through the same exposure rules as `get_record` (a hidden field is absent, a masked field is masked). `toolName` stays one of the three record tools for a record change, so a panel that only knows those still draws a working card. - **What was approved is what is written.** An update or a delete is proposed for the record as the user can read it at that moment; a record they cannot read is refused then, with the UI API's own answer, and no card is made. The proposal keeps a version of the record (a digest of the whole record, never its values). On confirmation the record is read again as the confirming user: if it is gone, may no longer be read, or changed in any field, the change is refused ("This record changed after the change was proposed. Nothing was changed.") and the record is left as it is. The UI API has no conditional write, so a change made in the instant between that read and the write is not detected. - **One decision.** A pending action is confirmed or cancelled once, by exactly one request, however many arrive together and on however many servers: the decision is a row in `assistant_pending_action_claim`, whose `pending_action_id` is unique. Keep that unique key. - **Expiry.** A proposal lapses after `pendingActionTtlSeconds`; expiry is evaluated when it is read and when it is decided. Expiry is itself a decision, made through the same claim: an action somebody confirmed is never marked expired afterwards, and is reported as what it is. - **A call that was cut short.** When a tool call runs out of time, is stopped or fails, the user is shown no card; what it proposed is withdrawn (marked `EXPIRED` and claimed) and what it would propose later is refused. - **Retention.** `assistantRetention` first ends a confirmed change that was left `RUNNING` for more than an hour (the server stopped while it ran) as `FAILED`, with "Outcome unknown: this change was started and its end was never recorded. Check the record before asking for it again." and a tool-call row; it can never be confirmed again. It then deletes pending actions older than `approvalRetentionDays` that were decided or lapsed. ### SSE behind a proxy (Traefik, nginx) - The responses carry `Cache-Control: no-cache` and `X-Accel-Buffering: no`. Do not compress or buffer `text/event-stream`. - Give the routes an idle timeout longer than the 15-second ping interval and a total timeout of at least `maxTurnSeconds`. For Traefik, raise the entrypoint's `respondingTimeouts` (`readTimeout`, `writeTimeout`, `idleTimeout`) or put the assistant routes on a router whose transport allows it. Don't add a buffering middleware to these routes. - A dropped connection does not stop the answer. The client resumes with `/turns/{id}/events?after=`. ## Tables All of these are standard, visible QQQ tables in the Assistant app. The model never reads any of them (AI exposure `NONE`). | Table | Holds | |---|---| | `assistant_conversation` | Conversations. | | `assistant_message` | Messages (`partsJson`). | | `assistant_turn` | Turn accounting. | | `assistant_turn_event` | Stream events, for replay. | | `assistant_tool_call` | The audit of every tool call. | | `assistant_feedback` | Feedback on answers. | | `assistant_pending_action` | Proposed changes waiting for the user, and what became of them. | | `assistant_pending_action_claim` | The one decision of a pending action (unique per action). | | `assistant_policy` | The kill switch and overrides. | | `assistant_usage_daily` | Usage rollups: per user per day, plus an instance row whose `userId` is null. | | `assistant_provider` | Provider settings: type, model, key variable (never the key), limits, last connection test. | | `assistant_model_price` | USD per million tokens per model, used for every call's cost. | | `assistant_usage` | One row per model call (a turn step or a connection test): user, conversation, turn, provider, model, status, latency, tokens, cost. Locked to its user like the other audit tables. | Table names follow the QRun-IO naming rule: lowercase snake_case with the `assistant_` prefix, the same as the physical tables in `docs/schema-postgres.sql` (the reference DDL). Field names are camelCase. Map them to your columns with a table customizer. Upgrading from 0.1.0-RC.1, which used camelCase table names and the `assistantUserId` key type: see [UPGRADE.md](UPGRADE.md). ## Build ```bash ./mvnw verify # against the committed QQQ version ./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a local QQQ build with #1078 + #1076 ``` Two test groups run in every build and fail it on any error: - the golden evals (JUnit tag `golden`): scripted conversations against a seeded fixture app - the permission red team (tag `redteam`). The **live** evals run the same golden tasks against Claude. They are skipped unless both `ANTHROPIC_API_KEY` and `-Dassistant.live=true` are set: ```bash ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am ``` ## Providers Add `com.kingsrook.qbits:qbit-assistant-provider-openai` at the same version as the QBit and register the adapter alongside any Anthropic adapter: ```java config.withProviderType(new OpenAiCompatibleProviderType()); ``` `OpenAiCompatibleProviderType` is in `com.kingsrook.qbits.assistant.provider.openai`. Its installed type id is `openai-compatible`; provider rows choose OPENAI, AZURE_OPENAI, OPENROUTER, OLLAMA, LM_STUDIO, VLLM, LLAMA_CPP or OPENAI_COMPATIBLE as their kind. - Local kinds use their localhost endpoint by default and need no key. Leave `apiKeyEnvName` empty. An auth proxy can use an explicitly allowed key env name. - Cloud defaults use `OPENAI_API_KEY`, `AZURE_OPENAI_API_KEY` or `OPENROUTER_API_KEY`. Values stay in the host's secret store. - An override must match an entry in `config.withAllowedBaseUrls(Set.of(...))`. Azure and OPENAI_COMPATIBLE have no default URL and always require an entry. Redirects are not followed. Azure uses the deployment model id and defaults `apiVersion` to `2024-10-21`. - **Discover models** adds a provider's Models child list; existing edits survive. Large cloud catalogs (over 20 models) start disabled. **Test connection** is available on both providers and models and records a TEST usage row. - A selected registered model must be enabled. With no model selected, the first enabled registry model wins; providers with no model rows retain legacy selection. Registry prices override type/model prices, then configured prices. Unpriced local models cost zero. Capabilities can be adjusted per registered model. For a local Ollama with no key (the live test ran against `llama3.3:70b-instruct-q4_K_M`): ```java config.withProviderType(new OpenAiCompatibleProviderType()); // provider row: type openai-compatible, kind OLLAMA, model llama3.3:70b-instruct-q4_K_M, // base URL empty (http://localhost:11434/v1) or an allow-listed override such as http://127.0.0.1:11434/v1 ``` Without any usable provider `GET /config` answers `enabled: false, reason: NOT_CONFIGURED` with a `message` that says what to configure, before any permission check. For Ollama behind a TLS proxy with a private CA and bearer authentication: ```java config.withProviderType(new OpenAiCompatibleProviderType()) .withAllowedBaseUrls(Set.of("https://llm.example.lan:8443/v1")) .withAllowedApiKeyEnvNames(Set.of("LOCAL_LLM_API_KEY")) .withTrustedCaBundleFile("local-llm", Path.of("/etc/app/llm-root-ca.pem")); ``` Set `LOCAL_LLM_API_KEY` in the server environment from your secret store. In the provider record choose kind `OLLAMA`, base URL `https://llm.example.lan:8443/v1`, `apiKeyEnvName = LOCAL_LLM_API_KEY`, and `tlsCaBundle = local-llm`. Only the names are stored. `withTrustedCaBundle("local-llm", pem)` also accepts one or more PEM certificates directly at startup. Invalid PEM fails configuration immediately. The named bundle adds to the JVM roots; hostname verification stays enabled. Leave `tlsCaBundle` empty to use JVM trust alone. Restart after changing a CA file. Default tests use localhost mocks. To exercise an existing development server, set `ASSISTANT_LIVE_OPENAI_BASE_URL` and `ASSISTANT_LIVE_OPENAI_MODEL`, optionally `ASSISTANT_LIVE_OPENAI_KIND` (default OLLAMA), `ASSISTANT_LIVE_OPENAI_KEY_ENV` (the variable name containing the key), and `ASSISTANT_LIVE_OPENAI_CA_FILE` (a PEM bundle path). Unset or unreachable endpoints, including TLS failures, skip the live test. Run: ```sh ./mvnw verify -pl qbit-assistant-provider-openai -am -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true ``` ### AWS Bedrock (Claude) Add `com.kingsrook.qbits:qbit-assistant-provider-bedrock` at the QBit version and register `com.kingsrook.qbits.assistant.provider.bedrock.BedrockProviderType`: ```java config.withProviderType(new BedrockProviderType()); ``` Create a provider row with type `bedrock`, kind `BEDROCK`, and the required AWS `region` (for example `us-east-1`). Leave `apiKeyEnvName` empty. The server uses the AWS default credential chain: environment variables, profiles and container credentials (ECS/EKS Pod Identity) work with the transitive dependencies. **For IRSA / web-identity credentials, the host application must add `software.amazon.awssdk:sts` to its runtime classpath**, matching the transitive AWS SDK version. The default chain picks it up when present; this adapter does not include STS or the full AWS SDK. Credentials are never stored in tables. Leave the base URL empty for the SDK's `https://bedrock-runtime..amazonaws.com` endpoint. Overrides, including VPC endpoints, must be listed in `withAllowedBaseUrls`. Model ids are Bedrock Claude ids or inference profile ids, for example `us.anthropic.claude-sonnet-4-5-20250929-v1:0`. The model must be available to the server's AWS role in that region. ### Google Vertex AI (Claude, Gemini) Add `com.kingsrook.qbits:qbit-assistant-provider-vertex` at the QBit version and register `com.kingsrook.qbits.assistant.provider.vertex.VertexProviderType`: ```java config.withProviderType(new VertexProviderType()); ``` Create a row with type `vertex`, kind `VERTEX`, required `region` (for example `us-east5`) and `cloudProject` (the GCP project id). Leave `apiKeyEnvName` empty. The server uses Google Application Default Credentials, such as `GOOGLE_APPLICATION_CREDENTIALS` or workload identity. Access tokens are refreshed when expired, held only in memory, and obtained afresh for each Gemini request. Models beginning `claude-` (for example `claude-sonnet-4-5@20250929`) use the Anthropic Vertex backend's `streamRawPredict` API. All other model ids use Vertex's OpenAI-compatible endpoint: ```text https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}/endpoints/openapi ``` Unprefixed ids, such as `gemini-2.5-flash`, are sent as `google/gemini-2.5-flash`; already-prefixed ids are preserved. Gemini requests omit `strict`, including when a tool opts in. An allow-listed base URL overrides the Claude API origin or the complete OpenAI-compatible base URL, according to the selected model. Use separate provider rows when an override needs different URL prefixes for the two APIs. Both cloud types default to CLOUD and expose a short static model catalog. **Discover models** returns those defaults; it does not call Bedrock's `ListFoundationModels` (which needs the control-plane SDK) or Vertex Model Garden. Health probes are unsupported; **Test connection** performs the existing one-shot completion. Model access and availability depend on the configured region/project. `hipaaEligible` is an administrator-recorded fact: a BAA and the associated legal and operational requirements remain owner responsibilities. The flag does not establish eligibility or implement PHI routing. The default build tests both clouds using localhost only, fake credentials, AWS binary event-stream framing, Claude SSE and Gemini Chat Completions, including engine/tool golden turns. No cloud credentials or live endpoints are required. ## License Apache 2.0. ## Email through qbit-email When the optional [qbit-email](https://github.com/QRun-IO/qbit-email) is on the classpath **and** produced in the instance, a finished scheduled-task run can be emailed to its owner. Without it nothing changes: `AssistantEmail.sendScheduledResult` returns `NOT_AVAILABLE`. The dependency is `optional`; every reference to it is in `AssistantEmailSender`. This integration uses qbit-email **0.1.0-RC.2** through its published `com.kingsrook.qbits.email.api` package. Scheduled tasks (P-g) are available, but the runner does not yet wire email delivery. Hosts can call `AssistantEmail.sendScheduledResult(qInstance, config, new ScheduledResultNotice(...))` after a run, supplying its owner, classification, absolute run link and explicit opt-in as `emailResult`. Persisting a task-level email opt-in and wiring the runner remain a separate integration step. Gates, in order (each returns a status and queues nothing): 1. the task opted in (`NOT_REQUESTED`); 2. qbit-email is installed (`NOT_AVAILABLE`); 3. `emailExternalApproved` is `true` (`NOT_APPROVED`). The EXTERNAL approval rule exists only in the spec, so this explicit flag gates it until the rule is built; 4. a recipient resolves (`NO_RECIPIENT`): `emailRecipientResolver` maps the owner's user id to an address; by default an owner id that already is an address is used. What is sent, one message per recipient, idempotency key `assistant:scheduled:`, linked record the run: - result classified PUBLIC (or none): template `assistant_scheduled_result` (NOTIFICATION, Markdown, registered on first send and never overwritten) with the task name, run time, a summary cut to 280 characters and a link to the run. The full model output is never emailed; - result classified above PUBLIC (core's `DataClassification`): only the link, through qbit-email's `email_secure_message` pattern. No task name, summary or output. The outbox result maps to `QUEUED`, `DUPLICATE`, `SUPPRESSED` or `REFUSED` (for example a link that is not absolute http(s)). Addresses and bodies are never logged. ## Data classification and HIPAA mode Classify application tables and fields in core's AI metadata (`AiTableMetaData` / `AiFieldMetaData`, QQQ 4.1.0-RC.6), or in the host configuration (a configured value wins when both are set): ```java import com.kingsrook.qqq.backend.core.actions.ai.DataClassification; AssistantQBitConfig config = new AssistantQBitConfig() .withDataClassification("patient", DataClassification.PHI) .withDataClassification("patient", "id", DataClassification.PUBLIC) .withDataClassification("patient", "email", DataClassification.PII) .withDataClassification("patient", "status", DataClassification.PUBLIC) .withHipaaMode(true) .withHipaaRetentionDays(2190) .withConversationEncryptionKeyEnvName("ASSISTANT_CONVERSATION_KEY"); ``` Set that environment variable through your secret manager to a base64-encoded, random 256-bit (32-byte) key. Supply only its **name** in config, never the key. The existing server-side `withSecretResolver` hook also resolves this reference. Startup validates the key. HIPAA mode defaults off; encryption can also be enabled with the key reference while HIPAA mode is off. The levels are core's `DataClassification` (PUBLIC < CONFIDENTIAL < PII < PHI); the qbit's former stand-in enum is gone. Unconfigured tables default to PUBLIC; a field falls back to its table unless explicitly overridden. A field subset uses its selected fields; unrestricted reads and record page contexts use the highest classification across the table and its fields. Returned columns and record-label fields also count: tools include a primary key even for subsets, so classify it explicitly if the host considers it safe for that subset. Cross-table results with unknown tables use the highest classification anywhere. Metadata-only tools remain PUBLIC. The assistant's own tables remain denied to tools. The default `ClassificationSource` reads core's metadata (`AiDataClassifications`, `AiTableMetaData`, `AiFieldMetaData`) merged with the configured map; configuration wins when both are set. Hosts can still supply `withClassificationSource(ClassificationSource)`. PHI is sent only to a persisted provider row explicitly marked `hipaaEligible`. Every model dispatch rechecks eligibility, including calls after tools and strict schema retries. Blocks end the turn with `PHI_BLOCKED` or `HIPAA_PROVIDER`; the PHI tool result is never sent to that provider. There is no automatic fallback. A conversation keeps its highest classification, so moving to a PUBLIC page does not make existing PHI history safe for a non-eligible provider. HIPAA mode also refuses non-eligible providers at resolution and when enabling settings rows, disables code-only fallback, requires encrypted conversation storage, and suppresses provider error details in logs and turn errors. The app home displays HIPAA mode and PHI retention. The eligibility flag records the operator's decision (including any applicable BAA); it does not establish one. Both modes audit each PII/PHI model call and every routing block in `assistant_data_audit` (admin read-only, owner-locked, on the conversation screen). PHI conversations use `hipaaRetentionDays` even with HIPAA mode off. A null value uses normal retention; tool-result/replay cleanup remains independently configured. See [UPGRADE.md](UPGRADE.md) for schema changes, plaintext migration, and key handling. ## Data-driven budgets Admins configure **Assistant > Budgets** for UTC DAY, WEEK (from Monday 00:00 UTC) or MONTH allowances in USD or TOKENS, scoped to INSTANCE, USER, ROLE (a permission, each holder counted separately), PROVIDER or MODEL. The rows are read at every question and before every model call, so a changed limit applies at once. A cost limit above `budgetCostLimitCeilingUsd` (default 1,000,000) is refused when saved. Provider and model scope values are registry record ids. Warnings default to 80%; at 100% the default action is DOWNGRADE, and the hard ceiling defaults to 120% (always STOP). Multiple budgets apply together and STOP wins. An explicit downgrade model is optional. Otherwise the cheapest enabled usable model is selected, preferring LOCAL providers. PHI never goes to a provider that is not HIPAA-eligible. No eligible target means STOP. Administrators can grant extra allowance or temporary unlimited use from the budget's Overrides child list, with a required reason and expiry; the session sets the grantor. Budget state changes appear once per scope/period/state in the Events child list, as panel notices and, through core QAlerts, bell alerts when an alerts sink is installed (logging otherwise). The Assistant home shows budgets, overrides, daily cost and tokens, and breakdowns by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and provider monthly caps continue to apply independently of these rows and overrides. See [UPGRADE.md](UPGRADE.md) for the required additive schema. ### No answer without a spending limit The assistant answers nobody for whom no spending limit is in force. A turn is refused before anything is stored and before any model call (HTTP 429, code `BUDGET`): "The assistant is not answering because no spending limit is in force. An administrator must set or switch on a budget (Assistant > Budgets) before it can be used." A tool call, an embedding request, the next model call of a running answer, the routing classifier's call and a scheduled turn are refused the same way. One model call is exempt, by decision: an administrator's **Test connection**, so a provider can be proven before a limit and people are put on it. It needs `assistant.admin`, sends a fixed prompt with no tools and nothing of a person or a record, asks for at most 32 output tokens, and is recorded as usage (purpose `TEST`). Restoring a limit restores the assistant with the next request. A spending limit is in force for a person when either holds: - a budget row is switched on, has a limit above zero and applies to them: one shared by everybody (INSTANCE; PROVIDER or MODEL for the provider or model in use) or their own (USER for them; ROLE for a permission they hold); - the code configuration or the policy row declares a hard limit above zero (`AssistantBudgets`: the user's daily tokens or cost, or the instance's monthly tokens or cost). What does not put one in force: a row that is switched off; a row whose limit is zero (it is not evaluated either); a row for somebody else or for a permission the person does not hold; a soft limit alone, which only warns. A budget row has no end date: its count starts again each period and it stays in force. An override is an administrator's dated exception to a budget that stays in force, so neither an override that has run out nor one that is running ("unlimited until") takes its budget out of force. ### Profile routing and provider fallback On an Assistant Profile, **Model routing** defaults to OFF. HEURISTIC selects the complex slot for long questions (>600 characters), two mentioned app tables, analysis/comparison/explanation/trend/planning requests, pending writes, more than eight previous turns, or a previous tool limit; otherwise it selects the simple slot. CLASSIFIER uses an optional registry model with just the question and a fixed instruction, four output tokens and a three-second timeout. An invalid reply, error, unavailable classifier or PHI-ineligible classifier uses HEURISTIC. ROUTE usage counts toward budgets. Empty model slots keep the provider/policy default; budgets may downgrade either choice. Without a profile, enabled code-level `AssistantModelRouting.escalation*` supplies the complex default. A model's **Fallback model** points to its next candidate, including another provider. Chains are cycle-free and bounded to four hops. Only retryable failures before any text or tool start from that call permit fallback. Disabled, down, PHI-ineligible or budget-blocked candidates are skipped. Without explicit links, only another LOCAL model in the same adapter family is eligible; local requests never automatically move to cloud. Provider state/signatures are removed when switching, preserving neutral tool history. Each attempted call is audited as usage. NETWORK/TIMEOUT marks a provider DOWN for 60 seconds; success marks it UP. Local health uses recent central results or a lazy probe with a two-second timeout. The central health card links to System Health; failures show fallback totals, and turn records show the route and final destination. See [UPGRADE.md](UPGRADE.md) for the additive schema. ## Scheduled tasks Administrators configure recurring prompts in **Assistant → Scheduled tasks**. Tasks use Quartz cron syntax (for example `0 0 8 * * ?`) and an IANA timezone (for example `America/Chicago`; default `UTC`). Tasks start disabled. ```java config.withUiApiBaseUrl("http://127.0.0.1:8000") .withScheduledTasksSchedulerName("hostScheduler") .withAllowedScheduledCredentialEnvNames(Set.of("ASSISTANT_SCHEDULED_TOKEN")); ``` Provide a long-lived access token accepted by the host's authentication provider in that environment variable. Store only its **name** on the task and set `runAsUserId` to the corresponding QQQ user id. The runner posts the token to `/qqq/v1/manageSession`, reads its session cookie and verifies the returned session's user before starting a new conversation. No system session reads user data. Configure the loopback URL at startup; a scheduled job cannot rely on a browser having visited first. For kof22, provision a designated Authentik account with the intended application permissions, obtain a token that kof22's QQQ authentication provider accepts as an `accessToken`, and inject it through the existing secret-manager/ExternalSecret pipeline. An Authentik management API token is suitable only if the host's auth adapter explicitly accepts it. Rotate the token in the secret manager, never in the task table. This feature does not provision an account or token. **Run now**, **Approve write mode** and **Revoke write mode** are Review/Result record actions. Write approval names the designated user and displays the exact prompt SHA-256. Changing prompt, user, credential reference, profile or write-mode request clears approval. Scheduled writes wait for that user's panel confirmation, as every write does. Each run keeps an owner-locked audit row and a normal conversation. RECORD adds no notification; NOTIFICATION and BOTH also notify the designated user through the optional Alerts QBit SPI. Without Alerts, notification delivery is log-only. HIPAA summaries are always `see conversation`. Cost caps stop at dispatch boundaries, so the last completed call can cross the cap; no further call starts. Apply the [P-g migration](UPGRADE.md#p-g-scheduled-tasks) before enabling jobs. ### Answer presentation The always-available `present_results` tool turns retrieved results into metrics, tables, charts and actionable in-app links. The server validates the user's UI API visibility, formats field values, computes shares and builds all links. It emits and stores a `presentation` part with a complete GFM Markdown fallback. Profile skills can extend the built-in presentation instructions. See the [part, stream and input contract](docs/answer-presentation.md). The image-breakdown golden task also runs in the existing opt-in live provider suite: `./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am` (with `ANTHROPIC_API_KEY` set). Live calls are excluded from normal verification. ### Record history and the assistant channel Core's default `ClientChannelsMetaData` denies `audit*` to the `assistant` channel. Accordingly, `get_record_history` is offered only when all three audit tables it queries (`auditTable`, `audit`, and `auditDetail`) exist and are not channel-denied, including their effective permission rules. A host that intends to expose record history can opt in during instance configuration: ```java new AssistantQBitConfig() .withRecordHistory(true); ``` This is an owner decision and a hard wall: record history defaults to disabled; **kof22 does not opt in**. On QQQ RC.6, the opt-in removes exactly `audit*` from the assistant's denied patterns (the former manual configuration step). RC.6 does not add a separate channel write denial for audits. On later RCs that include QRun-IO/qqq#1193, it calls `withAssistantAuditReads()`, which allows audit reads but denies audit writes. The user's own audit permissions still apply. Other patterns, including `worm_audit_*`, `assistant_*`, and security surfaces, remain untouched, as do explicit object and permission-rule denials. History visibility uses core's `isDenied` read/all-operation check. RC.6 has no operation argument; the later write-only deny patterns must remain separate from that check so they do not hide the read tool. ## In-app help The QBit registers [`help/qbit-assistant.yaml`](qbit-assistant/src/main/resources/help/qbit-assistant.yaml) with QQQ's help-bundle registry during production. Hosts with help content storage use QQQ's startup bundle sync; registration is harmless when the host has no `helpContent` table. To customize the wording, register a separate bundle with `layer: HOST` using `HelpBundles.register(qInstance, "help/my-application.yaml")`. Entries with the same key and role override this QBIT bundle. User-edited help remains protected by core's sync. Keep live `{{...}}` references so labels and metadata values follow the host's configuration. `AssistantTablesGuardrailTest.everyOwnedTargetHasBundleHelp` checks coverage and live references against the produced test instance with `HelpBundleAssertions`; it runs in the normal Maven build. Update the bundle's literal version alongside the artifact version when releasing.