Shiny

Shiny is a web framework for building interactive data applications. It provides a reactive programming model that’s a natural fit for querychat.

In this guide, you’ll learn how to build Shiny apps with querychat to enable rich data exploration experiences where data views update based on natural language filters.

Screenshot of querychat running in a custom Shiny app.

Initialize QueryChat

The “main” QueryChat class is available directly from the top-level module, and it designed to work with Shiny (Core):

from querychat import QueryChat

For Shiny Express, import from querychat.express instead (similar to how you import from shiny.express):

from querychat.express import QueryChat

Once imported, initialize it with your data source:

from querychat.data import titanic

qc = QueryChat(titanic(), "titanic")
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 for details.

Remember, the simplest way to get started is with .app(), which gives a “pre-baked” Shiny app:

shiny-app.py
from querychat import QueryChat
from querychat.data import titanic

qc = QueryChat(titanic(), "titanic")
app = qc.app()

Run with:

shiny run shiny-app.py

Relevant methods

After initializing QueryChat, use .sidebar() or .ui() to place the chat interface in your app. As users interact with the chat, .df(), .sql(), and .title() automatically update to reflect the current query. Any Shiny outputs that depend on these reactive values will re-render automatically.

Method Description
.sidebar() Place the chat interface in a sidebar
.ui() Returns just the chat component for custom placement
.df() Current filtered/sorted DataFrame
.sql() Current SQL query (str | None)
.title() Short description of current filter (str | None)
Note

Shiny has two modes: Express (simple, script-based) and Core (explicit UI/server separation). With Core, call qc.server() in your server function and access reactives via the returned object (e.g., qc_vals.df()). With Express, access them directly on the QueryChat instance (e.g., qc.df()).

Basic sidebar

The most common pattern places chat in the sidebar with your custom filtered views in the main area:

from pathlib import Path

from shiny.express import render, ui
from querychat.express import QueryChat
from querychat.data import titanic

greeting = Path(__file__).parent / "greeting.md"

# 1. Provide data source to QueryChat
qc = QueryChat(titanic(), "titanic", greeting=greeting)

# 2. Add sidebar chat control
qc.sidebar()

# 3. Add a card with reactive title and data frame
with ui.card():
    with ui.card_header():
        @render.text
        def title():
            return qc.title() or "Titanic Dataset"

    @render.data_frame
    def data_table():
        return qc.df()
    
# 4. Set some page options (optional)
ui.page_opts(
    fillable=True,
    title="Titanic Dataset Explorer"
)
Deferred data sources

Some data sources, like database connections or reactive calculations, may need to be created within an active Shiny session. To help support this, QueryChat allows you to initialize without a data source and provide it later, like this:

deferred-app.py
from shiny.express import render, session, ui
from querychat.express import QueryChat

# Don't create connection until we have an actual session
if session.is_stub_session():
    conn = None
else:
    conn = get_user_connection(session)

qc = QueryChat(conn, "users")
qc.sidebar()

@render.data_frame
def table():
    return qc.df()
from pathlib import Path

from shiny import App, render, ui
from querychat import QueryChat
from querychat.data import titanic

greeting = Path(__file__).parent / "greeting.md"

# 1. Provide data source to QueryChat
qc = QueryChat(titanic(), "titanic", greeting=greeting)

app_ui = ui.page_sidebar(
    # 2. Create sidebar chat control
    qc.sidebar(),
    ui.card(
        ui.card_header(ui.output_text("title")),
        ui.output_data_frame("data_table"),
        fill=True,
    ),
    fillable=True
)


def server(input, output, session):
    # 3. Add server logic (to get reactive data frame and title)
    qc_vals = qc.server()

    # 4. Use the filtered/sorted data frame reactively
    @render.data_frame
    def data_table():
        return qc_vals.df()

    @render.text
    def title():
        return qc_vals.title() or "Titanic Dataset"


app = App(app_ui, server)
Deferred data sources

Some data sources, like database connections or reactive calculations, may need to be created within an active Shiny session. To help support this, QueryChat allows you to initialize without a data source and provide it later, like this:

deferred-app.py
from shiny import App, ui
from querychat import QueryChat

# Global scope - create QueryChat without data source
qc = QueryChat(None, "users")

app_ui = ui.page_sidebar(
    qc.sidebar(),
    ui.output_data_frame("table"),
)

