CircleCI job 22 failed with turnCount 19 instead of 20. A writer thread died with ConcurrentModificationException: QQQ's MemoryRecordStore.query iterates a table's synchronized map without holding its lock, so one writer's INSTANCE-row update (INSTANCE usage lock) raced another writer's insert of a user row (a different usage lock). The exception was swallowed by the virtual thread, so the test only saw a lost count. The race is in the in-memory test backend, not in AssistantStore's per-key locking. The writers now use a store whose individual backend calls are atomic, as on a real database, so the per-key locks are still the only thing protecting the read-modify-write under test. A start latch releases all writers together, and an executor's futures surface any writer exception as a test failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
| Artifact | What it is |
|---|---|
com.kingsrook.qbits:qbit-assistant |
The QBit: conversation tables, turn engine, /qqq/v1/assistant routes (JSON + Server-Sent Events), budgets, kill switch, the provider SPI, 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. |
The data access itself is done by the AI tool kernel in QQQ core (com.kingsrook.qqq.backend.core.actions.ai.AiToolKernel). The kernel checks table permissions and exposure rules, applies record security, and caps and redacts what the model sees. Every tool result reaches the model wrapped as untrusted data.
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-javalinis needed for the routes. Until that RC exists, build against a local QQQ build. See 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
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant</artifactId>
<version>${qbit-assistant.version}</version>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-assistant-provider-anthropic</artifactId>
<version>${qbit-assistant.version}</version>
</dependency>
AssistantQBitConfig config = new AssistantQBitConfig()
.withBackendName("rdbms") // where the assistant_* tables live
.withTableMetaDataCustomizer(snakeCaseCustomizer) // optional: backend names for your schema
.withModelProvider(AnthropicModelProvider.fromEnvironment()); // key from ANTHROPIC_API_KEY
new AssistantQBitProducer().withQBitConfig(config).produce(qInstance);
produce does the following:
- Registers the QBit.
- Adds the tables, the
assistant_user_idsecurity key type, theassistantRetentionprocess and the Assistant Admin app. - 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. Then grant the permissions.
The API key
The key never goes in config, tables or logs. There are two ways to supply it:
AnthropicModelProvider.fromEnvironment()readsANTHROPIC_API_KEY.AnthropicModelProvider.builder().apiKey(valueFromYourSecretStore).build()takes the key from your own secret store. It also accepts the reference form"${env.NAME}".
The provider's toString never contains the key. Its errors carry only the HTTP status and the API error type.
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. |
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 the kernel redacts from tool results. |
killSwitchPollMillis |
2000 | How often a running turn re-checks the kill switch while it waits on the model. |
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
killSwitchPollMilliswhile 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.
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 Admin app, its tables and the retention process. |
There is no assistant-specific data permission. Everything the kernel reads 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.
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 Admin app through the key type's all-access key,
assistant_user_id_all_access. Grant that key toassistant.adminholders from your authentication customizer:
// in your QAuthenticationModuleCustomizerInterface.customizeSession(...)
AssistantSecurity.applySessionKeys(qSession, assistantConfig);
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_ONLYandMASKEDfields 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 untilconversationRetentionDays. - turn accounting: tokens, cache reads and writes, and cost
- streamed events, kept for replay
- feedback
- 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}. 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?}. |
The stream sends these events, each with a seq:
message.start,text.deltatool.start,tool.result(withrenderandrecordRefs)ui.navigate,noticeusage,message.enderrorpingevery 15 seconds.
The stream ends after message.end. Thinking is never streamed.
SSE behind a proxy (Traefik, nginx)
- The responses carry
Cache-Control: no-cacheandX-Accel-Buffering: no. Do not compress or buffertext/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'srespondingTimeouts(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=<last seq>.
Tables
All of these are standard, visible QQQ tables in the Assistant Admin app:
| 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_policy |
The kill switch and overrides. |
assistant_usage_daily |
Usage rollups: per user per day, plus an instance row whose userId is null. |
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.
Build
./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:
ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am
License
Apache 2.0.