AI assistant
The AI assistant is a bundled plugin (src/main/plugins/bundled/ai/).
Nothing in the core app knows about OpenAI or Anthropic; the plugin registers
providers, tools, and IPC handlers through the SDK at activation, exactly like a
driver plugin registers a database.
This doc covers how a chat turn flows end to end, the tool system shared with MCP, the App-Action registry that lets the AI act on the UI, conversation history, and how the orchestrator keeps requests bounded.
- Process split
- Providers and models
- Tools: the shared ToolRegistry
- App actions: deep links and agentic UI
- The orchestration loop
- Conversation history
- Enhancements (generate / complete / explain)
- Renderer state and UI
- File map
Process split
Section titled “Process split”| Runs in | Responsibility |
|---|---|
main (bundled/ai/internal/) | provider API calls, the tool loop, permission gating, conversation state for the current turn |
renderer (stores/ai.ts, components/ai/) | chat UI, conversation history + persistence, the App-Action registry, approval prompts |
shared (shared/ai-types.ts) | message/event/request types crossing the IPC boundary |
The renderer never calls a provider directly. It sends ai:chat:start and
streams ai:chat:event broadcasts back. All AI IPC channels live under the
ai:* prefix in shared/ipc.ts (see ipc.md).
Providers and models
Section titled “Providers and models”AIProviderRegistry holds the registered providers and tracks the active
provider + model id. Three providers ship in the box:
providers/openai.ts,providers/anthropic.ts,providers/ollama.ts
Each implements the AIProvider interface (internal/types.ts): models()
(lists models with a contextWindow and a costTier) and chat(request) (an
async generator of { type: 'text' | 'tool-call' | 'done' | 'error', … }
chunks). API keys live in the OS keyring under the __ai__ namespace, never in
settings.json (a one-time migration moves any legacy plaintext key out).
When a provider becomes active, the cheapest model for that vendor is selected
by default (pick-cheapest-model.ts), unless the user already chose one.
Tools: the shared ToolRegistry
Section titled “Tools: the shared ToolRegistry”Tool calling is unified across the AI chat and the built-in MCP server. Both
read the same ToolRegistry (sdk/tool-registry.ts), constructed once in
ipc-handlers.ts and handed to every plugin. A Tool (sdk/types.ts):
interface Tool { id: string name: string description: string inputSchema: z.ZodObject // validated + converted to JSON Schema for the LLM permission: 'read' | 'write' surfaces?: Array<'ai' | 'mcp'> // omitted = both execute(params, ctx: ToolContext): Promise<ToolResult>}- The canonical database tools (
query,explain_query,list_tables,describe_table,get_schemas,connection_info) are registered by thedb-toolsbundled plugin and are visible to both AI and MCP. surfaces: ['ai']scopes a tool to the chat only — the headless MCP server never sees it.perform_app_action(below) is AI-only for this reason.permission: 'write'tools route through thePermissionManager: the loop emits anapproval-requestevent and waits for the user’sai:chat:approval-responsebefore executing.
App actions: deep links and agentic UI
Section titled “App actions: deep links and agentic UI”“App actions” are named, parameterized things the assistant can point the user to or perform inside the renderer — opening a panel, a query tab, connecting to a database, exporting results. They are the single source of truth behind two surfaces, so a new action lights up both with no extra wiring.
The registry lives in the renderer (lib/app-actions/):
interface AppAction { id: string // used in verql://action/<id> links + tool calls title: string // human label on chips / in the catalog description: string // shown to the AI in the system prompt kind: 'navigation' | 'mutating' // gates agentic execution params?: Record<string, AppActionParam> run: (params) => void | Promise<void>}Built-ins are registered at startup (builtins.ts, via registerBuiltinAppActions()
in App.tsx); plugins can register their own through the same appActions.register
API, so any plugin destination becomes referenceable by the AI automatically.
Two surfaces, one registry:
- Deep-link chips (user-clicked). The assistant writes a markdown link with
a
verql://action/<id>?param=valuehref.MarkdownContent’s link renderer intercepts that scheme (parse.ts) and renders anActionChipinstead of an anchor. Clicking it runs the action.mutatingchips confirm first. - Agentic tool (AI-initiated). The AI calls the
perform_app_actiontool (registered ininternal/index.ts,surfaces: ['ai']). Because tools run in main but actions run in the renderer, the tool broadcastsapp:action:performwith a correlation id; the renderer bridge (bridge.ts) runs the action and reports the outcome back overapp:action:result, so the tool result honestly reflects success/failure. Onlynavigationactions run agentically — the bridge refusesmutatingones, which must go through a user-clicked chip.
The AI learns the catalog because the renderer sends appActions.describeForPrompt()
on every ai:chat:start, and ConversationManager.assembleSystemMessage()
appends it with usage rules. See builtins.ts for the full list (connect /
disconnect / switch connection, open a query tab, scaffold DDL, export results,
open a chart, reveal a table, open a saved query, ER diagram, insert into the
editor, settings, notifications, …).
The orchestration loop
Section titled “The orchestration loop”ConversationManager.chat() (internal/conversation-manager.ts) is an async
generator that drives one user turn to completion:
- Assemble the system prompt — base rules + the connected driver, the current schema summary, registered context providers, the saved-connections summary, a recent-notifications summary (for diagnostics), and the app-action catalog.
- Budget the context. The full transcript is kept in memory for display and
persistence, but only a trimmed copy is sent each round.
token-estimate.tsgives a cheapchars/4estimate;trimMessagesToBudgetkeeps the system prompt plus the most recent turns withinmaxContextTokens(default 24k), always retaining the newest message and never leading with an orphaned tool result. This stops a long conversation from growing the request — and the bill — without bound. - Stream + tool loop. Text chunks are forwarded as
chunkevents. On atool-call: resolve the tool, gatewritetools through approval, execute viatoolRegistry.execute(id, params, { connectionId, abortSignal }), and feed the result back. Loops up toMAX_TOOL_ROUNDS(10) until the model answers with no further tool calls.
The connection id is threaded per request from the renderer (the UI’s active connection), overriding the ambient one, so the tools always run against the database the user is actually looking at.
Conversation history
Section titled “Conversation history”The renderer owns conversations and persists them to the internal SQLite
app-data store in the main process (appdata:conversations:* IPC →
src/main/appdata/store.ts, file ${userData}/app.db). This replaced the
former localStorage blob (verql:ai-conversations), which hit the browser
storage quota and rewrote the entire history on every message; the store now
takes one transactional write per settled message. The legacy localStorage
payload is migrated into the store on first launch (see
docs/proposals/internal-app-data-store.md).
State and actions live in stores/ai.ts:
interface Conversation { id: string title: string // auto-derived from the first user message messages: AIChatMessage[] stats: SessionStats // per-conversation token / tool-call totals createdAt: number updatedAt: number}newConversation/switchConversation/deleteConversation/renameConversation/branchConversationmanage the list;ConversationMenuis the switcher at the top of the panel.- Branching (
branchConversation(messageId), surfaced as the branch button on a message) forks a new conversation containing the history up to that message, leaving the original intact. - A module-level store subscription keeps the active conversation in sync with
the live message/stat state and writes it through to the app-data store
(
appdata:conversations:upsert) — only the active conversation, not the whole list.hydrate()loads the set (and runs the one-time migration) on app boot.
Because the main process starts each launch with no history, the renderer pushes
the relevant transcript to main via ai:messages:set (→
ConversationManager.setMessages) on switch, on branch, and once on startup for
the restored active conversation. Otherwise continuing a restored chat would send
only the new message and lose context.
Enhancements
Section titled “Enhancements”Separate from tool-calling chat, internal/enhancements.ts exposes three direct
one-shot provider calls used by the editor and results UI:
| Channel | Used by | Purpose |
|---|---|---|
ai:generate-sql | NLInputBar | natural language → SQL |
ai:complete-sql | inline completion provider | ghost-text completion |
ai:explain-results | results panel | explain a result set |
These don’t go through the conversation loop or tools.
Streaming explain results
Section titled “Streaming explain results”ai:explain:start returns { streamId, model }; the plugin emits
ai:explain:event messages (token | done | error) keyed by streamId
until completion. Callers may abort with ai:explain:abort(streamId). The
renderer’s Results bar uses this for token-by-token rendering of the Explain
card with a Stop button.
Renderer state and UI
Section titled “Renderer state and UI”stores/ai.ts (useAIStore) holds messages, streaming state, providers/models,
approvals, per-session stats, and the conversation list. It listens for
ai:chat:event and applies each AIStreamEvent (handleStreamEvent).
Components in components/ai/:
| Component | Role |
|---|---|
ChatPanel | panel shell: ConversationMenu + SessionInfo + MessageThread + ChatInput |
ConversationMenu | conversation switcher: new / switch / rename / delete |
MessageThread | renders the message list + empty-state suggestion chips |
MessageBubble | a user/assistant bubble; hosts copy / retry / branch actions |
ToolCallCard | a tool call’s status, arguments, and result (resolves perform_app_action ids to titles) |
ApprovalCard | approval prompt for write tools |
MarkdownContent | assistant markdown; intercepts verql://action/* → ActionChip |
ActionChip | clickable deep-link pill backed by an AppAction |
SessionInfo | message / tool-call / token counts for the active conversation |
File map
Section titled “File map”src/main/plugins/bundled/ai/├── index.ts # plugin activate(): wires deps → startAIModule└── internal/ ├── index.ts # startAIModule: providers, IPC handlers, perform_app_action tool ├── conversation-manager.ts # system prompt + the tool loop + context trimming ├── token-estimate.ts # estimateTokens + trimMessagesToBudget ├── provider-registry.ts # active provider/model ├── permission-manager.ts # approval requests for write tools ├── enhancements.ts # generate / complete / explain SQL ├── pick-cheapest-model.ts └── providers/{openai,anthropic,ollama}.ts
src/renderer/src/├── stores/ai.ts # useAIStore: messages, conversations, persistence├── lib/app-actions/│ ├── types.ts registry.ts builtins.ts parse.ts bridge.ts resolve.ts└── components/ai/ # ChatPanel, ConversationMenu, MessageBubble, …
shared/ai-types.ts # AIChatMessage, AIStreamEvent, AIChatStartRequest, …