Skip to main content
Free webinar: Build AI agents in R you can trust, 19 Nov, 1:15pm UK Register

See all results
items
    • Overview 
    • Join Us  
    • Community 
    • Contact 
    • Overview 
    • Course Catalogue 
    • Public Courses 
    • Overview 
    • AI New 
    • Posit 
    • Data Science 
    • Engineering 
    • Blog 
    • Case Studies 
    • R Package Validation 
    • Gallery 
    • diffify  
    • Pro Bono Support 

Agents in Production: Build an AI Chat App with Shiny and {shinychat}

Author: Pedro Silva

Published: November 10, 2026

tags: r, ai, llm, ellmer, agents, shiny, python
Agents in Production: Build an AI Chat App with Shiny and {shinychat}. An astronaut works on a laptop among floating numbers and stars.

The agent we built in part 1 lives in an R console, which suits one person. A trial team needs an app in the browser, where several people can ask questions at once without seeing each other’s conversations.

This is part two of a five-part series on putting AI agents into production with R. Each part builds on the same app, a clinical trial safety assistant. The parts are:

  • From Chatbot to Agent
  • Putting the Agent in a Shiny App (this post)
  • Can You Trust It?
  • Audit Trails, Costs and Models
  • Sharing Your Tools with MCP

As a reminder, the agent answers questions about a clinical trial’s safety data with a model, four tools and a loop. The model chooses which tool to call, and the tools do the counting in R. The model is also told never to do the maths itself, and part 3 checks that. (Part 1 also explains the trial terms.)

In this post we:

  • build a chat app around the agent with {shiny} and {shinychat}
  • give every user their own conversation, and test it
  • show the agent’s tool calls in the chat
  • stop files being uploaded to the model
  • handle the model being slow or unavailable, and deploy to Posit Connect

Want to take your agent into production? Join Pedro for our free Agents in Production webinar on Thursday 19 November 2026 at 1:15 PM UK time, covering testing, logging and safeguards.

What is {shinychat}?

{shinychat} is Posit’s package of chat components for Shiny, and we need two of them. The chat window comes from chat_ui(). Behind it, chat_server() connects the window to an {ellmer} chat client. It sends each question to the model, streams the answer back and shows each tool call as it happens.

The client is the one part 1’s make_agent() builds, so the tools, the system prompt and the loop all carry over unchanged. If you’ve used chat_mod_ui() and chat_mod_server() before, {shinychat} 0.5.0 soft-deprecates them in favour of these two.

The app

We keep the page in its own function, app_ui(), so that tests can check it without starting the app:

GREETING <- paste(
  "Ask me about adverse events in CDISC pilot study 01: a xanomeline skin patch at two doses, against placebo.",
  "For example: *Which adverse events were most common on the high dose?*",
  "I only know this trial's safety data, and I can't give medical advice.",
  sep = "\n\n"
)

app_ui <- function() {
  bslib::page_fillable(
    title = "Trial safety assistant",
    shinychat::chat_ui(
      "chat",
      greeting = GREETING,
      placeholder = "Ask about the trial's adverse events",
      allow_attachments = FALSE
    )
  )
}

The greeting tells people what the assistant is for. It only sets expectations, though. The rule against medical advice goes into the system prompt in part 3, where we also test that the agent keeps it.

The app itself is short, because the work happens in the files in R/: part 1’s, plus four new ones (ui.R, display.R, wrap.R and attachments.R), which Shiny sources before app.R runs:

library(shiny)
library(bslib)
library(shinychat)

# shinychat writes a failed answer's error into the chat. Sanitised, it says
# "An error occurred" instead of showing the provider's raw message.
options(shiny.sanitize.errors = TRUE)

# Shiny sources every file in R/ before this runs.
trial_data <- load_trial_data()

ui <- app_ui()

server <- function(input, output, session) {
  # One client per session. A client made outside server() would share one
  # conversation between every user.
  client <- make_agent(tools = with_display(trial_tools(trial_data)))
  refuse_attachments(client)
  chat <- chat_server("chat", client, history = FALSE)

  observeEvent(chat$last_error(), {
    error <- chat$last_error()
    message <- if (inherits(error, "trial_attachment_refused")) {
      conditionMessage(error)
    } else {
      "The assistant couldn't answer just now. Please try again in a minute."
    }
    showNotification(message, type = "error", duration = NULL)
  })
}

shinyApp(ui, server)

