diff --git a/.gitignore b/.gitignore
index 740f3993c..a06bad8c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -268,6 +268,9 @@ renv.lock
# Planning documents (local only)
docs/plans/
+# Screenshot capture script (local only)
+pkg-py/docs/_screenshots/
+
# Playwright MCP
.playwright-mcp/
diff --git a/CLAUDE.md b/CLAUDE.md
index 4170fa304..98873f00e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -69,13 +69,15 @@ make py-build
make py-docs
```
-Before finishing your implementation or committing any code, you should run:
+Before committing any Python code, you must run all three checks and confirm they pass:
```bash
uv run ruff check --fix pkg-py --config pyproject.toml
+make py-check-types
+make py-check-tests
```
-To get help with making sure code adheres to project standards.
+Do not commit or push until all three pass.
### R Package
diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md
index a02fe5f68..ff0e3f084 100644
--- a/pkg-py/CHANGELOG.md
+++ b/pkg-py/CHANGELOG.md
@@ -9,16 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### New features
+* Added a `"visualize"` tool that lets the LLM create inline Altair charts from natural language requests using [ggsql](https://github.com/posit-dev/ggsql) — a SQL extension for declarative data visualization. Include it via `tools=("query", "visualize")` (or alongside `"update"`). Charts render inline in the chat with fullscreen support, a "Show Query" toggle, and Save as PNG/SVG. Install the optional dependencies with `pip install querychat[viz]`. (#219)
+
+* The `querychat_query` tool now accepts an optional `collapsed` parameter. When `collapsed=True`, the result card starts collapsed so preparatory or exploratory queries don't clutter the conversation. The LLM is guided to use this automatically when running queries before a visualization.
+
+* Added support for Snowflake Semantic Views. When connected to Snowflake (via SQLAlchemy or Ibis), querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200)
+
* `QueryChat()` now supports deferred chat client initialization. Pass `client=` to `server()` to provide a session-scoped chat client, enabling use cases where API credentials are only available at session time (e.g., Posit Connect managed OAuth tokens). When no `client` is specified anywhere, querychat resolves a sensible default from the `QUERYCHAT_CLIENT` environment variable (or `"openai"`). (#205)
### Improvements
* When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208)
-### New features
-
-* Added support for Snowflake Semantic Views. When connected to Snowflake (via SQLAlchemy or Ibis), querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200)
-
## [0.5.1] - 2026-01-23
### New features
diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml
index df2576e49..927859ba4 100644
--- a/pkg-py/docs/_quarto.yml
+++ b/pkg-py/docs/_quarto.yml
@@ -50,6 +50,7 @@ website:
- models.qmd
- data-sources.qmd
- context.qmd
+ - visualize.qmd
- section: "Build custom apps"
contents:
- build-intro.qmd
@@ -114,6 +115,8 @@ quartodoc:
signature_name: short
- name: tools.tool_reset_dashboard
signature_name: short
+ - name: tools.tool_visualize
+ signature_name: short
filters:
- "interlinks"
diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd
index 009f6cfd0..460d36cc7 100644
--- a/pkg-py/docs/build.qmd
+++ b/pkg-py/docs/build.qmd
@@ -31,6 +31,14 @@ from querychat.data import titanic
qc = QueryChat(titanic(), "titanic")
```
+::: {.callout-tip}
+### Visualization support
+
+querychat supports an optional visualization tool that lets the LLM create inline charts.
+Enable it by including `"visualize"` in the `tools` parameter.
+See [Visualizations](visualize.qmd) for details.
+:::
+
::: {.callout-note collapse="true"}
## Quick start with `.app()`
diff --git a/pkg-py/docs/images/viz-bar-chart.png b/pkg-py/docs/images/viz-bar-chart.png
new file mode 100644
index 000000000..0a7033651
Binary files /dev/null and b/pkg-py/docs/images/viz-bar-chart.png differ
diff --git a/pkg-py/docs/images/viz-fullscreen.png b/pkg-py/docs/images/viz-fullscreen.png
new file mode 100644
index 000000000..fbefca3fb
Binary files /dev/null and b/pkg-py/docs/images/viz-fullscreen.png differ
diff --git a/pkg-py/docs/images/viz-scatter.png b/pkg-py/docs/images/viz-scatter.png
new file mode 100644
index 000000000..db25bfe2a
Binary files /dev/null and b/pkg-py/docs/images/viz-scatter.png differ
diff --git a/pkg-py/docs/images/viz-show-query.png b/pkg-py/docs/images/viz-show-query.png
new file mode 100644
index 000000000..fc9ae6384
Binary files /dev/null and b/pkg-py/docs/images/viz-show-query.png differ
diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd
index 88b0da5c5..8d119ae3b 100644
--- a/pkg-py/docs/index.qmd
+++ b/pkg-py/docs/index.qmd
@@ -75,6 +75,11 @@ querychat can also handle more general questions about the data that require cal
{fig-alt="Screenshot of the querychat's app with a summary statistic inlined in the chat." class="lightbox shadow rounded mb-3"}
+querychat can also create visualizations, powered by [ggsql](https://ggsql.org/) and [Altair](https://altair-viz.github.io/).
+With the [visualization tool](visualize.qmd) enabled, ask for a chart and it appears inline in the conversation:
+
+{fig-alt="Screenshot of querychat with an inline bar chart showing survival rate by passenger class." class="lightbox shadow rounded mb-3"}
+
## Web frameworks
While the examples above use [Shiny](https://shiny.posit.co/py/), querychat also supports [Streamlit](https://streamlit.io/), [Gradio](https://gradio.app/), and [Dash](https://dash.plotly.com/). Each framework has its own `QueryChat` class under the relevant sub-module, but the methods and properties are mostly consistent across all of them.
diff --git a/pkg-py/docs/tools.qmd b/pkg-py/docs/tools.qmd
index e438e1bde..44301f1d4 100644
--- a/pkg-py/docs/tools.qmd
+++ b/pkg-py/docs/tools.qmd
@@ -6,7 +6,7 @@ querychat combines [tool calling](https://posit-dev.github.io/chatlas/get-starte
One important thing to understand generally about querychat's tools is they are Python functions, and that execution happens on _your machine_, not on the LLM provider's side. In other words, the SQL queries generated by the LLM are executed locally in the Python process running the app.
-querychat provides the LLM access two tool groups:
+querychat provides the LLM access to three tool groups:
1. **Data updating** - Filter and sort data (without sending results to the LLM).
2. **Data analysis** - Calculate summaries and return results for interpretation by the LLM.
@@ -52,6 +52,40 @@ app = qc.app()
{fig-alt="Screenshot of the querychat's app with a summary statistic inlined in the chat." class="lightbox shadow rounded mb-3"}
+## Data visualization
+
+When a user asks for a chart or visualization, the LLM generates a [ggsql](https://ggsql.org/) query — standard SQL extended with a `VISUALISE` clause — and requests a call to the `visualize` tool.
+This tool:
+
+1. Executes the SQL portion of the query
+2. Renders the `VISUALISE` clause as an Altair chart
+3. Displays the chart inline in the chat
+
+Unlike the data updating tools, visualization queries don't affect the dashboard filter.
+They query the full dataset independently, and each call produces a new inline chart message in the chat.
+
+The inline chart includes controls for fullscreen viewing, saving as PNG/SVG, and a "Show Query" toggle that reveals the underlying ggsql code.
+
+To use the visualization tool, first install the `viz` extras:
+
+```bash
+pip install "querychat[viz]"
+```
+
+Then include `"visualize"` in the `tools` parameter (it is not enabled by default):
+
+```{.python filename="viz-app.py"}
+from querychat import QueryChat
+from querychat.data import titanic
+
+qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize"))
+app = qc.app()
+```
+
+{fig-alt="Screenshot of querychat with an inline scatter plot." class="lightbox shadow rounded mb-3"}
+
+See [Visualizations](visualize.qmd) for more details.
+
## View the source
If you'd like to better understand how the tools work and how the LLM is prompted to use them, check out the following resources:
@@ -65,3 +99,4 @@ If you'd like to better understand how the tools work and how the LLM is prompte
- [`prompts/tool-update-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-update-dashboard.md)
- [`prompts/tool-reset-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-reset-dashboard.md)
- [`prompts/tool-query.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-query.md)
+- [`prompts/tool-visualize.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-visualize.md)
diff --git a/pkg-py/docs/visualize.qmd b/pkg-py/docs/visualize.qmd
new file mode 100644
index 000000000..e8d720b68
--- /dev/null
+++ b/pkg-py/docs/visualize.qmd
@@ -0,0 +1,104 @@
+---
+title: Visualizations
+lightbox: true
+---
+
+querychat can create charts inline in the chat.
+When you ask a question that benefits from a visualization, the LLM writes a query using [ggsql](https://ggsql.org/) — a SQL-like visualization grammar — and renders an [Altair](https://altair-viz.github.io/) chart directly in the conversation.
+
+## Getting started
+
+Visualization requires two steps:
+
+1. **Install the `viz` extras:**
+
+ ```bash
+ pip install "querychat[viz]"
+ ```
+
+2. **Include `"visualize"` in the `tools` parameter:**
+
+ ```{.python filename="app.py"}
+ from querychat import QueryChat
+ from querychat.data import titanic
+
+ qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize"))
+ app = qc.app()
+ ```
+
+Ask something like "Show me survival rate by passenger class as a bar chart" and querychat will generate and display the chart inline:
+
+{fig-alt="Bar chart showing survival rate by passenger class." class="lightbox shadow rounded mb-3"}
+
+## Choosing tools
+
+The `tools` parameter controls which capabilities the LLM has access to.
+By default, only `"query"` and `"update"` are enabled — visualization must be opted into explicitly.
+
+To enable only query and visualization (no dashboard filtering):
+
+```{.python}
+qc = QueryChat(titanic(), "titanic", tools=("query", "visualize"))
+```
+
+See [Tools](tools.qmd) for a full reference on available tools and what each one does.
+
+## Custom apps
+
+The example below shows a minimal custom Shiny app using only the `"query"` and `"visualize"` tools.
+It omits `"update"` to focus entirely on data analysis and visualization rather than dashboard filtering:
+
+```{.python filename="app.py"}
+{{< include /../examples/10-viz-app.py >}}
+```
+
+## What you can ask for
+
+querychat can generate a wide range of chart types.
+Some example prompts:
+
+- "Show me a bar chart of survival rate by passenger class"
+- "Scatter plot of age vs fare, colored by survival"
+- "Line chart of average fare over time"
+- "Histogram of passenger ages"
+- "Facet survival rate by class and sex"
+
+The LLM chooses an appropriate chart type based on your question, but you can always be specific.
+If you ask for a bar chart, you'll get a bar chart.
+
+{fig-alt="Scatter plot of age vs fare colored by survival status." class="lightbox shadow rounded mb-3"}
+
+::: {.callout-tip}
+If you don't like the chart, ask the LLM to adjust it — for example, "make the dots bigger" or "use a log scale on the y-axis".
+:::
+
+## Chart controls
+
+Each chart has controls in its footer:
+
+**Fullscreen** — Click the expand icon to view the chart in fullscreen mode.
+
+{fig-alt="A chart displayed in fullscreen mode." class="lightbox shadow rounded mb-3"}
+
+**Save** — Download the chart as a PNG or SVG file.
+
+**Show Query** — Expand the footer to see the ggsql query used to generate the chart.
+
+{fig-alt="A chart with the Show Query footer expanded, showing the ggsql query." class="lightbox shadow rounded mb-3"}
+
+## How it works
+
+1. **The LLM generates a ggsql query** — a SQL-like grammar that describes both data transformation and visual encoding in a single statement.
+2. **The SQL is executed** — querychat runs the data portion of the query against your data source locally.
+3. **The VISUALISE clause is rendered** — the result is passed to Altair, which produces a Vega-Lite chart specification.
+4. **The chart appears inline** — the chart is streamed back into the conversation as an interactive widget.
+
+Note that visualization queries are independent of any active dashboard filter set by the `update` tool.
+They always run against the full dataset.
+
+Learn more about the ggsql grammar at [ggsql.org](https://ggsql.org/).
+
+## See also
+
+- [Tools](tools.qmd) — Understand what querychat can do under the hood
+- [Provide context](context.qmd) — Help the LLM understand your data better
diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py
new file mode 100644
index 000000000..fe9ef6dc8
--- /dev/null
+++ b/pkg-py/examples/10-viz-app.py
@@ -0,0 +1,17 @@
+from querychat.express import QueryChat
+from querychat.data import titanic
+
+from shiny.express import ui, app_opts
+
+# Omits "update" tool — this demo focuses on query + visualization only
+qc = QueryChat(
+ titanic(),
+ "titanic",
+ tools=("query", "visualize")
+)
+
+qc.ui()
+
+ui.page_opts(fillable=True, title="QueryChat Visualization Demo")
+
+app_opts(bookmark_store="url")
diff --git a/pkg-py/src/querychat/_icons.py b/pkg-py/src/querychat/_icons.py
index 2b7683da0..fc484c9c0 100644
--- a/pkg-py/src/querychat/_icons.py
+++ b/pkg-py/src/querychat/_icons.py
@@ -2,19 +2,35 @@
from shiny import ui
-ICON_NAMES = Literal["arrow-counterclockwise", "funnel-fill", "terminal-fill", "table"]
+ICON_NAMES = Literal[
+ "arrow-counterclockwise",
+ "bar-chart-fill",
+ "chevron-down",
+ "download",
+ "funnel-fill",
+ "graph-up",
+ "terminal-fill",
+ "table",
+]
-def bs_icon(name: ICON_NAMES) -> ui.HTML:
+def bs_icon(name: ICON_NAMES, cls: str = "") -> ui.HTML:
"""Get Bootstrap icon SVG by name."""
if name not in BS_ICONS:
raise ValueError(f"Unknown Bootstrap icon: {name}")
- return ui.HTML(BS_ICONS[name])
+ svg = BS_ICONS[name]
+ if cls:
+ svg = svg.replace('class="', f'class="{cls} ', 1)
+ return ui.HTML(svg)
BS_ICONS = {
"arrow-counterclockwise": '',
+ "bar-chart-fill": '',
+ "chevron-down": '',
+ "download": '',
"funnel-fill": '',
+ "graph-up": '',
"terminal-fill": '',
"table": '',
}
diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py
index d3bf29e26..ffa325aed 100644
--- a/pkg-py/src/querychat/_querychat_base.py
+++ b/pkg-py/src/querychat/_querychat_base.py
@@ -23,11 +23,13 @@
from ._shiny_module import GREETING_PROMPT
from ._system_prompt import QueryChatSystemPrompt
from ._utils import MISSING, MISSING_TYPE, is_ibis_table
+from ._viz_utils import has_viz_deps, has_viz_tool
from .tools import (
UpdateDashboardData,
tool_query,
tool_reset_dashboard,
tool_update_dashboard,
+ tool_visualize,
)
if TYPE_CHECKING:
@@ -35,8 +37,10 @@
from narwhals.stable.v1.typing import IntoFrame
-TOOL_GROUPS = Literal["update", "query"]
+ from ._viz_tools import VisualizeData
+TOOL_GROUPS = Literal["update", "query", "visualize"]
+DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query")
class QueryChatBase(Generic[IntoFrameT]):
"""
@@ -58,7 +62,7 @@ def __init__(
*,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -72,7 +76,7 @@ def __init__(
"Table name must begin with a letter and contain only letters, numbers, and underscores",
)
- self.tools = normalize_tools(tools, default=("update", "query"))
+ self.tools = normalize_tools(tools, default=DEFAULT_TOOLS)
self.greeting = greeting.read_text() if isinstance(greeting, Path) else greeting
# Store init parameters for deferred system prompt building
@@ -128,6 +132,7 @@ def _create_session_client(
tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING,
update_dashboard: Callable[[UpdateDashboardData], None] | None = None,
reset_dashboard: Callable[[], None] | None = None,
+ visualize: Callable[[VisualizeData], None] | None = None,
) -> chatlas.Chat:
"""Create a fresh, fully-configured Chat."""
spec = self._client_spec if isinstance(client_spec, MISSING_TYPE) else client_spec
@@ -152,6 +157,10 @@ def _create_session_client(
if "query" in resolved_tools:
chat.register_tool(tool_query(data_source))
+ if "visualize" in resolved_tools:
+ viz_fn = visualize or (lambda _: None)
+ chat.register_tool(tool_visualize(data_source, viz_fn))
+
return chat
def client(
@@ -160,6 +169,7 @@ def client(
tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING,
update_dashboard: Callable[[UpdateDashboardData], None] | None = None,
reset_dashboard: Callable[[], None] | None = None,
+ visualize: Callable[[VisualizeData], None] | None = None,
) -> chatlas.Chat:
"""
Create a chat client with registered tools.
@@ -167,11 +177,14 @@ def client(
Parameters
----------
tools
- Which tools to include: `"update"`, `"query"`, or both.
+ Which tools to include: `"update"`, `"query"`, `"visualize"`,
+ or a combination.
update_dashboard
Callback when update_dashboard tool succeeds.
reset_dashboard
Callback when reset_dashboard tool is invoked.
+ visualize
+ Callback when visualize tool succeeds.
Returns
-------
@@ -184,6 +197,7 @@ def client(
tools=tools,
update_dashboard=update_dashboard,
reset_dashboard=reset_dashboard,
+ visualize=visualize,
)
def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str:
@@ -293,14 +307,24 @@ def create_client(client: str | chatlas.Chat | None) -> chatlas.Chat:
def normalize_tools(
tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE,
default: tuple[TOOL_GROUPS, ...] | None,
+ *,
+ check_deps: bool = True,
) -> tuple[TOOL_GROUPS, ...] | None:
if tools is None or tools == ():
- return None
+ result = None
elif isinstance(tools, MISSING_TYPE):
- return default
+ result = default
elif isinstance(tools, str):
- return (tools,)
+ result = (tools,)
elif isinstance(tools, tuple):
- return tools
+ result = tools
else:
- return tuple(tools)
+ result = tuple(tools)
+ if not check_deps:
+ return result
+ if has_viz_tool(result) and not has_viz_deps():
+ raise ImportError(
+ "Visualization tools require ggsql, altair, shinywidgets, and "
+ "vl-convert-python. Install them with: pip install querychat[viz]"
+ )
+ return result
diff --git a/pkg-py/src/querychat/_querychat_core.py b/pkg-py/src/querychat/_querychat_core.py
index af0685e01..1dd132631 100644
--- a/pkg-py/src/querychat/_querychat_core.py
+++ b/pkg-py/src/querychat/_querychat_core.py
@@ -165,6 +165,8 @@ def format_tool_result(result: ContentToolResult) -> str:
return str(result)
+
+
def format_query_error(e: Exception) -> str:
"""Format a query error with helpful guidance."""
error_msg = str(e).lower()
diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py
index c25b923fc..f915e79f4 100644
--- a/pkg-py/src/querychat/_shiny.py
+++ b/pkg-py/src/querychat/_shiny.py
@@ -10,9 +10,10 @@
from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
from ._icons import bs_icon
-from ._querychat_base import TOOL_GROUPS, QueryChatBase
+from ._querychat_base import DEFAULT_TOOLS, TOOL_GROUPS, QueryChatBase
from ._shiny_module import ServerValues, mod_server, mod_ui
from ._utils import MISSING, MISSING_TYPE, as_narwhals
+from ._viz_utils import has_viz_tool
if TYPE_CHECKING:
from pathlib import Path
@@ -97,10 +98,11 @@ class QueryChat(QueryChatBase[IntoFrameT]):
tools
Which querychat tools to include in the chat client by default. Can be:
- A single tool string: `"update"` or `"query"`
- - A tuple of tools: `("update", "query")`
+ - A tuple of tools: `("update", "query", "visualize")`
- `None` or `()` to disable all tools
- Default is `("update", "query")` (both tools enabled).
+ Default is `("update", "query")`. The visualization tool (`"visualize"`)
+ can be opted into by including it in the tuple.
Set to `"update"` to prevent the LLM from accessing data values, only
allowing dashboard filtering without answering questions.
@@ -156,7 +158,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -172,7 +174,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -188,7 +190,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -204,7 +206,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -219,7 +221,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
- tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -245,7 +247,7 @@ def app(
"""
Quickly chat with a dataset.
- Creates a Shiny app with a chat sidebar and data table view -- providing a
+ Creates a Shiny app with a chat sidebar and data view -- providing a
quick-and-easy way to start chatting with your data.
Parameters
@@ -301,6 +303,7 @@ def app_server(input: Inputs, output: Outputs, session: Session):
greeting=self.greeting,
client=self._create_session_client,
enable_bookmarking=enable_bookmarking,
+ tools=self.tools,
)
@render.text
@@ -399,7 +402,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs):
A UI component.
"""
- return mod_ui(id or self.id, **kwargs)
+ return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs)
def server(
self,
@@ -506,6 +509,7 @@ def create_session_client(**kwargs) -> chatlas.Chat:
greeting=self.greeting,
client=create_session_client,
enable_bookmarking=enable_bookmarking,
+ tools=self.tools,
)
@@ -616,6 +620,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"),
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -632,6 +637,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -648,6 +654,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -664,6 +671,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -680,6 +688,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -695,6 +704,7 @@ def __init__(
id: Optional[str] = None,
greeting: Optional[str | Path] = None,
client: Optional[str | chatlas.Chat] = None,
+ tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS,
data_description: Optional[str | Path] = None,
categorical_threshold: int = 20,
extra_instructions: Optional[str | Path] = None,
@@ -714,6 +724,7 @@ def __init__(
table_name,
greeting=greeting,
client=client,
+ tools=tools,
data_description=data_description,
categorical_threshold=categorical_threshold,
extra_instructions=extra_instructions,
@@ -743,6 +754,7 @@ def __init__(
greeting=self.greeting,
client=self._create_session_client,
enable_bookmarking=enable,
+ tools=self.tools,
)
def sidebar(
@@ -804,7 +816,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs):
A UI component.
"""
- return mod_ui(id or self.id, **kwargs)
+ return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs)
def df(self) -> IntoFrameT:
"""
diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py
index 4264285bd..7b568afa9 100644
--- a/pkg-py/src/querychat/_shiny_module.py
+++ b/pkg-py/src/querychat/_shiny_module.py
@@ -1,10 +1,9 @@
from __future__ import annotations
-import copy
import warnings
from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Generic, Union
+from typing import TYPE_CHECKING, Generic, TypedDict, Union
import chatlas
import shinychat
@@ -13,7 +12,9 @@
from shiny import module, reactive, ui
from ._querychat_core import GREETING_PROMPT
-from .tools import tool_query, tool_reset_dashboard, tool_update_dashboard
+from ._viz_altair_widget import AltairWidget
+from ._viz_ggsql import execute_ggsql
+from ._viz_utils import has_viz_tool, preload_viz_deps_server, preload_viz_deps_ui
if TYPE_CHECKING:
from collections.abc import Callable
@@ -23,6 +24,8 @@
from shiny import Inputs, Outputs, Session
from ._datasource import DataSource
+ from ._querychat_base import TOOL_GROUPS
+ from ._viz_tools import VisualizeData
from .types import UpdateDashboardData
ReactiveString = reactive.Value[str]
@@ -30,11 +33,31 @@
ReactiveStringOrNone = reactive.Value[Union[str, None]]
"""A reactive string (or None) value."""
+
+class VizWidgetEntry(TypedDict):
+ """A bookmarked visualization widget: enough state to re-register on restore."""
+
+ widget_id: str
+ ggsql: str
+
+
CHAT_ID = "chat"
+class _DeferredStubChatClient:
+ """Placeholder chat client for deferred stub sessions."""
+
+ def __getattr__(self, _name: str):
+ raise RuntimeError(
+ "Chat client is unavailable during stub session before data_source is set."
+ )
+
+
+ServerClient = chatlas.Chat | _DeferredStubChatClient
+
+
@module.ui
-def mod_ui(**kwargs):
+def mod_ui(*, preload_viz: bool = False, **kwargs):
css_path = Path(__file__).parent / "static" / "css" / "styles.css"
js_path = Path(__file__).parent / "static" / "js" / "querychat.js"
@@ -47,6 +70,7 @@ def mod_ui(**kwargs):
ui.include_js(js_path),
),
tag,
+ preload_viz_deps_ui() if preload_viz else None,
)
@@ -76,18 +100,17 @@ class ServerValues(Generic[IntoFrameT]):
`.title()`, or set it with `.title.set("...")`. Returns
`None` if no title has been set.
client
- The session-specific chat client instance. This is a deep copy of the
- base client configured for this specific session, containing the chat
- history and tool registrations for this session only. This may be
- `None` during stub sessions when the client depends on deferred,
- session-scoped state.
+ Session chat client value.
+ For real sessions this is a `chatlas.Chat` created by the client
+ factory. For deferred stub sessions (where `data_source` is not set
+ yet), this is a placeholder client that raises when accessed.
"""
df: Callable[[], IntoFrameT]
sql: ReactiveStringOrNone
title: ReactiveStringOrNone
- client: chatlas.Chat | None
+ client: ServerClient
@module.server
@@ -98,14 +121,39 @@ def mod_server(
*,
data_source: DataSource[IntoFrameT] | None,
greeting: str | None,
- client: chatlas.Chat | Callable,
+ client: Callable[..., chatlas.Chat],
enable_bookmarking: bool,
+ tools: tuple[TOOL_GROUPS, ...] | None = None,
) -> ServerValues[IntoFrameT]:
# Reactive values to store state
sql = ReactiveStringOrNone(None)
title = ReactiveStringOrNone(None)
has_greeted = reactive.value[bool](False) # noqa: FBT003
+ if not callable(client):
+ raise TypeError("mod_server() requires a callable client factory.")
+
+ def update_dashboard(data: UpdateDashboardData):
+ sql.set(data["query"])
+ title.set(data["title"])
+
+ def reset_dashboard():
+ sql.set(None)
+ title.set(None)
+
+ viz_widgets: list[VizWidgetEntry] = []
+
+ def on_visualize(data: VisualizeData):
+ viz_widgets.append({"widget_id": data["widget_id"], "ggsql": data["ggsql"]})
+
+ def build_chat_client() -> chatlas.Chat:
+ return client(
+ update_dashboard=update_dashboard,
+ reset_dashboard=reset_dashboard,
+ visualize=on_visualize,
+ tools=tools,
+ )
+
# Short-circuit for stub sessions (e.g. 1st run of an Express app)
# data_source may be None during stub session for deferred pattern
if session.is_stub_session():
@@ -113,11 +161,15 @@ def mod_server(
def _stub_df():
raise RuntimeError("RuntimeError: No current reactive context")
+ stub_client = (
+ _DeferredStubChatClient() if data_source is None else build_chat_client()
+ )
+
return ServerValues(
df=_stub_df,
sql=sql,
title=title,
- client=client if isinstance(client, chatlas.Chat) else None,
+ client=stub_client,
)
# Real session requires data_source
@@ -127,27 +179,11 @@ def _stub_df():
"Set it via the data_source property before users connect."
)
- def update_dashboard(data: UpdateDashboardData):
- sql.set(data["query"])
- title.set(data["title"])
-
- def reset_dashboard():
- sql.set(None)
- title.set(None)
-
- # Set up the chat object for this session
- # Support both a callable that creates a client and legacy instance pattern
- if callable(client) and not isinstance(client, chatlas.Chat):
- chat = client(
- update_dashboard=update_dashboard, reset_dashboard=reset_dashboard
- )
- else:
- # Legacy pattern: client is Chat instance
- chat = copy.deepcopy(client)
+ # Build the session-specific chat client through QueryChat.client(...).
+ chat = build_chat_client()
- chat.register_tool(tool_update_dashboard(data_source, update_dashboard))
- chat.register_tool(tool_query(data_source))
- chat.register_tool(tool_reset_dashboard(reset_dashboard))
+ if has_viz_tool(tools):
+ preload_viz_deps_server()
# Execute query when SQL changes
@reactive.calc
@@ -211,6 +247,8 @@ def _on_bookmark(x: BookmarkState) -> None:
vals["querychat_sql"] = sql.get()
vals["querychat_title"] = title.get()
vals["querychat_has_greeted"] = has_greeted.get()
+ if viz_widgets:
+ vals["querychat_viz_widgets"] = viz_widgets
@session.bookmark.on_restore
def _on_restore(x: RestoreState) -> None:
@@ -221,9 +259,44 @@ def _on_restore(x: RestoreState) -> None:
title.set(vals["querychat_title"])
if "querychat_has_greeted" in vals:
has_greeted.set(vals["querychat_has_greeted"])
+ if "querychat_viz_widgets" in vals:
+ restored = restore_viz_widgets(
+ data_source, vals["querychat_viz_widgets"]
+ )
+ viz_widgets[:] = restored
return ServerValues(df=filtered_df, sql=sql, title=title, client=chat)
class GreetWarning(Warning):
"""Warning raised when no greeting is provided to QueryChat."""
+
+
+def restore_viz_widgets(
+ data_source: DataSource[IntoFrameT],
+ saved_widgets: list[VizWidgetEntry],
+) -> list[VizWidgetEntry]:
+ """Re-execute ggsql queries, register widgets, and return restored entries."""
+ from ggsql import validate
+ from shinywidgets import register_widget
+
+ restored: list[VizWidgetEntry] = []
+
+ for entry in saved_widgets:
+ widget_id = entry["widget_id"]
+ ggsql_str = entry["ggsql"]
+ try:
+ validated = validate(ggsql_str)
+ spec = execute_ggsql(data_source, validated)
+ altair_widget = AltairWidget.from_ggsql(spec, widget_id=widget_id)
+ register_widget(widget_id, altair_widget.widget)
+ restored.append(entry)
+ except Exception:
+ # If a query fails on restore (e.g. data changed), skip it.
+ # The placeholder will remain empty but the rest of the chat restores.
+ warnings.warn(
+ f"Failed to restore visualization widget '{widget_id}' on bookmark restore.",
+ stacklevel=2,
+ )
+
+ return restored
diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py
index 5a8445e93..0a57a70ba 100644
--- a/pkg-py/src/querychat/_system_prompt.py
+++ b/pkg-py/src/querychat/_system_prompt.py
@@ -6,6 +6,8 @@
import chevron
+from ._viz_utils import has_viz_tool
+
_SCHEMA_TAG_RE = re.compile(r"\{\{[{#^/]?\s*schema\b")
if TYPE_CHECKING:
@@ -83,7 +85,14 @@ def render(self, tools: tuple[TOOL_GROUPS, ...] | None) -> str:
"extra_instructions": self.extra_instructions,
"has_tool_update": "update" in tools if tools else False,
"has_tool_query": "query" in tools if tools else False,
+ "has_tool_visualize": has_viz_tool(tools),
"include_query_guidelines": len(tools or ()) > 0,
}
- return chevron.render(self.template, context)
+ prompts_dir = str(Path(__file__).parent / "prompts")
+ return chevron.render(
+ self.template,
+ context,
+ partials_path=prompts_dir,
+ partials_ext="md",
+ )
diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py
index 555e8e376..1c8f9f31b 100644
--- a/pkg-py/src/querychat/_utils.py
+++ b/pkg-py/src/querychat/_utils.py
@@ -4,8 +4,10 @@
import re
import warnings
from contextlib import contextmanager
+from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Optional, overload
+import chevron
import narwhals.stable.v1 as nw
from great_tables import GT
@@ -14,6 +16,50 @@
import ibis
import pandas as pd
+ import polars as pl
+ from narwhals.stable.v1.typing import IntoFrame
+
+
+_SCHEMA_DUMP_PATTERN = re.compile(
+ r"^\s*[\{\[]|'additionalProperties'|\"additionalProperties\"",
+)
+
+
+def truncate_error(error_msg: str, max_chars: int = 500) -> str:
+ if len(error_msg) <= max_chars:
+ return error_msg
+
+ lines = error_msg.split("\n")
+ meaningful: list[str] = []
+ truncated_by_schema = False
+ for line in lines:
+ if not line.strip():
+ truncated_by_schema = True
+ break
+ if _SCHEMA_DUMP_PATTERN.search(line):
+ truncated_by_schema = True
+ break
+ meaningful.append(line)
+
+ if truncated_by_schema and meaningful:
+ prefix = "\n".join(meaningful)
+ if len(prefix) > max_chars:
+ cut = prefix[:max_chars]
+ last_space = cut.rfind(" ")
+ if last_space > max_chars // 2:
+ cut = cut[:last_space]
+ prefix = cut
+ return prefix.rstrip() + "\n\n(error truncated)"
+
+ # No schema markers found (or nothing before them) — apply hard cap if needed
+ if len(error_msg) <= max_chars:
+ return error_msg
+
+ truncated = error_msg[:max_chars]
+ last_space = truncated.rfind(" ")
+ if last_space > max_chars // 2:
+ truncated = truncated[:last_space]
+ return truncated.rstrip() + "\n\n(error truncated)"
class MISSING_TYPE: # noqa: N801
@@ -171,14 +217,18 @@ def get_tool_details_setting() -> Optional[Literal["expanded", "collapsed", "def
return setting_lower
-def querychat_tool_starts_open(action: Literal["update", "query", "reset"]) -> bool:
+def querychat_tool_starts_open(
+ action: Literal[
+ "update", "query", "reset", "visualize"
+ ],
+) -> bool:
"""
Determine whether a tool card should be open based on action and setting.
Parameters
----------
action : str
- The action type ('update', 'query', or 'reset')
+ The action type ('update', 'query', 'reset', or 'visualize')
Returns
-------
@@ -290,3 +340,15 @@ def df_to_html(df, maxrows: int = 5) -> str:
table_html += f"\n\n*(Showing {maxrows} of {nrow_full} rows)*\n"
return table_html
+
+
+def to_polars(data: IntoFrame) -> pl.DataFrame:
+ """Convert any narwhals-compatible frame to a polars DataFrame."""
+ return as_narwhals(data).to_polars()
+
+
+def read_prompt_template(filename: str, **kwargs: object) -> str:
+ """Read and interpolate a prompt template file."""
+ template_path = Path(__file__).parent / "prompts" / filename
+ template = template_path.read_text()
+ return chevron.render(template, kwargs)
diff --git a/pkg-py/src/querychat/_viz_altair_widget.py b/pkg-py/src/querychat/_viz_altair_widget.py
new file mode 100644
index 000000000..00d40347d
--- /dev/null
+++ b/pkg-py/src/querychat/_viz_altair_widget.py
@@ -0,0 +1,187 @@
+"""Altair chart wrapper for responsive display in Shiny."""
+
+from __future__ import annotations
+
+import copy
+from typing import TYPE_CHECKING, Any, cast
+from uuid import uuid4
+
+from shiny.session import get_current_session
+
+from shiny import reactive
+
+if TYPE_CHECKING:
+ import altair as alt
+ import ggsql
+
+class AltairWidget:
+ """
+ An Altair chart wrapped in ``alt.JupyterChart`` for display in Shiny.
+
+ Always produces a ``JupyterChart`` so that ``shinywidgets`` receives
+ a consistent widget type and doesn't call ``chart.properties(width=...)``
+ (which fails on compound specs).
+
+ Simple charts use native ``width/height: "container"`` sizing.
+ Compound charts (facet, concat) get calculated cell dimensions
+ that are reactively updated when the output container resizes.
+ """
+
+ widget: alt.JupyterChart
+ widget_id: str
+
+ def __init__(
+ self,
+ chart: alt.TopLevelMixin,
+ *,
+ widget_id: str | None = None,
+ ) -> None:
+ import altair as alt
+
+ is_compound = isinstance(
+ chart,
+ (alt.FacetChart, alt.ConcatChart, alt.HConcatChart, alt.VConcatChart),
+ )
+
+ # Workaround: Vega-Lite's width/height: "container" doesn't work for
+ # compound specs (facet, concat, etc.), so we inject pixel dimensions
+ # and reconstruct the chart. Remove this branch when ggsql handles it
+ # natively: https://github.com/posit-dev/ggsql/issues/238
+ if is_compound:
+ chart = fit_chart_to_container(
+ chart, DEFAULT_COMPOUND_WIDTH, DEFAULT_COMPOUND_HEIGHT
+ )
+ else:
+ chart = chart.properties(width="container", height="container")
+
+ self.widget = alt.JupyterChart(chart)
+ self.widget_id = widget_id or f"querychat_viz_{uuid4().hex[:8]}"
+
+ # Reactively update compound cell sizes when the container resizes.
+ # Also part of the compound sizing workaround (issue #238).
+ if is_compound:
+ self._setup_reactive_sizing(self.widget, self.widget_id)
+
+ @classmethod
+ def from_ggsql(
+ cls, spec: ggsql.Spec, *, widget_id: str | None = None
+ ) -> AltairWidget:
+ from ggsql import VegaLiteWriter
+
+ writer = VegaLiteWriter()
+ return cls(writer.render_chart(spec), widget_id=widget_id)
+
+ @staticmethod
+ def _setup_reactive_sizing(widget: alt.JupyterChart, widget_id: str) -> None:
+ session = get_current_session()
+ if session is None:
+ return
+
+ @reactive.effect
+ def _sizing_effect():
+ width = session.clientdata.output_width(widget_id)
+ height = session.clientdata.output_height(widget_id)
+ if width is None or height is None:
+ return
+ chart = widget.chart
+ if chart is None:
+ return
+ chart = cast("alt.Chart", chart)
+ chart2 = fit_chart_to_container(chart, int(width), int(height))
+ # Must set widget.spec (a new dict) rather than widget.chart,
+ # because traitlets won't fire change events when the same
+ # chart object is assigned back after in-place mutation.
+ widget.spec = chart2.to_dict()
+
+ # Clean up the effect when the session ends to avoid memory leaks
+ session.on_ended(_sizing_effect.destroy)
+
+
+# ---------------------------------------------------------------------------
+# Compound chart sizing helpers
+#
+# Vega-Lite's `width/height: "container"` doesn't work for compound specs
+# (facet, concat, etc.), so we manually inject cell dimensions. Ideally ggsql
+# will handle this natively: https://github.com/posit-dev/ggsql/issues/238
+# ---------------------------------------------------------------------------
+
+DEFAULT_COMPOUND_WIDTH = 900
+DEFAULT_COMPOUND_HEIGHT = 450
+
+LEGEND_CHANNELS = frozenset(
+ {"color", "fill", "stroke", "shape", "size", "opacity"}
+)
+LEGEND_WIDTH = 120 # approximate space for a right-side legend
+
+
+def fit_chart_to_container(
+ chart: alt.TopLevelMixin,
+ container_width: int,
+ container_height: int,
+) -> alt.TopLevelMixin:
+ """
+ Return a copy of ``chart`` with cell ``width``/``height`` set.
+
+ The original chart is never mutated.
+
+ For faceted charts, divides the container width by the number of columns.
+ For hconcat/concat, divides by the number of sub-specs.
+ For vconcat, each sub-spec gets the full width.
+
+ Subtracts padding estimates so the rendered cells fill the container,
+ including space for legends when present.
+ """
+ import altair as alt
+
+ chart = copy.deepcopy(chart)
+
+ # Approximate padding; will be replaced when ggsql handles compound sizing
+ # natively (https://github.com/posit-dev/ggsql/issues/238).
+ padding_x = 80 # y-axis labels + title padding
+ padding_y = 120 # facet headers, x-axis labels + title, bottom padding
+ if has_legend(chart.to_dict()):
+ padding_x += LEGEND_WIDTH
+ usable_w = max(container_width - padding_x, 100)
+ usable_h = max(container_height - padding_y, 100)
+
+ if isinstance(chart, alt.FacetChart):
+ ncol = chart.columns if isinstance(chart.columns, int) else 1
+ cell_w = usable_w // max(ncol, 1)
+ chart.spec.width = cell_w
+ chart.spec.height = usable_h
+ elif isinstance(chart, alt.HConcatChart):
+ cell_w = usable_w // max(len(chart.hconcat), 1)
+ for sub in chart.hconcat:
+ sub.width = cell_w
+ sub.height = usable_h
+ elif isinstance(chart, alt.ConcatChart):
+ ncol = chart.columns if isinstance(chart.columns, int) else len(chart.concat)
+ cell_w = usable_w // max(ncol, 1)
+ for sub in chart.concat:
+ sub.width = cell_w
+ sub.height = usable_h
+ elif isinstance(chart, alt.VConcatChart):
+ cell_h = usable_h // max(len(chart.vconcat), 1)
+ for sub in chart.vconcat:
+ sub.width = usable_w
+ sub.height = cell_h
+
+ return chart
+
+
+def has_legend(vl: dict[str, object]) -> bool:
+ """Check if any encoding in the VL spec uses a legend-producing channel with a field."""
+ specs: list[dict[str, Any]] = []
+ if "spec" in vl:
+ specs.append(vl["spec"]) # type: ignore[arg-type]
+ for key in ("hconcat", "vconcat", "concat"):
+ if key in vl:
+ specs.extend(vl[key]) # type: ignore[arg-type]
+
+ for spec in specs:
+ for layer in spec.get("layer", [spec]): # type: ignore[union-attr]
+ enc = layer.get("encoding", {}) # type: ignore[union-attr]
+ for ch in LEGEND_CHANNELS:
+ if ch in enc and "field" in enc[ch]: # type: ignore[operator]
+ return True
+ return False
diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py
new file mode 100644
index 000000000..b1a950363
--- /dev/null
+++ b/pkg-py/src/querychat/_viz_ggsql.py
@@ -0,0 +1,106 @@
+"""Helpers for ggsql integration."""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+
+from ._utils import to_polars
+
+if TYPE_CHECKING:
+ import ggsql
+
+ from ._datasource import DataSource
+
+
+def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql.Spec:
+ """
+ Execute a pre-validated ggsql query against a DataSource, returning a Spec.
+
+ Executes the SQL portion through DataSource (preserving database pushdown),
+ then feeds the result into a ggsql DuckDBReader to produce a Spec.
+
+ Parameters
+ ----------
+ data_source
+ The querychat DataSource to execute the SQL portion against.
+ validated
+ A pre-validated ggsql query (from ``ggsql.validate()``).
+
+ Returns
+ -------
+ ggsql.Spec
+ The writer-independent plot specification.
+
+ """
+ from ggsql import DuckDBReader
+
+ visual = validated.visual()
+ if has_layer_level_source(visual):
+ # Short term, querychat only supports visual layers that can be replayed
+ # from one final SQL result. Long term, the cleaner fix is likely to use
+ # ggsql's native remote-reader execution path (for example via ODBC-backed
+ # Readers) instead of reconstructing multi-relation scope here.
+ raise ValueError(
+ "Layer-specific sources are not currently supported in querychat visual "
+ "queries. Rewrite the query so that all layers come from the final SQL "
+ "result."
+ )
+
+ pl_df = to_polars(data_source.execute_query(validated.sql()))
+ # Snowflake (and some other backends) uppercase unquoted identifiers,
+ # but the LLM writes lowercase aliases in the VISUALISE clause.
+ # DuckDB is case-insensitive, so lowercasing here lets both match.
+ pl_df.columns = [c.lower() for c in pl_df.columns]
+
+ reader = DuckDBReader("duckdb://memory")
+ table = extract_visualise_table(visual)
+
+ if table is not None:
+ # VISUALISE [mappings] FROM
— register data under the
+ # referenced table name and execute the visual part directly.
+ name = table[1:-1] if table.startswith('"') and table.endswith('"') else table
+ reader.register(name, pl_df)
+ return reader.execute(visual)
+ else:
+ # SELECT ... VISUALISE — no FROM in VISUALISE clause, so register
+ # under a synthetic name and prepend a SELECT.
+ reader.register("_data", pl_df)
+ return reader.execute(f"SELECT * FROM _data {visual}")
+
+
+def extract_visualise_table(visual: str) -> str | None:
+ """
+ Extract the table name from ``VISUALISE ... FROM