Studio ships an AI assistant that can inspect your project — schemas, tables, indexes, RLS status, logs, metrics — and answer questions about it. It is bring-your-own-key: no traffic leaves your server until you configure a provider.
Providers
Configured in the ai.assistant settings group (Studio → Settings). GET /api/admin/ai/providers returns the catalogue, defined in packages/ai/src/providers/index.ts:
| Id | Label | Default model | Base URL |
|---|---|---|---|
anthropic | Anthropic (Claude) | claude-opus-5 | — |
openai | OpenAI | gpt-5 | — |
gemini | Google Gemini | gemini-2.5-pro | — |
deepseek | DeepSeek | deepseek-chat | prefilled: https://api.deepseek.com |
qwen | Qwen (Alibaba Cloud) | qwen-plus | prefilled: https://dashscope-intl.aliyuncs.com/compatible-mode/v1 |
glm | GLM (Z.ai) | glm-4.6 | prefilled: https://api.z.ai/api/paas/v4 |
moonshot | Kimi (Moonshot AI) | kimi-k2-0905-preview | prefilled: https://api.moonshot.ai/v1 |
mistral | Mistral AI | mistral-large-latest | prefilled: https://api.mistral.ai/v1 |
xai | xAI (Grok) | grok-4 | prefilled: https://api.x.ai/v1 |
groq | Groq | openai/gpt-oss-120b | prefilled: https://api.groq.com/openai/v1 |
openrouter | OpenRouter | openrouter/auto | prefilled: https://openrouter.ai/api/v1 |
compatible | OpenAI-compatible (custom base URL) | — | required |
A prefilled base URL applies when the field is left blank, so picking a named provider and pasting its key is enough. Fill the field to override it, for example with a mainland-China endpoint (https://dashscope.aliyuncs.com/compatible-mode/v1 for Qwen, https://open.bigmodel.cn/api/paas/v4 for GLM) or a proxy. Use compatible for anything else that speaks the OpenAI API: Ollama, vLLM, LiteLLM.
Resolution order (services/admin/ai-config.service.ts):
- The project's own
ai.assistantvalues, ifenabled,providerandapiKeyare all set. - Otherwise the console-global config.
- Otherwise "not configured" — the assistant reports it and does nothing.
Setting enabled: false at project level with an API key present is an explicit opt-out: it does not fall through to the console default.
POST /api/admin/ai/test validates the resolved configuration.
What it can do
The tool registry (packages/ai/src/tools/registry.ts) is entirely read-mode today:
| Tool | Reads |
|---|---|
list_schemas, list_tables, list_indexes | Schema layout |
list_functions, list_triggers, list_extensions | Database objects |
run_select | A single read-only query |
rls_status | Which tables have RLS on, and their policies |
security_advisor | Advisor findings |
read_logs, read_metrics, read_performance | Observability data |
list_queues | Queue topics and delivery state |
index_advisor | Index recommendations |
Every tool call is an HTTP call to this server's own /api/admin/* endpoints, carrying the signed-in user's bearer token. Three consequences:
- The assistant can do exactly what you can do, and nothing more.
- Tool activity goes through the same guards and audit trail as your own clicks.
- If the token is missing, tool calls simply fail with 401 — there is no service-token fallback.
run_select is defended in depth: statement-shape checks reject anything that is not a read, a function-call blocklist rejects pg_read_file, pg_sleep, dblink, lo_import, pg_terminate_backend and friends, and the query still runs through the /api/admin/sql/readonly endpoint inside a Postgres READ ONLY transaction.
Results are capped before being fed back to the model, so a single list_tables on a large database cannot flood the context window.
The run loop
runAgent in packages/ai/src/agent.ts is a bounded loop (maxSteps, default 12 from ai.assistant):
- Stream a provider turn; emit
text_delta,tool_call,usage. - On a provider
error, the run ends immediately — no message is persisted and noend_turnis reported. - No tool calls →
done: end_turn. - Read tools execute right away and the loop continues.
- Write tools are proposed, not executed. Every write in the turn is emitted as
tool_proposaland the run ends, so you approve before anything mutates.autoApproveWritesinai.assistantdisables that gate. - Hitting
maxStepsemitstruncated.
Transport
POST /api/admin/ai/chat is Server-Sent Events (text/event-stream, X-Accel-Buffering: no). Studio reads it with fetch + a stream reader because the browser cannot POST through EventSource. Closing the request aborts the run via AbortController.
Each frame is one JSON AgentEvent:
data: {"type":"thread","threadId":"…"}
data: {"type":"text_delta","text":"Your "}
data: {"type":"tool_call","call":{"id":"…","name":"list_tables","input":{}}}
data: {"type":"tool_result","callId":"…","name":"list_tables","result":[…]}
data: {"type":"done","stopReason":"end_turn"}Errors are emitted as {"type":"error","message":…} followed by done. Only the caught error message is ever written to the stream — the resolved config (which carries your decrypted API key) is never yielded or thrown.
Conversations
Threads are stored in SQLite under AI_DATA_DIR (assistant.db), separate from BACKUP_DIR so restoring a database backup never touches conversations. Opening the store is deliberately non-fatal: on a read-only or unmounted volume you lose history, not your project API.
| Route | Action |
|---|---|
GET /api/admin/ai/threads | List threads in scope |
GET /api/admin/ai/threads/:id | Read one |
PATCH /api/admin/ai/threads/:id | Rename |
DELETE /api/admin/ai/threads/:id | Delete |
Scope is (project, userId). Every route that takes a thread id resolves ownership first and returns 404 Conversation not found otherwise — the store itself does no ownership check, so guessing an id gets you nothing.
Settings
See the ai.assistant group in the settings reference for the full field list, and @motherbase/ai for the package API.