def server(input, output, session):
    # Server scope - create connection with session credentials
    conn = get_user_connection(session)
    qc_vals = qc.server(data_source=conn)

    @render.data_frame
    def table():
        return qc_vals.df()

app = App(app_ui, server)

If your chat client also depends on session-scoped credentials, you can defer that too by passing it to qc.server(client=...) alongside the data_source.

Custom chat UI

Use .ui() to place the chat anywhere in your layout. Here we use it simply to place custom content in the sidebar alongside the chat (like a reset button):

from shiny.express import ui
from querychat.express import QueryChat

qc = QueryChat(data, "my_data")

with ui.sidebar():
    qc.ui()  # Chat component
    ui.hr()
    ui.input_action_button("reset", "Reset Filters", class_="w-100")
from shiny import ui
from querychat import QueryChat

qc = QueryChat(data, "my_data")

app_ui = ui.page_sidebar(
    ui.sidebar(
        qc.ui(),  # Chat component
        ui.hr(),
        ui.input_action_button("reset", "Reset Filters", class_="w-100"),
    ),
    # Main content here
)
Custom Shiny chat UIs

Learn more about customizing Shiny chat UIs in the Shiny Chat documentation.

Data views

The real power of querychat comes from connecting it to visualizations. Here’s an example showing both filtered data and a chart:

import plotly.express as px

from shiny.express import render, ui
from shinywidgets import render_plotly

from querychat.express import QueryChat
from querychat.data import titanic

qc = QueryChat(titanic(), "titanic")
qc.sidebar()

with ui.layout_columns():
    with ui.card():
        ui.card_header("Data Table")

        @render.data_frame
        def table():
            return qc.df()

    with ui.card():
        ui.card_header("Survival by Class")

        @render_plotly
        def survival_plot():
            d = qc.df().to_native()  # Convert for pandas groupby()
            summary = d.groupby('pclass')['survived'].mean().reset_index()
            return px.bar(summary, x='pclass', y='survived')

When users filter data through the chat (e.g., “show only children”), both charts update automatically.

Screenshot of a querychat app showing both a data table and a bar chart of survival by class.

A more complete example adds metrics, tabs, and multiple views:

titanic-dashboard.py
import plotly.express as px
from faicons import icon_svg
from querychat.data import titanic
from querychat.express import QueryChat
from shiny.express import render, ui
from shinywidgets import render_plotly

qc = QueryChat(titanic(), "titanic")
qc.sidebar()

with ui.layout_column_wrap(fill=False):
    with ui.value_box(showcase=icon_svg("users")):
        "Passengers"

        @render.text
        def count():
            return str(len(qc.df()))

    with ui.value_box(showcase=icon_svg("heart")):
        "Survival Rate"

        @render.text
        def survival():
            rate = qc.df()["survived"].mean() * 100
            return f"{rate:.1f}%"

    with ui.value_box(showcase=icon_svg("coins")):
        "Avg Fare"

        @render.text
        def fare():
            avg = qc.df()["fare"].mean()
            return f"${avg:.2f}"


with ui.layout_columns():
    with ui.card():
        with ui.card_header():
            "Data Table"

            @render.text
            def table_title():
                return f" - {qc.title()}" if qc.title() else ""

        @render.data_frame
        def data_table():
            return qc.df()

    with ui.card():
        ui.card_header("Survival by Class")

        @render_plotly
        def survival_by_class():
            df = qc.df().to_pandas()
            summary = df.groupby("pclass")["survived"].mean().reset_index()
            return px.bar(
                summary,
                x="pclass",
                y="survived",
                labels={"pclass": "Class", "survived": "Survival Rate"},
            )


with ui.layout_columns():
    with ui.card():
        ui.card_header("Age Distribution")

        @render_plotly
        def age_dist():
            df = qc.df()
            return px.histogram(df, x="age", nbins=30)

    with ui.card():
        ui.card_header("Fare by Class")

        @render_plotly
        def fare_by_class():
            df = qc.df()
            return px.box(df, x="pclass", y="fare", color="survived")


ui.page_opts(
    title="Titanic Survival Analysis",
    fillable=True,
    class_="bslib-page-dashboard",
)

Screenshot of a querychat app showing value boxes, a data table, and multiple plots.