For each person who opens the app, server() does four things:

  • It makes them their own chat client. make_agent() builds it with the four tools, each wrapped by with_display() so that its results read well in the chat.
  • It checks every request before it goes to the model, with refuse_attachments().
  • It connects the client to the chat window. That’s chat_server(), with its built-in conversation history turned off (history = FALSE). Left on, {shinychat} 0.5.0 saves every conversation as files on the server, patient records included, outside the audit log and any retention rule, and asks the model to title each one. Part 4 keeps one record instead.
  • It tells the user what to do if a question fails, by watching chat$last_error().

The data is loaded once, outside server(), and shared by every session. That’s safe because nothing changes it.

To run the app, get this part’s folder as in part 1 and open it as a project. Your key doesn’t come with it, since git ignores .Renviron, so copy that file across from part 1’s folder.

Then run renv::restore(), which is quick because {renv} reuses the packages it installed before, or install the new ones with install.packages(c("shiny", "shinychat", "rsconnect")). Finally, call shiny::runApp().

The app answering a question. The "Summarising adverse events" row above the answer is the tool call; open it to see what the agent asked for and what came back.
Screenshot of the trial safety assistant. The user asks which adverse events were most common on the high dose. Above the answer, a collapsed row reads 'Summarising adverse events'. The answer lists the ten most common events in the Xanomeline High Dose arm of 72 subjects, from pruritus in 25 subjects (34.7%) to application site dermatitis in 7 subjects (9.7%).

One conversation per user

An {ellmer} chat client sends the conversation so far with each new question. That’s what makes follow-ups work, and it means a client belongs to one person.

Shiny runs one R process for many users, and it calls server() once for each browser session. Anything created inside server() belongs to that session, while anything created at the top of app.R is shared by everyone.

So a client created at the top of the file puts every user in the same conversation. The second person to arrive can ask “what did I just ask?” and get the first person’s question back.

Each browser session gets its own chat client. Only the trial data, which never changes, is shared.
Diagram. Two browsers connect to one Shiny app running in one R process. Browser 1 connects to session 1 and browser 2 to session 2; each session has its own chat client and its own conversation. Both clients call the same model through its API. The trial data sits in the app, loaded once and read-only. A note says a client created outside server() would be shared by every session, so browser 2 would talk into browser 1's conversation.

You won’t catch this by trying the app on your own, so we test for it. Shiny’s testServer() runs the server function without a browser. Our test builds one app with shiny::shinyAppDir(), runs testServer() on it twice, and checks that the two sessions never share a chat$client.

For the browser tests that go alongside testServer(), see our series on {shinytest2}.

test_that("two sessions never share a client", {
  withr::local_envvar(GEMINI_API_KEY = "test-key")
  # One app, as on a server: both sessions run inside it. Given a folder,
  # testServer() builds a fresh app each time, which would hide a shared client.
  app <- shiny::shinyAppDir(test_path("..", ".."))
  seen <- new.env()
  for (i in 1:2) {
    shiny::testServer(app, assign(paste0("client", i), chat$client, envir = seen))
  }
  expect_false(identical(seen$client1, seen$client2))
})

Showing the work

{shinychat} shows the tool calls as rows above the answer. Each row takes its title from the tool_annotations(title = …) we gave the tool in part 1, such as “Counting subjects” or “Summarising adverse events”. Opening a row shows what the agent asked for and what came back, so a trial team can check where a number came from.

By default, calls to the same tool share one row, which lists each call when you open it. Set tool_grouping = "none" in chat_ui() to give every call its own row.

The raw results are JSON, which suits the model but is hard for people to read. So with_display() wraps each tool, and people see a readable table while the model still gets JSON:

with_display <- function(tools) {
  lapply(tools, function(tool) {
    wrap_tool(tool, function(run, args) {
      value <- run()
      # The display is for people. If it can't be built, the model still
      # gets its answer.
      markdown <- tryCatch(result_markdown(value), error = function(e) NULL)
      if (is.null(markdown)) return(value)
      ellmer::ContentToolResult(
        value = value,
        extra = list(display = shinychat::tool_result_display(markdown = markdown))
      )
    })
  })
}

Two helpers do the work. The first, wrap_tool() in R/wrap.R, copies a tool so that every call goes through a function we give it. Inside that function, run() calls the original tool and args holds the arguments the tool will run with, including any defaults the model left out.

The second, result_markdown() in R/display.R, turns the JSON into a table or a list. We use wrap_tool() again in part 4, for the audit log.

Not every tool returns JSON. The handover tool in part 3 returns a plain sentence, and our first version of the display broke on it, taking the whole tool call down too. It now falls back to plain text.

Do you require help building a Shiny app? Would you like someone to take over the maintenance burden? If so, check out our Shiny and Dash services.

No file uploads

