The agent, for the person using it

August 15, 2026 · View on GitHub

This is the page for someone who has the agent rail open and wants to know what the words in it mean. It describes what the application says, in the application's own vocabulary, and it does not invent a second one: every label, sentence and number quoted here is rendered by src/components/agent/AgentRail.tsx or composed by src/components/agent/timeline.ts, and the citation is given so you can check any of it.

It is a separate page from docs/AGENT.md on purpose. That document is the behaviour reference — durability, the ledger, the policy, the module map, the deferrals — and it is written for someone changing the runtime. Folding a user guide into it would bury the four questions a user actually has (what is a run, what are the three workflows, what does answered mean, what are the meter's numbers) inside eight hundred lines about resume semantics. Where a sentence here needs the mechanism behind it, it links there instead of restating it.


Contents


Where the agent is

The rail is part of the standalone application only — the embedded @libredb/studio package renders no agent surface at all (src/workspace/StudioWorkspace.tsx, and tests/unit/agent-package-boundary.test.ts pins it). Above the md breakpoint it is a resizable panel beside the editor; below it, the same rail opens as a sheet (AgentRail.tsx:786-807 chooses the presentation, and it is one component instance either way, so an objective you are typing survives a window resize).

Three controls elsewhere in the shell open it carrying the statement in your editor:

ControlWhereWhat it does
"Ask the agent about this query"Command palette (src/components/CommandPalette.tsx:134-137)Selects the Investigate workflow and fills the objective with the editor's statement
"Ask about this query"Mobile header (src/components/studio/StudioMobileHeader.tsx:232-242)The same, and opens the sheet
"Agent"Mobile nav (src/components/Studio.tsx:825)Opens the rail and asks nothing

None of them starts a run. The shortcut selects a workflow and fills the box; pressing Start is yours (src/components/agent/use-agent-prefill.ts, and the rail's own effect at AgentRail.tsx:268-302). If you were already typing an objective, the shortcut does not overwrite it — it offers the new one on a line reading Suggested: … with a Replace control beside it (AgentRail.tsx:473-490). The statement is passed as you wrote it, minus surrounding whitespace; nothing is composed around it on your behalf (Studio.tsx:262-269).

If the rail is not there, the server is telling you something. Visibility is derived rather than flagged: the browser asks GET /api/agent/config once per mount (src/hooks/use-agent-capability.ts), and the rail renders only for {"enabled": true}. The same answer carries a reason naming which condition failed — no model configured, an unwritable ledger, an operator off-switch — so curl on that route is the diagnosis. The conditions and the reason codes are in docs/AGENT.md.


What a run is

A run is one objective, asked once, against one connection, recorded as an append-only ledger. You choose three things before pressing Start, and two of them are controls in the rail's header:

1. The mode — how the run executes (AgentRail.tsx:405-420, labels at AgentRail.tsx:80-83):

ButtonWhat it means
PlanThe model writes one statement that answers your objective, for you to run yourself. It has no tools: it runs no statement of yours, writes nothing, and applies nothing. The server reads your schema for it first, so the statement names your real tables — see What a Plan run knows about your database. This is what a run opens in.
AgentThe model is given the read-only tools and investigates: it drafts statements, reads results, and finishes by composing a report whose claims cite what it read.

2. The workflow — what the run is for (AgentRail.tsx:433-448, labels at AgentRail.tsx:94-98): Investigate, Optimize, Assess, Operate, Analyze. Offered in both modes, because "how would you make this faster?" is an ordinary thing to ask a plan for.

Analyze is the one that answers a question about the data rather than about the database: "which region brought in the most revenue last quarter", "how many orders shipped but were never invoiced", "did signups fall after the pricing change". It finishes by saying which result is the answer and how it should be shown — as a chart when the result has a category and a number to plot, and as a table when it is a single number, a single row, or has no numeric column at all. A table is a complete answer there, not a lesser one. What it presents is always a result it read: a query plan is the engine describing a statement rather than running it, and a table profile is a count about a table, so neither can be nominated as the answer — the run can still cite either as evidence in its report, and its report has to cite the result it presented, or the run is marked as not having answered. It is also the only workflow that offers the auto-execute checkbox below, because handing a statement to your editor is part of presenting an answer and the other four workflows have no answer to present.

3. The objective — the box labelled "What should the run investigate?", placeholder "Why is checkout slow?", bounded to 4000 characters (AGENT_MAX_OBJECTIVE_LENGTH in src/lib/agent/execution-policy.ts:157). It is emptied once the server has opened the run, so the next question needs no deleting; the question itself is not lost, since the run's header carries it and the timeline's first entry quotes it. A start that was refused leaves what you typed exactly where it was, so retrying is one click rather than one retyping.

Both axes are fixed when the run opens and are read from the run's own record for the rest of its life (src/app/api/agent/runs/route.ts:79-88), so nothing can widen a Plan run into an Agent one afterwards.

The connection is the one the shell is on, and it has to be one the server can rebuild. A run stores a connection id and no credential, so a connection that exists only in your browser cannot be investigated. The rail says so rather than offering a Start that must fail:

"… cannot be rebuilt on the server: its settings live in this browser. A run re-resolves its connection there after a restart, so it can only investigate a connection the server holds too." (AgentRail.tsx:492-498)

What a Plan run knows about your database

A Plan run runs no statement of yours, writes nothing, and hands every statement it drafts to you to run yourself. That is the promise of the mode, and it is the one to hold it to.

What it is not is "a Plan run never touches your database". It used to be described that way, and that stopped being true on 2026-08-15. Before the model's first turn, the server reads your schema for it — the catalog, plus what the engine already estimates about its own tables — the same reading the sidebar takes when you connect. Nothing the model writes goes anywhere: it holds no tool, and once the run has been given that reading, nothing further is read for it.

You no longer have to run an Agent run first. That was the old arrangement, and it meant the safe mode only worked for people who had already used the other one — and stopped working after every restart. A Plan run now reads what it needs itself, from whichever of these is cheapest: what it already recorded on an earlier drive of the same run, what this server read for another run on the same connection, or a fresh reading.

What the Plan run is shown:

  • Your tables, columns, keys and relations, so the statement names your real tables and the real joins between them.
  • The engine's estimated statistics — roughly how many rows a table holds, how often a column is null, roughly how many distinct values it has. These come from what PostgreSQL and SQLite already keep (pg_class/pg_stats, sqlite_stat1); nothing is counted and no value in any row is read. They are what lets a plan choose which table to drive a join from.

Every one of those numbers is an estimate, and the run is told to treat it as one. They can be badly out of date, and a table nobody has ANALYZEd has none at all — such a table is shown as having no statistics, never as empty. On SQLite the statistics exist only after you have run ANALYZE, there is no null fraction at all, and a table with no index gets no row estimate either.

Two engines only. Grounding works on PostgreSQL and SQLite. On any other connection a Plan run says plainly that no inventory could be read for it, and is asked to refuse rather than to invent table names. Operate is the one workflow with no grounding by choice, on any engine: it asks the engine about itself rather than about your tables, so there is nothing to ground against, and its plan is prose rather than a statement.

The statement it drafts, and what is checked. The run finishes with one fenced statement and a short rationale. The rail shows it on its own card with a Copy and an Apply to editor — the run never runs it. Two things are checked first and both are shown to you:

  • Tables it names that your schema does not have. They are listed beside the statement. Nothing checks its columns, so an unflagged statement is one where nothing recognised was missing, not one that is known to be correct.
  • Whether it only reads. A statement that writes is not withheld — sometimes writing one is exactly what you asked for — but it is marked, on the card and in the button's own screen-reader label, so that Apply is never a quiet handover of a DELETE. Read the mark as what it says: the guard did not classify this as a bounded read, and its reason is shown. That check deliberately over-refuses — text two dialects would read differently is refused whole, and so are PostgreSQL's #>/#>> operators — so a marked statement is one nothing established to be a read, which is not the same as one established to write.

A statement built from your real table names is still not a statement guaranteed to run. The inventory records what exists in the database, not what your role is permitted to read.

When it cannot answer, the run says so instead of guessing: the rail shows a No statement drafted card with what was missing and the one question that would unblock it. That is a successful ending for this mode, not a failure. A Plan run that instead lectures — a numbered list of things it would look at, with no statement and no refusal — is marked unanswered.


The four workflows

Each workflow changes three things: what the model is told the run is FOR, which tools it is offered, and the bar its verdict is judged against.

Investigate

The default. The objective is a question about the database and the model answers it from what it establishes (WORKFLOW_OBJECTIVES.investigation in src/lib/agent/investigation.ts:203). Tools: inspect_schema, run_read_query, inspect_plan, compose_report (AGENT_MODE_TOOLS, src/lib/agent/tools.ts:402-407).

Answered when the run composed at least one claim and the claims do not rest entirely on empty results (verifyInvestigationGoal, src/lib/agent/goal-verifier.ts:172-173).

Optimize

For a statement that is too slow. The model is told that what matters is how the engine reaches its rows (investigation.ts:204-205), and it is offered two further tools: compare_plans, which takes the ids of two plans the run already inspected, and recommend_change, which records one index or rewrite (tools.ts:384-394).

Two things this workflow will not do, and it says so rather than implying otherwise:

  • Every plan is an estimate. EXPLAIN ANALYZE executes the statement and is policy-denied, so the comparison entry carries a sentence the application wrote: "Estimates only: these plans were described, not executed. EXPLAIN ANALYZE is policy-denied because it would run the statement." (PLAN_ESTIMATE_CAVEAT, timeline.ts:209-210).
  • A recommendation is never applied. Every recommendation entry carries "Not applied: nothing here runs this statement." (NOT_APPLIED_CAVEAT, timeline.ts:213), and the only thing offered is an Apply to editor button, which puts the text in your editor and runs nothing (HydrationControls, AgentRail.tsx:176-186).

Answered when the Investigate bar is met and the change it proposes rests on a plan it read: a before/after comparison for a rewrite, or — for an index, whose "after" plan would need the index to already exist — the recommendation citing the plan it diagnosed (verifyQueryOptimizationGoal, goal-verifier.ts).

Assess

For the state of the data itself — where it is incomplete, inconsistent or surprising (investigation.ts:206-207). It adds one tool, profile_table, and the rule that matters is worth reading before you point it at a table of personal data:

A profile records counts, never values. Row counts, present counts, distinct counts, and how many values match a shape — computed inside the database with count(CASE WHEN … LIKE …), so no matching value leaves it. There is deliberately no min/max, because on a text column those return real values (src/lib/agent/table-profile.ts:1-27). The findings — high_null, constant, low_cardinality, suspected_pii, fk_unindexed — are the server's own mechanical predicates over those counts, with stated thresholds; the model may interpret them and cannot invent one.

Answered when the Investigate bar is met and a table was actually profiled (verifyDatabaseAssessmentGoal, goal-verifier.ts:211).

Operate

For how the database is running right now — the slowest queries, who is connected and what is blocked, the biggest tables, the unused indexes, storage pressure. It is the one workflow that sends no SQL at all: the only tool that reaches the database is inspect_operations, and you name a reading rather than a statement — sessions, slow-queries, table-stats, index-stats, storage or health. The server calls the engine's own reporting interface and stores the answer as an ordinary citable result.

Two consequences you will notice:

  • It runs on every engine. The other workflows need a database-native read-only statement path, which only PostgreSQL and SQLite have; this one needs none, so a run opened on MySQL, Oracle, SQL Server, MongoDB or Redis works rather than ending engine-unsupported.
  • It has no schema and no free-form SQL. There is no inspect_schema and no run_read_query here, and the run is told so in its opening message rather than being left to discover it.

Two things this workflow will not do, and it says so rather than implying otherwise:

  • Every reading is a moment, not a history. A session list is who was connected as the run looked. The timeline says so on the entry itself: "A moment, not a history: this reading says what the engine reported as it was taken." (POINT_IN_TIME_CAVEAT, timeline.ts), and the model is told the same before it starts, so a report cannot quietly imply a trend was measured.
  • It cannot propose an operational action. recommend_change offers an index or a rewrite and nothing else, so "kill this session" or "vacuum this table" is stated as a claim in the report rather than filed as a recommendation you could apply with one click.

Answered when the run composed a report (verifyOperationsGoal, goal-verifier.ts). Every claim in it already cites a reading the run took — compose_report refuses a claim whose evidence names nothing this run produced, and a reading is the only thing this workflow can produce to cite — so the citation is enforced as you write, not judged afterwards. Note what is deliberately not required: a reading that came back empty still counts. No blocked session and no slow query is what a healthy server looks like, and treating that as an absence of evidence would mark an accurate report as unanswered.


What you see while a run goes

The rail's timeline is a fold over the run's ledger, one line per recorded event (foldLedgerEntries, timeline.ts:603-675). Before anything has happened it says "No activity yet. A run's steps appear here as they are recorded."

HeadlineWhen
Run opened in <mode> mode for <workflow>The run's own header. Your objective is shown quoted underneath.
Schema capturedThe run read the catalog once: table count and the first 8 characters of the snapshot fingerprint
Statement draftedThe model wrote SQL. The statement is shown quoted, with the model's stated reason
Tool invoked / Result storedThe call, then its outcome: rows, columns, elapsed ms, and the artifact id
Refused by policyThe operation layer denied it. Shown by deny code — there is no engine text, because a denial produced none
Approval requiredThe operation needs a human approval this run does not have
The database refused the statementThe engine's own message, quoted
Plans compared / Index recommended / Rewrite recommendedOptimize only
Profiled <table>Assess only: counts and findings
Result stored"A moment, not a history"Operate only: an operational reading, with the caveat that it describes an instant already past
Answer composedThe run named one stored result as its answer and said how to show it — as a table, or as a chart of a named type. A chart's caption is the model's own prose and is shown quoted; the columns never appear in the application's own sentence, because they are engine text
Report composedHow many claims, each citing evidence
Closing statementThe model's closing prose. It cites nothing and claims nothing — a Plan run's whole output, an Agent run's aside
Statement drafted / Statement drafted — not classified as a readPlan mode: the one statement the run wrote, on its own card — the SQL verbatim, any table it names that your schema does not have, and Apply to editor. The second headline is a statement the guard did not classify as read-only, and its reason is shown beside it: that can mean the statement writes, and it can equally mean the guard could not settle the text. Nothing runs either
No statement draftedA Plan run that could not answer from your schema, with what was missing. A legitimate ending, not a failure
Stop requestedYou pressed Stop; "the run takes no further database step; work already in hand, such as a report, still finishes"

Wording the application chose and text that came from elsewhere never share a line. Headlines and details are the application's words; anything from the model, the engine or you is rendered as a quoted block, because database content is untrusted input (timeline.ts:25-36).

The one exception is the closing statement, and it is a rendering rather than a mixing. Models write that block as markdown, so it is rendered as markdown — headings, bullets, bold and inline code — inside a block of its own with a rule down its left edge, so you can still see where the application stops speaking. Only those five forms are interpreted; links, tables and fenced blocks stay the characters the model typed. Nothing on that path touches an HTML parser: it is built as React nodes, so a model that writes <img src=x onerror=…> has written text and nothing else.

The timeline stays at its newest entry while you are at the bottom of it, so a run's report does not arrive below the fold. Scroll up to read an earlier step and it stops following — the next entry leaves you where you are — until you scroll back down.

Two controls appear under an entry only when there is something to act on and the shell can act:

  • Apply to editor — puts a drafted statement into the editor. Always your click, never automatic, unless the run was opened with auto-execute (below), which is the one thing that can put an answer's statement into your editor and run it there.
  • Show result — hydrates the stored rows into the ordinary bottom-panel surface: the results grid, the explain view for a plan, or the charts view for an answer the run composed as a chart — drawn as the run said to draw it, and with the ordinary chart controls still yours to change. Which surface opens is what the run recorded, not a guess from the data: a result whose answer said table is shown as a table however chartable it looks. Every one of them carries a read-only provenance badge naming the run. It is offered only while the run is live: a run's stored rows are released when it ends, and the rail says so where the results are listed — "A run's stored rows are released when the run ends, so a result can be shown only while its run is still going." (AgentRail.tsx:730-735).

The answer itself is shown without being asked for. When an Answer composed entry arrives, the rail opens that result immediately — as the chart the run composed, or as a table — because a run that answers with a picture and shows you a sentence saying it drew one has not answered. It happens once, at the moment the entry arrives, while the rows still exist; nothing is kept any longer than before, and while the run is live the entry's own Show result still brings the answer back if you dismiss the panel. This is not the rail acting on your behalf in the sense the rule above forbids: nothing is executed, and the rows are ones the run already read on its own bounded read-only path.

At the bottom, once a report exists, a Report section lists each claim as the model's own quoted prose with its citations under it — Artifact <id> with the row count and the statement that produced it, or Schema snapshot <fingerprint> with the table count. A citation the rail cannot resolve in what it has read says so in amber rather than looking checked (UNRESOLVED_DETAIL, timeline.ts:556).


Auto-execute: when the run runs the answer in your editor

An Analyze run in Agent mode can be opened with auto-execute. It changes one thing and it is worth being exact about which: the run's own answer is produced the same way either way — from a result it read on its own bounded path, at 200 rows and a 10-second ceiling, with a ledger entry behind it. What the setting adds is that the answer's statement is also placed in your editor and run there, on the connection the run was opened on, at the editor's 500-row limit and with no time limit.

It is the same read-only session either way. The re-run is not an ordinary editor execution: it is sent to the database inside the engine's own read-only transaction — the same one the run used to produce the answer — so a write or a DDL statement is refused by the database, not by reading the statement and judging it. That distinction is the whole of it: a SELECT can call a function that writes, and no amount of reading the statement would tell you so.

It is offered on Analyze alone, and only in Agent mode. Auto-execute hands over the answer, and Analyze is the only workflow that produces one — an Investigate, Optimize, Assess or Operate run finishes with a report, not with a statement it is nominating as the answer, so there would be nothing to hand over. The checkbox therefore appears only when Analyze and Agent are both selected, and it disappears if you switch away; a run started after switching is started without the setting rather than with a setting that could not be honoured. (The API refuses autoExecute: true on the other workflows outright, rather than accepting it and quietly doing nothing.)

The control is the checkbox above Start, "Also run the final answer in my editor", with the terms under it in the words above: what the run keeps for its own read (200 rows, 10 seconds), what the editor keeps (the 500-row limit), what is given up (the time limit), and what happens instead when the run declines to run it for you. On a SQLite connection one more line appears, because there the missing time limit means something different: "On SQLite a read is not interrupted when it runs long: it blocks other writers and this application until it finishes."

It is decided when the run is opened and cannot be changed afterwards — the request that opens the run is the only place it is set, and it is recorded on the run itself. So the checkbox stops responding while a run is open and says why; tick it before you press Start, and start a new run to change your mind. (The same field is accepted by POST /api/agent/runs as autoExecute, absent meaning off.)

The re-run is at the editor's default 500 rows even if you had widened this tab. "Show unlimited rows" is a choice you made about a statement you wrote; a statement the run hands over does not inherit it, and it is run at the default limit whatever the tab was last set to.

A statement is only run for you when all three of these hold:

  1. This run executed that exact statement itself. A final statement wider than anything the run ran is never run for you — and one it only explained was never executed.
  2. The engine's plan for it reads as cheap: on PostgreSQL an indexed access path and an estimated cost of at most 50 000; on SQLite every step a SEARCH. Anything unknown or unreadable counts as risky, and on SQLite that is deliberate — a read there is not interrupted when it runs long, it blocks other writers and this application until it finishes.
  3. The run measured its own execution of it at 2 000 ms or less.

When any of them fails the statement is put in your editor and left unrun, and the run says which one failed"Not run for you: … so this one is yours to run." A statement sitting there unrun is the feature working, not the feature failing.

Nothing is added to the statement. No LIMIT is injected: a chart of 200 of 4 000 regions would look like a complete chart, and every number on it would be right, which is worse than an obvious error.

And it is only ever run on the connection you started the run on. The statement itself cannot reach anywhere else — the server runs it on the connection recorded on the run, not on whatever the editor is pointed at. But if you switch the editor to a different connection while a run is going, the statement is not run at all: the rows would have landed in a tab connected somewhere else and been read as that database's answer. The timeline says so beside the answer — the run recorded that it handed the statement over, and this is the app telling you it did not carry that out. The statement is still there: take it with the control beside the entry and run it yourself, on whichever connection you are on now.

There is no ledger entry for the re-run. The run may already have finished by the time it happens, and a finished run's record does not take additions. The timeline records that the run handed the statement over, and says that what happened next is visible in the editor.


What "answered" means

A run's ending and its verdict are two different facts, and the rail states them separately.

The status word — succeeded, failed, cancelled — says how the run ended. It does not say whether you got an answer: a run whose model stopped without composing a report ends succeeded, and a run that ran out of turns ends failed, and neither answered anything. This was not a hypothesis; both were observed on live runs, which is why the verdict is a separate field (timeline.ts:452-461, and docs/AGENT.md for the mechanism).

So the last line of a run reads "Run answered" or "Run did not answer" (answeredHeadline, timeline.ts:328-329). A ledger written before verdicts existed carries none, and such a run keeps the status word it always had (Run succeeded) rather than being given a judgement nobody made.

Under it, when the run fell short, is one sentence naming what it did not produce. These are the whole vocabulary (SHORTFALL_SENTENCES, timeline.ts:335-343):

The run did not answer becauseThe sentence you get
no-report"The run finished without composing a cited report, so nothing it found was written down."
empty-evidence"Every result the report cited came back empty, so the answer rests on nothing."
no-plan"The run produced no plan at all."
no-statement"The run described how it would approach the question and never wrote the statement, and it did not say what was missing either."
no-plan-comparison"No before-and-after plan comparison was recorded, and no index was recommended: a query optimization rests on one or the other."
no-plan-evidence"The index was recommended without citing a plan this run read, so nothing the engine said backs it."
no-table-profile"No table was profiled, so the state of the data was never established."
no-answer"The run reported what it found but never produced an answer to show, so there is nothing to put in front of you."
answer-uncited"The run presented one result as the answer and its report rests on other evidence entirely, so the claims and the picture are not about the same thing."
cancelled"The run was stopped before it could finish."

A run you stopped reports cancelled rather than the output it was missing: a stop is not a defect of the run (goal-verifier.ts:166,172).

If the run had no shortfall to report, the line under the verdict says how the loop ended instead (STOP_SENTENCES, timeline.ts:267-282) — for example "The run reached its step limit before it finished. What it had gathered is above."

The shortfall wins over the ending in both modes (describeEnding, timeline.ts:305-315), and one ending is where that matters. When the model simply stops, an Agent run that composed no claims also carries the no-report shortfall (verifyInvestigationGoal, goal-verifier.ts:172), so the sentence you actually read is the shortfall one — "The run finished without composing a cited report, so nothing it found was written down." The stop sentence for that ending, "The model stopped without composing a cited report.", is what a run whose ledger carries no verdict at all shows. In Plan mode the same ending is not a shortfall by itself — that mode has no report to compose — so its own wording is what appears: "The model finished its plan and stopped. Planning mode has no tools, so it composed no report.", which is how a good Plan run ends. It is not enough to have spoken, though: verifyPlanningGoal asks for the mode's actual deliverable, so a run that stopped after a lecture — no statement drafted and no NO STATEMENT: refusal — carries the no-statement shortfall, and that sentence wins over the ending one exactly as above.

And when the run failed for a reason that is not about the answer at all, that sentence wins over both, and also appears next to the Start button so you can fix it before starting another (FAILURE_SENTENCES, timeline.ts:189-198; rendered at AgentRail.tsx:506-510): the model provider not being configured or reachable, limiting this key's requests, rejecting the credentials, an engine with no read-only execution profile, a connection that no longer resolves, or an internal failure whose reason is in the server log.


The budget meter's numbers

The meter above the timeline shows three gauges, and it shows only what the server actually enforces and the ledger actually records (AgentRail.tsx:662-699, gauges built in timeline.ts:668-672):

GaugeReadsThe limit, and where it comes from
Statementsused / 30 on an investigationmaxStatementsPerRun. Every statement the pipeline allowed and invoked — catalog reads, drafts and repairs alike
Database timeused / 90.0 s on an investigationmaxTotalRunMs. Database time only — the elapsed time completed reads reported, not the wall clock
Repair attemptsused / 3AGENT_MAX_REPAIR_ATTEMPTS. Only statements that failed at the database consume one; a policy denial does not

Under them, one line states the three ceilings that nothing durable counts against, so they are given as ceilings rather than as gauges: "Each statement gets 10.0 s, each drive 7.5 min and at most 36 model turns."

Every number on the meter is the one the server is enforcing on THIS run, because the ceilings differ per workflow and both halves of the meter read the workflow off the run's own header. So the meter changes with the run, not with the buttons: pick another workflow while a run is in flight and the figures stay the run's.

WorkflowModel turnsStatementsEach driveDatabase time
Investigate36307.5 min90 s
Optimize36307.5 min90 s
Assess484510.5 min135 s
Operations20125.0 min60 s
Analyze604215.0 min180 s

These figures are approved and pending live measurement. They are the starting point a measurement confirms or corrects, not numbers read off measured runs. Operations is the small row on purpose: it sends no SQL, so it never spends a statement drafting one and never repairs one, and its readings come from a closed set of six kinds. Analyze is the large row for the opposite reason: it iterates towards an aggregate rather than repeating a reading, and a GROUP BY over a fact table costs far more database time than a catalog read.

If you self-host behind a reverse proxy, Analyze needs a longer read timeout than the default. A 15-minute drive holds one streaming response open for its whole life, and nginx's default proxy_read_timeout is 60 seconds — so a long Analyze run has its stream cut and the rail loses it, even though the run itself survives on the server. docs/AGENT.md ("Deployment") gives the setting for nginx, ingress-nginx and the common PaaS routers.

Before any run is open there is no header to read, and the meter shows the investigation figures — the same reading the server takes for a ledger that names no workflow.

There is no token gauge, and its absence is deliberate: this build enforces no token budget, so a figure would mean nothing (docs/BACKLOG.md B10).

A run that ends early was asked to stop. When a run comes within 2 model turns or 20 seconds of either of those ceilings, the server tells it once — in its own words, not the database's — that this is its last turn and that it should report what it has established now. So a run that finishes well short of every figure on the meter is usually a run that took that offer, and its report is a partial answer rather than a missing one. The bar does not move when it happens: a claim still has to cite something the run read, so a forced report is a cited report or it is no report at all. Nothing is asked of a Plan run, which has no report tool to call. The meter says this under the ceilings.

Then the caveat, which is the part worth reading twice (AgentRail.tsx:692-699):

  • Every ceiling is per drive. A run resumed after a restart starts each of them again, so these totals can read past a single drive's ceiling.
  • Every figure is a floor, never a ceiling. The ledger records less than the server charges: the schema capture's catalog reads are not itemized, a statement that failed at the database records no duration, and a completed read reports the engine's own elapsed time rather than the span the budget was charged (docs/BACKLOG.md B12, B13).
  • On SQLite a statement over its timeout is refused once it returns, not interrupted while it runs. PostgreSQL preempts with SET LOCAL statement_timeout; SQLite does not, so there the timeout is a post-execution deadline.

When the model is refused

Before an Agent run opens, the server asks the configured model to call one trivial tool and watches what comes back (src/lib/agent/capability-probe.ts). If it establishes that the model cannot do the job, the run is refused with 422 and the rail renders a state of its own rather than a red error line (AgentRail.tsx:589-628):

  • the heading "This model cannot drive an agent run.";
  • The probe could not establish: one chip per capability, in this build's own labels — tool calling, schema-valid tool arguments, streaming (src/lib/agent/capability-labels.ts);
  • the server's own sentence, which is the only place the model's name and the endpoint's own words appear;
  • and what is still worth trying.

That last line has three registers, and which one you get is a fact about what the probe saw (refusalActionText, AgentRail.tsx:137-145). Where nothing the probe observed rules it out, you are offered Switch to Plan mode — because Plan mode is toolless and is never probed, so it is reachable with exactly the model that was just refused. The copy says "may still work" and not does: admission without probing is not proof of compatibility. Where the probe watched the endpoint answer without streaming, the offer is withdrawn instead, because Plan mode reads the same stream and would produce a run that reports success and contains nothing.

A refusal that says nothing about the model is not routed here. A bad key, a quota, or a 5xx start the run and are reported by the drive as model-unauthorized, model-rate-limited or model-unavailable, each with its own sentence.


Running the agent on a local model (Ollama)

Ollama is a first-class path: LLM_PROVIDER=ollama with LLM_API_URL=http://localhost:11434/v1 reaches the OpenAI-compatible endpoint through the same adapter as the openai and custom kinds (src/lib/agent/provider-registry.ts:121-132,154-159), and no key is required — the adapter sends a placeholder rather than leaving apiKey undefined, so an ambient OPENAI_API_KEY cannot leak in (provider-registry.ts:86,105-111).

The model decides whether a local deployment can run the agent — not the endpoint. This is worth stating precisely, because it is the opposite of what the mechanism suggests. The capability probe sends toolChoice: "required" (capability-probe.ts:253), which the OpenAI-compatible provider serializes as tool_choice — and Ollama documents tool_choice as an unsupported field on /v1/chat/completions (https://docs.ollama.com/openai), with forcing a tool call listed under future improvements in its own tool-support announcement (https://ollama.com/blog/tool-support). So on Ollama the probe's forcing mechanism does not apply, and the verdict rests on the model volunteering the call.

What was measured

Against a real Ollama at http://localhost:11434/v1, driven through this repository's own createAgentModel + probeAgentModel — no mocks, no fixtures:

ModelVerdictElapsed
qwen3.5:4bsupported: true — tool calling, structured output and streaming all established51.5 s first probe with the weights cold on disk; 3.2 s immediately repeated (2026-08-13). Re-measured 2026-08-14: 4.7 s warm, 4.6 s after ollama stop with the weights still in the OS page cache
gemma3:270msupported: false, missing: [toolCalling, structuredOutput, streaming], disproved: [], detail "The endpoint refused the tool request with HTTP 400: registry.ollama.ai/library/gemma3:270m does not support tools."128 ms (2026-08-13); 121 ms (2026-08-14)

The 2026-08-13 figures are from the probe recorded for #331 T6; the 2026-08-14 figures were measured again for this page against the same endpoint. Both readings agree on the verdicts.

Three operator facts follow from that, and each is a reading of the numbers above rather than a recommendation copied from a vendor page:

  1. A model with native tool support passes on Ollama today, despite tool_choice being ignored. qwen3.5:4b volunteered the call. So pick from the families that advertise tool support, and confirm it with a probe — Ollama's own tag metadata lists "tools" among the model's capabilities, which is the cheapest thing to check first.
  2. A model without tool support is refused, and the refusal is cheap and correct. 121 ms, and the endpoint's own words are what you are shown. That model can still be used for Plan mode, which the rail offers there — disproved is empty, so nothing was watched failing to stream.
  3. The first probe of a cold model pays the load. 51.5 s against 3.2 s warm. A probe that looks hung on a fresh Ollama is usually a model being read from disk.

Reproduce either reading against your own endpoint before trusting it here — that is the whole point of the probe existing.


What the agent does not do

Stated plainly, because a surface that hides its edges is the one that surprises you:

  • It cannot write. Every database reach the agent makes goes through the agent's own audited pipeline — the policy decision, the audit event and the budget accounting that executeAuditedOperation performs before the driver is touched (src/lib/db/operations/execution.ts:129, reached only from src/lib/agent/tools.ts:844) — under a read-only execution profile whose boundary is database-native rather than a parser: a read-only transaction on PostgreSQL, PRAGMA query_only re-asserted per statement on SQLite. Writes and DDL are refused before the database is reached. See docs/SECURITY.md row 3.4.
  • That pipeline is the agent's, not the application's. It is worth saying plainly, because the wording used to imply otherwise: a statement you run yourself in the editor does not pass through it. /api/db/query calls the provider directly (src/app/api/db/query/route.ts:44), so an editor query is neither policy-checked nor written to the agent audit trail. The controls above describe what the agent is held to, not a guarantee the whole product enforces.
  • Agent mode runs on PostgreSQL and SQLite only. The read-only profile has to be implemented by the provider, and only two do: queryReadOnly exists on postgres.ts:870 and sqlite.ts:397. Acquiring a profiled provider for any other engine raises PROFILE_UNSUPPORTED_BY_PROVIDER (src/lib/db/factory.ts:437), which the runtime reports as engine-unsupported (src/lib/agent/runtime.ts:199) — the rail says so in as many words (src/components/agent/timeline.ts:195). So on MySQL, Oracle, SQL Server, MongoDB, Redis, ClickHouse, Druid and Couchbase an Agent-mode run cannot read anything. It also covers the bundled LibreDB sample connection, whose provider implements no queryReadOnly (src/lib/db/providers/embedded/libredb.ts) — the bundled SQLite sample is the seeded connection to try a run against (src/lib/seed/sqlite-sample.ts:131). Plan mode still opens on every connection — the model is toolless there, so no profile has to be acquired for it — but its grounding takes the same path and therefore the same two engines. On any other connection a Plan run starts, says no inventory could be read for it, and is asked to refuse rather than to invent table names.
  • It never executes a recommendation, and never applies one to your editor by itself. The single exception anywhere in the rail is auto-execute, which is off unless the run was opened with it, and which covers only the answer's own statement under the three conditions above.
  • It cannot be paused or resumed from the rail. There is a Stop control and nothing standing in for a capability this build does not have (docs/BACKLOG.md B11).
  • A stopped run stops at its next checkpoint, not instantly: cancellation is enforced by the run loop's own persisted state, and the checkpoint sits in the step that reaches a database. A run that was already composing its report therefore finishes it and answers — twice on 2026-08-12 it did, 2.4 seconds after the Stop. That is the contract rather than a defect, so what changed in #356 is that the ending now says it: "A stop was requested before this ending: the run took no further database step, and finished what it already had in hand."
  • A run's stored rows do not outlive it, so a report can outlive the rows its citations point at (docs/BACKLOG.md B15). A result opens in the grid, the explain view or the charts view — whichever the run's own record names — and cannot be exported from any of them, because Export writes the tab's own rows (B34).
  • An interrupted run is resumable but is not resumed on its own — nothing enqueues a drive yet (docs/BACKLOG.md B9).
  • It reads what your connection's role can read. The declared-target allowlist, the statement guard and the role's own grants are the whole boundary on out-of-scope reads (docs/BACKLOG.md, "Agent M1 deferrals", A3).

What it sends to your model provider, and what it does not, is a page of its own: docs/AGENT_DATA_FLOW.md.