Resetting filters

Add a reset button to clear filters and show all data:

ui.input_action_button("reset", "Reset Filters")

@reactive.effect
@reactive.event(input.reset)
def _():
    qc.sql("")
    qc.title(None)
ui.input_action_button("reset", "Reset Filters")

qc_vals = qc.server()

@reactive.effect
@reactive.event(input.reset)
def _():
    qc_vals.sql.set("")
    qc_vals.title.set(None)
Tip

Users can also ask the LLM to “reset” or “show all data” to clear filters through the chat interface.

Programmatic updates

You can update the query state programmatically using .sql() and .title() as setters. This is useful for adding preset filter buttons or linking filters to other UI controls.

Multiple tables

querychat can work with multiple related tables in a single chat interface, letting users query across tables, join data, and filter any table independently. Register additional tables with .add_table() after creating the QueryChat instance, then access per-table state through the .table() method.

Registering tables

Pass the first table when creating QueryChat, then call .add_table() for each additional table:

from querychat import QueryChat

qc = QueryChat(orders, "orders")
qc.add_table(customers, "customers")
qc.add_table(products, "products")

If your data lives in a SQLAlchemy engine or Ibis backend, use .add_tables() to register all tables in a single call:

from sqlalchemy import create_engine
import ibis

# SQLAlchemy
engine = create_engine("sqlite:///store.db")
qc = QueryChat()
qc.add_tables(engine)                        # all tables
qc.add_tables(engine, ["orders", "customers"])  # specific subset

# Ibis
backend = ibis.duckdb.connect("store.duckdb")
qc = QueryChat()
qc.add_tables(backend)                          # all tables
qc.add_tables(backend, ["orders", "customers"]) # specific subset

The LLM can query any registered table and write joins across them. You can inspect which tables are registered with qc.table_names().

Per-table reactive access

When working with multiple tables, access filtered data and SQL for each table individually using .table():

from shiny.express import render

qc.sidebar()

@render.data_frame
def orders_table():
    return qc.table("orders").df()

@render.data_frame
def customers_table():
    return qc.table("customers").df()
def server(input, output, session):
    qc_vals = qc.server()

    @render.data_frame
    def orders_table():
        return qc_vals.table("orders").df()

    @render.data_frame
    def customers_table():
        return qc_vals.table("customers").df()

Each table has its own .df(), .sql(), and .title() reactives that update independently when the user filters that specific table.

Tracking the active table

Use .current_table() to find out which table the LLM most recently queried. This is useful for auto-switching a tabbed UI to the relevant table:

@reactive.effect
def _():
    name = qc_vals.current_table()
    if name:
        ui.update_navs("table_tabs", selected=name)

Data dictionary

When working with multiple related tables, providing a data dictionary is strongly recommended. It tells the LLM how tables relate to each other, which columns are keys, and what domain terms mean — all of which help it write accurate joins and queries.

from pathlib import Path

qc = QueryChat(orders, "orders", data_dict=Path("data-dict.yaml"))
qc.add_table(customers, "customers")

See Provide context for the full data dictionary format.

Separate chat interfaces

If your tables are truly independent (not related), you may prefer separate QueryChat instances, each with its own chat interface:

multiple-datasets.py
from querychat.data import titanic
from querychat.express import QueryChat
from seaborn import load_dataset
from shiny.express import render, ui

penguins = load_dataset("penguins")

qc_titanic = QueryChat(titanic(), "titanic")
qc_penguins = QueryChat(penguins, "penguins")

with ui.sidebar():
    with ui.panel_conditional("input.navbar == 'Titanic'"):
        qc_titanic.ui()
    with ui.panel_conditional("input.navbar == 'Penguins'"):
        qc_penguins.ui()

with ui.nav_panel("Titanic"):

    @render.data_frame
    def titanic_table():
        return qc_titanic.df()


with ui.nav_panel("Penguins"):

    @render.data_frame
    def penguins_table():
        return qc_penguins.df()


ui.page_opts(
    id="navbar",
    title="Multiple Datasets with querychat",
    fillable=True,
)

Screenshot of a querychat app with two datasets: titanic and penguins.

See also

  • Greet users - Create welcoming onboarding experiences
  • Provide context - Help the LLM understand your data better
  • Tools - Understand what querychat can do under the hood