By default, chat_server() allows file uploads. People can attach a CSV file, a PDF or an image, and it goes straight to the model. That undoes part 1’s design. The model would read raw data itself instead of asking our tested tools, and the whole file would go to the provider, when a question only needs a few numbers.

Setting allow_attachments = FALSE in chat_ui() only hides the upload button, and anyone can send a request without using the page. So the server checks every request as well. It refuses any user turn that holds more than typed text and tool results ({ellmer} sends tool results back as user turns).

Text files need one more check. {shinychat} sends a CSV or TXT file as plain text in a <file-attachment> wrapper, so the check refuses that too. {ellmer} builds turns and their contents as S7 objects, so the code tests their class with S7::S7_inherits() and reads their fields with @:

attachment_check <- function() {
  allowed <- function(content) {
    if (S7::S7_inherits(content, ellmer::ContentText)) {
      # shinychat sends a text file (CSV, TXT, JSON and so on) as text in a
      # <file-attachment> wrapper, so text can be a file too.
      return(!grepl("^\\s*<file-attachment ", content@text))
    }
    S7::S7_inherits(content, ellmer::ContentToolResult)
  }
  function(turns) {
    for (turn in turns) {
      if (S7::S7_inherits(turn, ellmer::UserTurn) && !all(vapply(turn@contents, allowed, logical(1)))) {
        rlang::abort(
          "Files can't be sent to this assistant. Ask your question as text.",
          class = "trial_attachment_refused"
        )
      }
    }
  }
}

refuse_attachments <- function(chat) {
  chat$on_request_start(attachment_check())
}

The check can’t stop someone pasting a file’s contents into the question, because no check can tell pasted data from a question. So what limits the risk is who can open the app and which provider you send text to, and part 4 covers both.

When the model is down

As we saw in part 1, Gemini’s free tier often answers “503: high demand” or “429: quota exceeded”. When the provider is busy or rate-limited, {ellmer} tries each request up to three times, waiting longer each time, and options(ellmer_max_tries = 5) allows more.

If the last try fails, chat$last_error() fires. The chat shows {shinychat}’s generic line, “An error occurred. Please try again or contact the app author.”, and our notification tells the user what to do. Our post on dynamic alt text shows the same idea outside a chat app, with tryCatch() and a fallback.

Two users at once

R does one thing at a time. If one person’s 20-second question blocked the R process, everyone else’s app would freeze until it finished.

{shinychat}’s chat_server() avoids that. It streams each answer with {ellmer}’s asynchronous stream_async(), inside a Shiny ExtendedTask, so R keeps serving other sessions while one waits for the model. Tool calls still run one at a time in the shared process, so keep your tools quick. Ours take milliseconds.

Waiting for the model doesn't block R, so a second person's question starts straight away.
Timeline diagram. Browser 1 sends a question, then waits while the model works and the answer streams in. Partway through, browser 2 sends its own question and waits too. Below them, the shared R process only has short jobs to do, such as running one tool call for each session, and is free the rest of the time. A note says tool calls run in R one at a time, so keep them quick, and that the waiting happens at the model provider.

To check by hand, open two browser windows. Ask in the first and, while its answer is still arriving, ask in the second. The second answer should start straight away. Our Shiny in Production 2025 summary has more on asynchronous Shiny.

Deploying to Posit Connect

Posit Connect runs Shiny apps for a team, with sign-in and access control.

Treat this first deploy as a private test. The free tier is fine for this public data, but not for your own patient or confidential data. And an app offered to users in the UK, the EEA or Switzerland must use Google’s paid service, as part 4 explains.

This version also lets anyone who can open the app see patient records, and it keeps no log. Parts 3 and 4 fix both.

You register your Connect account once, and the code’s README has the steps. After that, deploying is one call from the project folder. The appFiles argument lists what goes into the bundle: the app, the R/ folder and the system prompt. Tests and local files stay behind.

rsconnect::deployApp(
  ".",
  appFiles = c("app.R", list.files("R", full.names = TRUE), "prompts/system.md"),
  appName = "trial-safety-assistant",
  envVars = "GEMINI_API_KEY"
)

The API key never goes in the code or the bundle. Instead, envVars copies it from your R session, which read it from .Renviron, into the app’s Vars panel in Connect, over an encrypted connection, before the app first starts. Every later deploy copies it again, so change the key in your .Renviron as well as in the panel.

Beyond a private test, use a key from your organisation’s own account, under its paid terms, rather than your personal one.

To deploy on every push instead, see Keith’s post on GitHub Actions deployments to Posit Connect Cloud, or use git-backed publishing on your own Connect server.

