Replace the whole filter panel with one sentence.
Your users stop translating what they want into dropdowns and date pickers — they just say it. QueryForge turns that sentence into a typed query plan your config constrains, then compiles it to Postgres, MySQL or MongoDB with deterministic Go. The model never writes the query.
Why not just ask an LLM for SQL?
Because you can't check what comes back. Ask a model for SQL directly and it can invent a column, widen a filter, or hand you something only probably right. QueryForge never lets the model near a query string — it fills in a typed form, and ordinary Go does everything after that.
{{ row.a }}
One typed step between the sentence and the query.
The query plan has no node type that can write, so read-only isn't a rule someone has to remember — there's no way to express a mutation. Everything after the model is pure, testable Go.
{{ stage.desc }}
The same query plan compiles anywhere.
Postgres, MySQL and MongoDB ship today. A new backend is a generator, not a rewrite — the model and validator never change.
{{ activeDb.code }}
Two ways to get the same rows.
QueryForge isn't a UI — it's the logic underneath one. Flip the toggle: the panel on the left is what most apps ship today; the box on the right is all the UI QueryForge needs, because the interesting part happens in code you can read below it.
| id | status | amount | created_at | tags |
|---|---|---|---|---|
| 1001 | DELIVERED | 612 | 2026-07-28 | premium |
| 1002 | CANCELLED | 240 | 2026-06-30 | — |
| 1003 | DELIVERED | 58 | 2026-07-30 | express |
| 1004 | PENDING | 935 | 2026-08-01 | premium, express |
| 1005 | CANCELLED | 560 | 2026-07-14 | — |
{{ filterRowCount }} filter rows, 7 possible fields, 8 operators — a dropdown, an input and a remove button to wire up for each one. Add a field to your schema and this panel needs new frontend code before anyone can filter on it.
That's the whole UI. Everything below is what the sentence actually does — the part that makes this a backend, not a widget.
{{ qfCompiled }}
This exact sentence isn't wired into the canned demo — pick one of the presets above. In the real engine, any phrasing goes to the model next; the AST → validator → generator pipeline below runs exactly the same either way.
Scope filters: your own rules on every query.
Subscription, user, enterprise — values the caller already knows, never the user's to phrase. They're AND-ed onto the query root after the model answers, so the model never even learns the field exists.
engine.Translate(ctx, "delivered orders over 500 dollars", "sql", qf.Scope{
"subscriptionId": session.SubscriptionID,
"userId": session.UserID,
})
WHERE (subscriptionId = $1 AND userId = $2
AND status = $3 AND amount > $4)
Same config, four ways to call it.
A config file describing your data and a model API key in the environment. No server, no Docker, no database connection.
{{ activeCodeText }}
{{ activeInstall }}
Choosing a model is a config change.
List several and QueryForge tries them in order — a rate limit or outage on one falls through to the next, transparently.
Facts out. No logging decisions made for you.
A traced live request measured the deterministic half at 0.5ms against a 1,781ms model call — roughly 99.97% of a translation is one HTTP round trip. That's the one seam Observer reports on.
Attempt outcomes
Repair attempts
Which model answered
Scope values (tenant/user ids)
The API key
engine.SetObserver(func(ctx context.Context, e qf.Event) {
switch e.Kind {
case qf.EventModelCall:
log.Info("model", "latency", e.Latency,
"tokens", e.TotalTokens)
case qf.EventTranslate:
log.Info("translate", "outcome", e.Outcome,
"duration", e.Duration)
}
})