Click me!
library(shinychat)
shinychat::chat_app(ellmer::chat_google_gemini())- 1
-
chat_app()takes any ellmer chat object and wraps it in a complete running Shiny app. Try it now âit is the fastest way to poke at a system prompt interactively.
Build LLM-powered apps with shinychat, querychat and ggsql
Nelson Amaya
July 4, 2026
July 4, 2026
âThe most profound technologies are those that disappear.â
âMark Weiser
In session 7 you built Shiny apps where users interact through sliders and dropdowns. In session 9 you learned to call an LLM from code. This session welds the two: apps where the interface is a conversation, and where the model translates human questions into the filters, queries and charts you used to build by hand.
Everything runs on the same ellmer chat objects from session 9, so your API key setup carries over unchanged.
The shinychat package provides the chat interface âthe streaming text, the message bubbles, the input boxâ so you never build them from scratch. The quickest possible start is genuinely one line:
chat_app() takes any ellmer chat object and wraps it in a complete running Shiny app. Try it now âit is the fastest way to poke at a system prompt interactively.
For a real app you compose the pieces yourself: chat_ui() in the interface, chat_append() in the server. Here is a complete teaching assistant ânote how the system prompt does all the personality work, exactly as in session 9:
library(shiny)
library(bslib)
library(shinychat)
ui <- bslib::page_fillable(
theme = bslib::bs_theme(bootswatch = "journal"),
h4("R4DEV teaching assistant đ¤"),
shinychat::chat_ui("chat", fill = TRUE)
)
server <- function(input, output, session) {
chat <- ellmer::chat_google_gemini(
system_prompt = paste(
"You are the teaching assistant of R4DEV, a tidyverse-first R workshop.",
"Answer questions about R with short runnable examples.",
"Always use the native pipe |> and never suggest base R plotting."
)
)
observeEvent(input$chat_user_input, {
stream <- chat$stream_async(input$chat_user_input)
shinychat::chat_append("chat", stream)
})
}
shinyApp(ui, server)chat_ui() drops the whole chat interface into your layout, like any other Shiny widget. It fills the page like the dashboards from session 7.
input$chat_user_input âa plain Shiny input, nothing exotic.
$stream_async() returns the answer as it is generated, and chat_append() streams it into the UI word by word. Async matters: while one user waits for an answer, the app stays responsive for everyone else.
Try it live below, download it to run yourself:
The live demo above runs on Claude (Anthropic) rather than Gemini, using credentials already provisioned for this deployment âthe code you download and the code in this session both use chat_google_gemini(), matching what you set up with your own free-tier key.
Now the genuinely new idea. In session 7âs COVID app, filtering data meant building a sidebar of inputs âone widget per variable, wired to filter() calls. querychat replaces that whole apparatus: the user asks for the subset they want, the LLM writes SQL, and your app reacts to the result.
The safety design deserves attention before the code: the model never sees your data and never executes anything. It only reads the tableâs schema âcolumn names and typesâ and proposes a read-only SELECT statement, which querychat runs locally against a DuckDB copy of your data frame. Hallucinated SQL fails loudly; it cannot delete, modify or leak a single row.
Weâll point it at an old friend âthe Spotify archive from session 2:
library(querychat)
songs <- readr::read_csv("spotify_favorites_archive.csv")
querychat::querychat_app(
songs,
table_name = "songs",
client = ellmer::chat_google_gemini(),
greeting = "Ask me anything about these 1,178 songs â in plain language.",
data_description = paste(
"One row per song from the discographies of 8 artists,",
"with Spotify audio features saved before the API closed in 2024.",
"valence and energy run from 0 to 1.",
"cover_url holds the album cover image address."
)
)chat_app(), querychat_app() is the batteries-included version: data table, chat sidebar, live SQL display. For custom layouts there are modular pieces (querychat_ui(), querychat_server()) that plug into any Shiny app.
data_description is your prompt-craft moment: tell the model what a row means and what units the columns use. Good descriptions are the difference between âit just worksâ and confused SQL.
Once it runs, try these âand watch the SQL it writes (querychat shows it; always read it, thatâs your audit trail):
You just watched an LLM write SQL for you âwhich makes this the right moment to meet ggsql, a brand-new tool from Posit (alpha, April 2026) that extends SQL with a VISUALISE clause borrowed straight from the grammar of graphics you learned in session 2. One language selects the data and draws it.
These chunks run live in this page through ggsqlâs knitr engine âthe r: prefix lets SQL read data frames straight from the R session:
Aggregation in SQL, mapping and geometry in the VISUALISE clause: mean_valence AS x is exactly aes(x = mean_valence) wearing a SQL costume. One more, with color as a third channel:
ggsql is an alpha release âthe syntax may still change, and you will hit rough edges. That is not a reason to avoid it; it is a reason to pin the version in your projects and read the release notes when you update. Watching a tool this young is also watching how the R ecosystem evolves in real time.
Why does ggsql belong in this session? Because of what you saw in Part II: LLMs are remarkably good at writing SQL. A model that can answer âshow me energy against valence, colored by artistâ with a complete ggsql query âdata selection and chart in one statementâ is a very short step from the querychat app you just ran. The pieces on this page are the ingredients of the next generation of data tools; now you know how each one works alone.
One last app, and it puts together nearly everything the workshop has taught: a chatbot that answers questions about R4DEV itself, grounded only in the actual content of these 13 sessions.
The pipeline: every sessionâs prose was chunked by heading (session 6âs scraping instincts, applied to our own .qmd files instead of a website), indexed into DuckDBâs full-text search (a keyword/BM25 ranker âno embedding model needed, which matters: it means retrieval runs with zero external API calls, identically on your laptop and once deployed), and wired into a shinychat interface. Every question first retrieves the most relevant excerpts, then the model is instructed to answer only from what was retrieved âthe same grounding fix that would have saved the mood-scoring experiment back in session 9.
Try âHow do I make an interactive map with leaflet?â or âWhat packages does R4DEV use for text analysis?â âthen ask it something the workshop never covers, and watch it say so instead of guessing.
querychat_app() at the Maddison GDP data from session 2 and ask it three questions you answered with dplyr back then. Compare the SQL to your pipeschat_ui() where the model has a tool that runs ggsql_execute() on the songs table and returns the chart. You will have built a tiny data analyst@online{amaya2026,
author = {Amaya, Nelson},
title = {Chat with Your Data đŹ},
date = {2026-07-04},
url = {https://r4dev.netlify.app/sessions_workshop/10-llm-apps/10-llm-apps},
langid = {en}
}