We deployed it to our own Connect server, version 2026.06.1, and repeated the two-window check there. Each window kept its own conversation.

The bundle held only the nine files listed. Because the folder has an renv.lock, though, {rsconnect} took the package list from it, so Connect installed all 100 packages in the lockfile, including those only the tests use. That makes the first deploy slower, but it does no harm.

In Python

Shiny for Python has a chat component that streams from chatlas. Passing content="all" streams the tool calls too, so they show in the chat as they do in the R app:

from pathlib import Path

from chatlas import ChatGoogle
from shiny.express import ui

# Shiny Express runs this whole file once per session,
# so each user gets their own client.
client = ChatGoogle(
    model="gemini-3.7-flash",
    system_prompt=Path("prompts/system.md").read_text(encoding="utf-8"),
)
# client.register_tool(...) for each tool, as in part 1

chat = ui.Chat(id="chat")
chat.ui()

@chat.on_user_submit
async def _(user_input: str):
    response = await client.stream_async(user_input, content="all")
    await chat.append_message_stream(response)

Summary

The agent from part 1 is now an app that several people can use at once. Each person gets their own conversation, and the tool calls are visible. File attachments are refused, a failed question tells the user what to do next, and the app runs on Posit Connect.

Before you share it, run through Tim’s five pre-flight checks for your dashboard. They apply to a chat app too.

Next time, we take on the question every trial team asks first: can you trust it? We’ll test the tools, evaluate the agent and check where every number in an answer came from. We’ll also let only reviewers open a patient record, and have them confirm each one.

All the code for this part, including the app and its tests, is in our blog repository.

If you’re building AI tools with Shiny, come and talk about it at AI in Production 2027, where the call for talks is open until 29 January 2027. Our LLM-Driven Applications with R and Python course also covers adding a chat to a Shiny app.


Jumping Rivers Logo

You might also like

Agents in Production: From Chatbot to Agent

We asked Gemini how many people on the high dose of a drug had itching in a well-known clinical trial. It said 21, and sounded sure of it. We asked again and got 25, with different numbers for the other groups too. Both answers cited the trial's data files, but the model hadn't been given them.

{ellmer} for Dynamic Alt Text

Ever had trouble adding alt text to your dynamic plots and tables within Shiny apps? That's where ellmer comes in, allowing us to query LLM APIs and generate suitable alt text.

Retrieval-Augmented Generation: Setting up a Knowledge Store in R

Learn about retrieval-augmented generation (RAG) workflows for large language models. This blog provides a hands-on introduction, including example code for creating a working knowledge store using open source packages in R.

You Might Also Like

  • Agents in Production: Build Your First AI Agent in R with {ellmer}
  • Using {ellmer} for Dynamic Alt Text Generation in {shiny} Apps
  • Retrieval-Augmented Generation: Setting up a Knowledge Store in R

Recent Posts

  • Agents in Production: Build an AI Chat App with Shiny and {shinychat} 
  • What's Changed for AI in Pharma, and What to Watch Next 
  • SAS to R: Why R, and Where Your Migration Budget Should Go 
  • Agents in Production: Build Your First AI Agent in R with {ellmer} 
  • We're heading to PHUSE EU Connect 2026 
  • Agents in Production Webinar: From a Chatbot to a Tool You Can Rely On 
  • Spring 2027 data science training courses: January to June 
  • What is an MCP server? 
  • AI in Production 2027 Call for Speakers: What Makes a Strong Talk 
  • What's New in Python 3.15? 

Keep Updated

Like data science? R? Python? Stan? Then you’ll love the Jumping Rivers newsletter. The perks of being part of the Jumping Rivers family are:

  • Be the first to know about our latest courses and conferences.
  • Get discounts on the latest courses.
  • Read news on the latest techniques with the Jumping Rivers blog.

We keep your data secure and will never share your details. By subscribing, you agree to our privacy policy.

Follow Us

  • GitHub
  • Bluesky
  • LinkedIn
  • YouTube
  • Eventbrite

Find Us

The Catalyst Newcastle Helix Newcastle, NE4 5TG
Get directions

Contact Us

  • hello@jumpingrivers.com
  • + 44(0) 191 432 4340

Newsletter

Sign up

Events

  • North East Data Scientists Meetup
  • Leeds Data Science Meetup
  • AI in Production
  • Webinars
British Assessment Bureau, UKAS Certified logo for ISO 9001 - Quality management British Assessment Bureau, UKAS Certified logo for ISO 27001 - Information security management Cyber Essentials Certified Plus badge Accredited Living Wage Employer
  • Privacy Notice
  • |
  • Booking Terms

©2016 - present. Jumping Rivers Ltd