Shiny + chat con ellmer

Patrones progresivos para conectar conversación, datos e interfaz

LatinR 2026

2026-11-01

Cómo avanzaremos por el taller

  1. Partimos de una aplicación existente.
  2. Agregamos una sola funcionalidad nueva.
  3. Explicamos qué cambia y cómo funciona en el código.
  4. Continuamos con la siguiente app.

El recorrido del taller

  1. Preparar la app y conversar

    • Inputs, outputs y dashboard reactivo.
    • Chat sin acceso a los datos.
  2. Consultar y actualizar datos

    • Tools de consulta y SQL.
    • Actualización del dashboard.
  3. Actuar sobre la interfaz

    • Tablas y gráficos en modales.
    • Controles manuales, chat y cámara del mapa.
  4. Conectar herramientas externas

    • MCP con datos del FMI y un gráfico local.
  5. Organizar las tools en aplicaciones más completas

    • Casos territorial y económico de Colombia.

01 · Preparar y conversar

App 00–02 · De inputs y outputs a una conversación en Shiny.

{shiny}

{shiny} conecta inputs y outputs para crear aplicaciones web interactivas en R.

App 00 · Conectar inputs y outputs

library(shiny)
library(bslib)
library(forecast)

# data --------------------------------------------------------------------
pasajeros <- utils::read.csv(file.path("..", "data", "air-passengers.csv"))

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 00 · La estructura mínima de una app",
  sidebar = sidebar(
    textInput("title", "Título", value = "Pasajeros aéreos"),
    sliderInput("n", "Cantidad de puntos", min = 24, max = 144, value = 48, step = 24),
    checkboxInput("forecast", "Mostrar pronóstico", value = FALSE)
  ),
  card(
    card_header(textOutput("plot_title")),
    plotOutput("plot")
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  
  output$plot_title <- renderText(paste0(input$title, " (", input$n, " observaciones)"))

  output$plot <- renderPlot({
    series <- ts(head(pasajeros$pasajeros, input$n), start = c(1949, 1), frequency = 12)

    if (input$forecast) {
      plot(
        forecast::forecast(series),
        col = "#0E4F5A", fcol = "#0E4F5A", shadecols = c("#DCECEE", "#A9CED2"),
        main = "", xlab = NULL, ylab = "Pasajeros (miles)"
      )
    } else {
      plot(
        series,
        col = "#0E4F5A", lwd = 2, main = "", xlab = NULL, ylab = "Pasajeros (miles)"
      )
    }
  })
}

shinyApp(ui, server)
1
1. La UI define la estructura visible de la aplicación.
2
2. Los inputs permiten que la persona interactúe.
3
3. Los outputs muestran los resultados.
4
4. El server conecta los inputs con los outputs.

App 00 · Explorar

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| components: [viewer]
#| viewerHeight: 650
library(shiny)
library(bslib)
library(forecast)

# data --------------------------------------------------------------------
pasajeros <- data.frame(pasajeros = as.numeric(AirPassengers))

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 00 · Widgets y outputs",
  tags$style("html { zoom: 0.8; }"),
  sidebar = sidebar(
    textInput("title", "Título", value = "Pasajeros aéreos"),
    sliderInput("n", "Cantidad de puntos", min = 24, max = 144, value = 48, step = 24),
    checkboxInput("forecast", "Mostrar pronóstico", value = FALSE)
  ),
  card(
    card_header(textOutput("plot_title")),
    plotOutput("plot")
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  output$plot_title <- renderText(paste0(input$title, " (", input$n, " observaciones)"))

  output$plot <- renderPlot({
    series <- ts(head(pasajeros$pasajeros, input$n), start = c(1949, 1), frequency = 12)

    if (input$forecast) {
      plot(
        forecast::forecast(series),
        col = "#0E4F5A", fcol = "#0E4F5A", shadecols = c("#DCECEE", "#A9CED2"),
        main = "", xlab = NULL, ylab = "Pasajeros (miles)"
      )
    } else {
      plot(
        series,
        col = "#0E4F5A", lwd = 2, main = "", xlab = NULL, ylab = "Pasajeros (miles)"
      )
    }
  })
}

shinyApp(ui, server)

App 01 · Filtrar datos con controles

Esta será la aplicación base que iremos modificando paso a paso.

library(shiny)
library(bslib)
library(tinyplot)

# data --------------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")
continentes <- c("Todos", sort(unique(paises$continente)))

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 01 · Dashboard reactivo",
  sidebar = sidebar(selectInput("continente", "Continente", choices = continentes), width = 400),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  data <- reactive({
    if (input$continente == "Todos") paises
    else paises[paises$continente == input$continente, ]
  })

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)
}

shinyApp(ui, server)
1
1. Carga los datos compartidos y prepara las opciones del filtro.
2
2. Construye el dashboard con filtro, indicadores, gráfico y tabla.
3
3. Conecta el filtro con los outputs mediante lógica reactiva.

App 01 · Ejecutar y explorar

shiny::runApp("app-01")

Haz

Selecciona Europa y luego Asia en el filtro de continente.

Observa

Los indicadores, el gráfico y la tabla cambian juntos: todos dependen de los mismos datos reactivos.

{ellmer}

{ellmer} conecta R con modelos de lenguaje para construir conversaciones con contexto.

Configuración de {ellmer}

Para usar OpenAI desde {ellmer}, configura la clave en un archivo .Renviron.

OPENAI_API_KEY=clave_de_openai

Después de crear o modificar el archivo, reinicia la sesión de R.

Reemplaza clave_de_openai por tu clave personal en el archivo .Renviron de tu proyecto.

Una conversación con {ellmer}

Antes de conectarlo a una aplicación, probamos el cliente y la conversación de forma aislada.

library(ellmer)

chat <- ellmer::chat_openai(model = "gpt-5-nano", system_prompt = "Eres un bot muy alegre.")

chat$chat("Salúdame")

chat$chat("¿Cuánto es 2 elevado a 4?")

live_console(chat)

El cliente conserva el historial entre llamadas a chat$chat(). La clave se lee desde el entorno.

{shinychat}

{shinychat} conecta la conversación de {ellmer} con la interfaz reactiva de {shiny}.

App 02 · Conversar sin acceso a los datos

El chat en la UI

En la UI, chat_ui() crea la conversación. El id chat la conecta con el server; messages muestra un saludo inicial y placeholder orienta la pregunta. El modelo todavía no recibe los datos del dashboard.

shinychat::chat_ui(
  "chat",
  messages = saludo,
  placeholder = "Pregunta algo sobre los países..."
)

El chat en el server

En el server, chat_openai() crea el cliente. observeEvent() escucha input$chat_user_input y chat_append() devuelve el stream al componente con el mismo id.

chat <- ellmer::chat_openai(...)

observeEvent(input$chat_user_input, {
  stream <- chat$stream_async(input$chat_user_input)
  shinychat::chat_append("chat", stream)
})

App 02 · El código

library(shiny)
library(bslib)
library(ellmer)
library(shinychat)
library(tinyplot)

# data --------------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")
continentes <- c("Todos", sort(unique(paises$continente)))

# prompt ------------------------------------------------------------------
saludo <- "Prueba con:\n\n- **¿Cuántos países se muestran en el dashboard?**"

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 02 · Chat sin tools",
  sidebar = sidebar(
    selectInput("continente", "Continente", choices = continentes),
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Pregunta algo sobre los países..."),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  data <- reactive({
    if (input$continente == "Todos") paises
    else paises[paises$continente == input$continente, ]
  })

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(
    model = "gpt-5-nano",
    system_prompt = paste(
      "Responde brevemente en español sobre los países de Gapminder en su último año.",
      "No inventes resultados numéricos."
    )
  )

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input)
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. Carga {ellmer} y {shinychat}.
2
2. Incorpora el chat en la UI con el id chat.
3
3. Crea la conversación; todavía no entrega los datos de la app al modelo.
4
4. Escucha el input y agrega la respuesta al chat.

App 02 · Ejecutar y explorar

shiny::runApp("app-02")

Pregunta

«¿Cuántos países se muestran en el dashboard?»

Observa

El chat puede conversar, pero todavía no puede consultar los datos ni conocer el filtro seleccionado.

02 · Consultar y actualizar

App 03–05 · Dar acceso a los datos y modificar el estado reactivo.

Una tool con {ellmer}

Consulta un pronóstico por ciudad y fecha. Copia y ejecuta el bloque completo en R.

library(ellmer)
library(httr2)

# tools -------------------------------------------------------------------
consultar_pronostico <- function(ciudad, fecha) {
  lugares <- request("https://geocoding-api.open-meteo.com/v1/search") |>
    req_url_query(name = trimws(ciudad), count = 1, language = "es") |>
    req_perform() |>
    resp_body_json()
  lugar <- lugares$results[[1]]

  pronostico <- request("https://api.open-meteo.com/v1/forecast") |>
    req_url_query(
      latitude = lugar$latitude, longitude = lugar$longitude,
      daily = "temperature_2m_min,temperature_2m_max,precipitation_probability_max",
      timezone = "UTC", start_date = fecha, end_date = fecha
    ) |>
    req_perform() |>
    resp_body_json(simplifyVector = TRUE)

  list(
    ciudad = lugar$name, region = lugar$admin1, pais = lugar$country, fecha = fecha,
    pronostico = pronostico$daily, unidades = pronostico$daily_units
  )
}

# prompt ------------------------------------------------------------------
fecha_ejemplo <- format(as.Date(format(Sys.time(), tz = "UTC")) + 1, "%Y-%m-%d")
pregunta <- sprintf("¿Lloverá en Madrid el %s? Indica también las temperaturas.", fecha_ejemplo)

# chat --------------------------------------------------------------------
# Prueba primero la función sin el modelo:
consultar_pronostico("Madrid", fecha_ejemplo)

chat <- ellmer::chat_openai(
  model = "gpt-5-nano",
  system_prompt = paste(
    "Responde en español. Usa consultar_pronostico; no inventes datos meteorológicos.",
    "Si no tienes una tool disponible para consultar el pronóstico, explica que no puedes obtenerlo."
  )
)

# Sin registrar la tool: el modelo no puede consultar la API.
chat$chat(pregunta)

# Ahora damos acceso a la función y describimos sus argumentos.
chat$register_tool(tool(
  consultar_pronostico,
  "Consulta el pronóstico de un día en una ciudad. Usa la primera coincidencia.",
  arguments = list(
    ciudad = type_string("Nombre de una ciudad, por ejemplo Madrid o Tokio."),
    fecha = type_string("Fecha AAAA-MM-DD, entre hoy y hoy + 15 días, UTC.")
  )
))

# Con la tool disponible: vuelve a pedir el pronóstico.
chat$chat(paste("Ahora consulta el pronóstico con la herramienta disponible.", pregunta))

App 03 · Leer un resumen de los datos visibles

La tool calcula un resumen con campos predefinidos a partir de los países visibles.

isolate(data()) lee el estado al ejecutar la tool. Esta consulta no modifica el dashboard.

resumir_datos_visibles <- function() {
  df <- isolate(data())
  list(
    continente = isolate(input$continente),
    registros = nrow(df),
    poblacion_total = sum(df$poblacion),
    esperanza_promedio = round(mean(df$esperanza_de_vida), 1),
    pib_promedio = round(mean(df$pib_per_capita))
  )
}

chat$register_tool(tool(
  resumir_datos_visibles,
  "Resume los países visibles en el dashboard."
))

App 03 · El código

library(shiny)
library(bslib)
library(ellmer)
library(shinychat)
library(tinyplot)

# data --------------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")
continentes <- c("Todos", sort(unique(paises$continente)))

# prompt ------------------------------------------------------------------
saludo <- paste(
  "Prueba con:",
  "- **¿Cuántos países se muestran?**",
  "- **¿Cuál es su esperanza de vida promedio?**",
  sep = "\n\n"
)

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 03 · Tool con contexto",
  sidebar = sidebar(
    selectInput("continente", "Continente", choices = continentes),
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Pregunta sobre los datos visibles..."),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  data <- reactive({
    if (input$continente == "Todos") paises
    else paises[paises$continente == input$continente, ]
  })

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)

  # tool ------------------------------------------------------------------
  resumir_datos_visibles <- function() {
    df <- isolate(data())
    list(
      continente = isolate(input$continente),
      registros = nrow(df),
      poblacion_total = sum(df$poblacion),
      esperanza_promedio = round(mean(df$esperanza_de_vida), 1),
      pib_promedio = round(mean(df$pib_per_capita))
    )
  }

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(
    model = "gpt-5-nano",
    system_prompt = paste(
      "Responde brevemente en español.",
      "Usa resumir_datos_visibles para responder sobre el dashboard y no inventes resultados."
    )
  )

  chat$register_tool(tool(
    resumir_datos_visibles,
    "Resume los países que están visibles en el dashboard."
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. Define la tool que lee y resume los datos visibles al ejecutarse.
2
2. Registra la tool para que el modelo pueda usarla.
3
3. Mantiene el flujo de conversación en el chat.

App 03 · Ejecutar y explorar

shiny::runApp("app-03")

Haz y pregunta

Selecciona Europa y pregunta: «¿Cuántos países se muestran y cuál es su esperanza de vida promedio?»

Observa

El resumen del chat coincide con los indicadores. Cambia a Asia y vuelve a preguntar.

App 04 · Pasar del resumen a consultas SQL

La tool consulta toda la tabla paises; el filtro manual del dashboard no limita esa consulta.

consultar_paises <- function(consulta) {
  DBI::dbGetQuery(con, validar_select(consulta))
}

chat$register_tool(tool(
  consultar_paises,
  "Ejecuta una consulta SELECT sobre la tabla paises.",
  arguments = list(
    consulta = type_string("Consulta SELECT sin punto y coma.")
  )
))

App 04 · El código

library(shiny)
library(bslib)
library(DBI)
library(duckdb)
library(ellmer)
library(shinychat)
library(tinyplot)

# database ----------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")
continentes <- c("Todos", sort(unique(paises$continente)))

con <- DBI::dbConnect(duckdb::duckdb(), dbdir = ":memory:")
DBI::dbWriteTable(con, "paises", paises)
DBI::dbExecute(con, "SET enable_external_access = false")
onStop(function() DBI::dbDisconnect(con, shutdown = TRUE))

validar_select <- function(consulta) {
  consulta <- trimws(consulta)
  if (!grepl("^SELECT\\b", consulta, ignore.case = TRUE)) stop("Solo se permiten consultas SELECT.")
  if (!grepl("\\bpaises\\b", consulta, ignore.case = TRUE)) stop("La consulta debe usar paises.")
  if (grepl(";", consulta, fixed = TRUE)) stop("Envía una sola consulta y no incluyas punto y coma.")
  consulta
}

# prompt ------------------------------------------------------------------
saludo <- paste(readLines("greeting.md", warn = FALSE), collapse = "\n")
prompt_sistema <- paste(readLines("prompt.md", warn = FALSE), collapse = "\n")

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 04 · Tool SQL",
  sidebar = sidebar(
    selectInput("continente", "Continente", choices = continentes),
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Pregunta por los datos..."),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  data <- reactive({
    if (input$continente == "Todos") paises
    else paises[paises$continente == input$continente, ]
  })

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)

  # tool ------------------------------------------------------------------
  consultar_paises <- function(consulta) {
    DBI::dbGetQuery(con, validar_select(consulta))
  }

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)

  chat$register_tool(tool(
    consultar_paises,
    "Ejecuta una consulta SELECT de solo lectura sobre la tabla DuckDB paises.",
    arguments = list(
      consulta = type_string("Consulta DuckDB SELECT sobre la tabla paises, sin punto y coma.")
    )
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. Prepara la tabla paises en DuckDB para consultarla desde el chat.
2
2. La tool ejecuta la consulta sobre DuckDB.
3
3. Registra la tool y describe sus argumentos.

App 04 · Ejecutar y explorar

shiny::runApp("app-04")

Pregunta

«Lista todos los países de Europa con su esperanza de vida, de mayor a menor.»

Observa

La tool consulta la tabla completa y devuelve el resultado al chat. La vista del dashboard permanece igual.

Dos tools con {ellmer}

«¿Lloverá en Madrid dentro de tres días?» → consultar la fecha actual y luego el pronóstico.

library(ellmer)
library(httr2)

# tools -------------------------------------------------------------------
consultar_pronostico <- function(ciudad, fecha) {
  if (length(ciudad) != 1L || is.na(ciudad) || !nzchar(trimws(ciudad))) {
    stop("Indica una ciudad.")
  }
  if (length(fecha) != 1L || is.na(fecha) || !grepl("^\\d{4}-\\d{2}-\\d{2}$", fecha)) {
    stop("Usa una fecha con formato AAAA-MM-DD.")
  }
  dia <- tryCatch(as.Date(fecha), error = function(e) NA)
  hoy <- as.Date(format(Sys.time(), tz = "UTC", format = "%Y-%m-%d"))
  if (is.na(dia) || dia < hoy || dia > hoy + 15) {
    stop("La fecha debe estar entre hoy y los próximos 15 días, UTC.")
  }

  lugares <- request("https://geocoding-api.open-meteo.com/v1/search") |>
    req_url_query(name = trimws(ciudad), count = 1, language = "es") |>
    req_timeout(20) |>
    req_perform() |>
    resp_body_json()
  if (is.null(lugares$results)) stop("No encontré esa ciudad.")
  lugar <- lugares$results[[1]]

  pronostico <- request("https://api.open-meteo.com/v1/forecast") |>
    req_url_query(
      latitude = lugar$latitude, longitude = lugar$longitude,
      daily = "temperature_2m_min,temperature_2m_max,precipitation_probability_max",
      timezone = "UTC", start_date = fecha, end_date = fecha
    ) |>
    req_timeout(20) |>
    req_perform() |>
    resp_body_json(simplifyVector = TRUE)

  list(
    ciudad = lugar$name, region = lugar$admin1, pais = lugar$country, fecha = fecha,
    pronostico = pronostico$daily, unidades = pronostico$daily_units
  )
}

consultar_fecha_actual <- function() {
  list(
    fecha = format(Sys.time(), tz = "UTC", format = "%Y-%m-%d"),
    zona_horaria = "UTC"
  )
}

# prompt ------------------------------------------------------------------
fecha_ejemplo <- format(as.Date(format(Sys.time(), tz = "UTC")) + 1, "%Y-%m-%d")
pregunta <- "¿Lloverá en Madrid dentro de tres días? Indica también las temperaturas."

# chat --------------------------------------------------------------------
# Prueba primero la función sin el modelo:
consultar_pronostico("Madrid", fecha_ejemplo)

chat <- ellmer::chat_openai(
  model = "gpt-5-nano",
  system_prompt = paste(
    "Responde en español.",
    "Para fechas relativas, consulta primero consultar_fecha_actual y calcula la fecha.",
    "Después usa consultar_pronostico. No inventes datos meteorológicos.",
    "Si faltan tools, explica que no puedes obtener el pronóstico."
  )
)

# Sin registrar las tools: todavía no puede consultar la fecha ni el pronóstico.
chat$chat(pregunta)

# Ahora damos acceso a ambas funciones.
chat$register_tool(tool(
  consultar_pronostico,
  "Consulta el pronóstico de un día en una ciudad. Usa la primera coincidencia.",
  arguments = list(
    ciudad = type_string("Nombre de una ciudad, por ejemplo Madrid o Tokio."),
    fecha = type_string("Fecha AAAA-MM-DD, entre hoy y hoy + 15 días, UTC.")
  )
))

chat$register_tool(tool(
  consultar_fecha_actual,
  "Devuelve la fecha actual en UTC. Úsala para interpretar hoy, mañana o en N días."
))

# Con ambas tools: consulta la fecha actual y luego el pronóstico.
chat$chat(paste("Ahora usa las herramientas disponibles para responder.", pregunta))

App 05 · Pasar de consultar a actualizar la vista

Consultar devuelve datos al chat. Actualizar cambia la consulta reactiva que alimenta el dashboard.

actualizar_dashboard <- function(consulta) {
  consulta <- validar_select(consulta)
  resultado <- DBI::dbGetQuery(con, consulta)
  consulta_actual(consulta)

  list(mensaje = "Dashboard actualizado.", registros = nrow(resultado))
}

App 05 · Cómo se propaga el cambio

La tool cambia el estado. Shiny propaga el cambio hasta los outputs.

01 / ACCIÓN La tool escribe actualizar_dashboard() Recibe una consulta SQL.
02 / ESTADO Guarda la consulta consulta_actual() Invalida sus dependientes.
03 / DATOS Recalcula los datos data() Ejecuta la nueva consulta.
04 / OUTPUTS Actualiza la vista Indicadores · gráfico · tabla Muestran el nuevo resultado.
consulta_actual <- reactiveVal("SELECT * FROM paises")
data <- reactive(DBI::dbGetQuery(con, consulta_actual()))

Una escritura en consulta_actual() activa toda la cadena reactiva.

App 05 · El código

library(shiny)
library(bslib)
library(DBI)
library(duckdb)
library(ellmer)
library(shinychat)
library(tinyplot)

# database ----------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")

con <- DBI::dbConnect(duckdb::duckdb(), dbdir = ":memory:")
DBI::dbWriteTable(con, "paises", paises)
DBI::dbExecute(con, "SET enable_external_access = false")
onStop(function() DBI::dbDisconnect(con, shutdown = TRUE))

validar_select <- function(consulta) {
  consulta <- trimws(consulta)
  if (!grepl("^SELECT\\b", consulta, ignore.case = TRUE)) stop("Solo se permiten consultas SELECT.")
  if (!grepl("\\bpaises\\b", consulta, ignore.case = TRUE)) stop("La consulta debe usar paises.")
  if (grepl(";", consulta, fixed = TRUE)) stop("Envía una sola consulta y no incluyas punto y coma.")
  consulta
}

# prompt ------------------------------------------------------------------
saludo <- paste(readLines("greeting.md", warn = FALSE), collapse = "\n")
prompt_sistema <- paste(readLines("prompt.md", warn = FALSE), collapse = "\n")

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 05 · Tool + reactividad",
  sidebar = sidebar(
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Filtra u ordena el dashboard..."),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  consulta_actual <- reactiveVal("SELECT * FROM paises") # paises, tal cual

  data <- reactive(DBI::dbGetQuery(con, consulta_actual()))

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)

  # tools -----------------------------------------------------------------
  consultar_paises <- function(consulta) {
    DBI::dbGetQuery(con, validar_select(consulta))
  }

  actualizar_dashboard <- function(consulta) {
    consulta <- validar_select(consulta)
    resultado <- DBI::dbGetQuery(con, consulta)

    consulta_actual(consulta)
    list(mensaje = "Dashboard actualizado.", registros = nrow(resultado))
  }

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)

  chat$register_tool(tool(
    consultar_paises,
    "Ejecuta una consulta SELECT de solo lectura sobre la tabla DuckDB paises.",
    arguments = list(
      consulta = type_string("Consulta DuckDB SELECT sobre la tabla paises, sin punto y coma.")
    )
  ))

  chat$register_tool(tool(
    actualizar_dashboard,
    "Filtra, ordena o reinicia los datos reactivos mostrados en el dashboard.",
    arguments = list(
      consulta = type_string("SELECT * FROM paises con WHERE u ORDER BY, sin punto y coma.")
    )
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. El dashboard lee la consulta reactiva actual.
2
2. La tool escribe la consulta en el estado reactivo del dashboard.
3
3. Registra la tool que puede modificar el dashboard.

App 05 · Ejecutar y explorar

shiny::runApp("app-05")

Pide

«Muestra solo los países de Europa en el dashboard.» Luego: «Reinicia el dashboard».

Observa

La tool cambia el estado reactivo: se actualizan los indicadores, el gráfico y la tabla.

03 · Actuar sobre la interfaz

App 06–08 · Abrir vistas, comparar controles y mover la cámara del mapa.

Una tool que devuelve una imagen

Generamos puntos aleatorios: nosotros vemos el gráfico y el modelo recibe la misma imagen.

library(ellmer)

# tools -------------------------------------------------------------------
mostrar_puntos <- function() {
  puntos <- data.frame(x = rnorm(12), y = rnorm(12))
  puntos[12, ] <- runif(2, min = 4, max = 6)
  puntos <- puntos[sample(nrow(puntos)), ]
  puntos$nombre <- LETTERS[seq_len(nrow(puntos))]

  dibujar <- function() {
    plot(
      puntos$x, puntos$y, pch = 19, col = "#0E4F5A",
      xlim = range(puntos$x) + c(-0.5, 0.5), ylim = range(puntos$y) + c(-0.5, 0.5),
      xlab = "X", ylab = "Y", main = "¿Qué punto se separa del grupo?"
    )
    text(puntos$x, puntos$y, labels = puntos$nombre, pos = 3)
  }

  if (interactive()) dibujar()

  archivo <- tempfile(fileext = ".png")
  png(archivo, width = 700, height = 500)
  tryCatch(dibujar(), finally = dev.off())
  on.exit(unlink(archivo))
  content_image_file(archivo, resize = "none")
}

# prompt ------------------------------------------------------------------
pregunta <- "Genera un gráfico e identifica el punto más separado del grupo. Explica por qué."

# chat --------------------------------------------------------------------
# Prueba la función y revisa el objeto de imagen sin llamar al modelo.
resultado <- mostrar_puntos()
class(resultado)
str(resultado, max.level = 1)

chat <- chat_openai(
  model = "gpt-5-nano",
  system_prompt = paste(
    "Responde brevemente en español. Usa mostrar_puntos para observar el gráfico.",
    "Si no tienes la tool disponible, explica que no puedes observar los puntos."
  )
)

chat$chat(pregunta)

chat$register_tool(tool(
  mostrar_puntos,
  "Genera y devuelve un gráfico aleatorio con puntos identificados por letras."
))

chat$chat(paste("Ahora usa la herramienta disponible.", pregunta))

App 06 · Abrir vistas y devolver contexto

Una tool abre una tabla o un gráfico sin recargar la aplicación.

La tool devuelve al modelo los datos de la tabla o la imagen del gráfico que muestra en el modal.

mostrar_tabla <- function(n, titulo) {
  resultado <- head(isolate(data()), n)
  output$tabla_modal <- renderTable(resultado)

  showModal(modalDialog(
    title = titulo,
    tableOutput("tabla_modal")
  ))

  resultado
}

App 06 · El código

library(shiny)
library(bslib)
library(DBI)
library(duckdb)
library(ellmer)
library(shinychat)
library(tinyplot)

# database ----------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")

con <- DBI::dbConnect(duckdb::duckdb(), dbdir = ":memory:")
DBI::dbWriteTable(con, "paises", paises)
DBI::dbExecute(con, "SET enable_external_access = false")
onStop(function() DBI::dbDisconnect(con, shutdown = TRUE))

validar_select <- function(consulta) {
  consulta <- trimws(consulta)
  if (!grepl("^SELECT\\b", consulta, ignore.case = TRUE)) stop("Solo se permiten consultas SELECT.")
  if (!grepl("\\bpaises\\b", consulta, ignore.case = TRUE)) stop("La consulta debe usar paises.")
  if (grepl(";", consulta, fixed = TRUE)) stop("Envía una sola consulta y no incluyas punto y coma.")
  consulta
}

# prompt ------------------------------------------------------------------
saludo <- paste(readLines("greeting.md", warn = FALSE), collapse = "\n")
prompt_sistema <- paste(readLines("prompt.md", warn = FALSE), collapse = "\n")

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 06 · Tools de interfaz",
  sidebar = sidebar(
    shinychat::chat_ui(
      "chat",
      messages = saludo,
      placeholder = "Filtra, muestra datos o crea un gráfico..."
    ),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(tableOutput("table")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  consulta_actual <- reactiveVal("SELECT * FROM paises")

  data <- reactive(DBI::dbGetQuery(con, consulta_actual()))

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = data(), col = "#0E4F5A", cex = 1.5)
  })

  output$table <- renderTable(data(), striped = TRUE, hover = TRUE)

  # tools -----------------------------------------------------------------
  consultar_paises <- function(consulta) {
    DBI::dbGetQuery(con, validar_select(consulta))
  }

  actualizar_dashboard <- function(consulta) {
    consulta <- validar_select(consulta)
    resultado <- DBI::dbGetQuery(con, consulta)

    consulta_actual(consulta)
    list(mensaje = "Dashboard actualizado.", registros = nrow(resultado))
  }

  mostrar_tabla <- function(n, titulo) {
    df <- isolate(data())
    resultado <- head(df, n)
    output$tabla_modal <- renderTable(resultado, striped = TRUE, hover = TRUE)

    showModal(modalDialog(
      title = titulo, tableOutput("tabla_modal"), size = "l", easyClose = TRUE,
      footer = modalButton("Cerrar")
    ))

    resultado
  }

  mostrar_grafico <- function(x, y, color, titulo) {
    df <- isolate(data())
    if (!nrow(df)) stop("No hay datos para graficar.")
    formula <- as.formula(paste(y, "~", x, if (nzchar(color)) paste("|", color) else ""))

    dibujar <- function() {
      if (nzchar(color)) tinyplot(formula, data = df, pch = 19, palette = "Blues 3")
      else tinyplot(formula, data = df, pch = 19, col = "#0E4F5A")
    }

    output$grafico_modal <- renderPlot(dibujar())

    showModal(modalDialog(
      title = titulo, plotOutput("grafico_modal", height = "500px"), size = "l", easyClose = TRUE,
      footer = modalButton("Cerrar")
    ))

    archivo <- tempfile(fileext = ".png")
    grDevices::png(archivo, width = 768, height = 500)
    tryCatch(dibujar(), finally = grDevices::dev.off())
    on.exit(unlink(archivo))
    content_image_file(archivo, resize = "none")
  }

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)

  chat$register_tool(tool(
    consultar_paises,
    "Ejecuta una consulta SELECT de solo lectura sobre la tabla DuckDB paises.",
    arguments = list(
      consulta = type_string("Consulta DuckDB SELECT sobre la tabla paises, sin punto y coma.")
    )
  ))

  chat$register_tool(tool(
    actualizar_dashboard,
    "Filtra, ordena o reinicia los datos reactivos mostrados en el dashboard.",
    arguments = list(
      consulta = type_string("SELECT * FROM paises con WHERE u ORDER BY, sin punto y coma.")
    )
  ))

  chat$register_tool(tool(
    mostrar_tabla,
    "Abre en un modal una tabla con los datos visibles en el dashboard.",
    arguments = list(
      n = type_integer("Cantidad de filas que quieres mostrar."),
      titulo = type_string("Título breve del modal.")
    )
  ))

  chat$register_tool(tool(
    mostrar_grafico,
    "Grafica los datos visibles en un modal y devuelve la imagen al modelo.",
    arguments = list(
      x = type_enum(c("poblacion", "esperanza_de_vida", "pib_per_capita"), "Columna para el eje x."),
      y = type_enum(c("poblacion", "esperanza_de_vida", "pib_per_capita"), "Columna para el eje y."),
      color = type_enum(c("", "continente"), "Columna para el color, o ninguna."),
      titulo = type_string("Título breve del modal.")
    )
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, tool_mode = "sequential", stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. Define la tool que muestra una tabla en un modal.
2
2. Define la tool que crea un gráfico y devuelve su imagen al modelo.
3
3. Registra las tools de interfaz con sus argumentos.

App 06 · Ejecutar y explorar

shiny::runApp("app-06")

Pide

«Abre una tabla con 10 países.» Después: «Grafica PIB per cápita versus esperanza de vida por continente».

Observa

Las tools abren modales. Pregunta «¿Qué patrón ves?» para usar la imagen devuelta al modelo.

App 07 · El mismo filtro con controles o con chat

Comparamos dos formas de expresar la misma intención sobre el dashboard.

Controles tradicionales Chat
América; esperanza de vida: 70–80 años. «Muestra América con esperanza de vida de 70 a 80 años».

Ambos caminos alimentan data(). La interacción más reciente determina el modo activo.

App 07 · Ejecutar y explorar

shiny::runApp("app-07")

Compara

Con los controles, selecciona Américas y esperanza de vida entre 70 y 80 años. Luego pide al chat: «Muestra Américas con esperanza de vida entre 70 y 80 años».

Observa

Ambos caminos muestran los mismos países. Cambia un filtro manual para volver al modo de controles.

App 08 · Mover la cámara mediante un proxy

ir_a_pais() mueve la cámara sin cambiar el filtro de países ni el indicador del mapa.

El proxy conserva el mapa al viajar. Los cambios de filtro o color usan el render reactivo.

ir_a_pais <- function(pais, zoom = 4) {
  ubicacion <- DBI::dbGetQuery(
    con,
    paste(
      "SELECT DISTINCT p.pais, g.longitud, g.latitud",
      "FROM paises p JOIN paises_geo g USING (codigo)",
      "WHERE lower(p.pais) = lower(?)"
    ),
    params = list(pais)
  )
  if (!nrow(ubicacion)) stop("No encontré ese país.")

  mapgl::maplibre_proxy("map", session) |>
    mapgl::fly_to(
      center = c(ubicacion$longitud[1], ubicacion$latitud[1]),
      zoom = zoom
    )

  paste("Mapa centrado en", ubicacion$pais[1])
}

App 08 · El código

library(shiny)
library(bslib)
library(DBI)
library(duckdb)
library(ellmer)
library(shinychat)
library(tinyplot)
library(mapgl)
library(sf)

# database ----------------------------------------------------------------
paises <- utils::read.csv(file.path("..", "data", "paises.csv"), fileEncoding = "UTF-8")
catalogo <- subset(gapminder::gapminder, year == max(year))
stopifnot(
  nrow(paises) == nrow(catalogo),
  isTRUE(all.equal(paises$esperanza_de_vida, catalogo$lifeExp)),
  isTRUE(all.equal(paises$poblacion, catalogo$pop)),
  isTRUE(all.equal(paises$pib_per_capita, catalogo$gdpPercap))
)
paises$codigo <- unname(countrycode::countrycode(catalogo$country, "country.name", "iso3c"))
rangos_mapa <- list(
  esperanza_de_vida = range(paises$esperanza_de_vida),
  poblacion = range(log10(paises$poblacion)),
  pib_per_capita = range(log10(paises$pib_per_capita))
)

data("countries50", package = "rnaturalearthdata")
mapa_mundo <- sf::st_transform(sf::st_as_sf(countries50), 4326)
geometrias <- data.frame(
  codigo = mapa_mundo$adm0_a3,
  longitud = mapa_mundo$label_x,
  latitud = mapa_mundo$label_y,
  wkt = sf::st_as_text(sf::st_geometry(mapa_mundo))
)
paises_geo <- merge(paises, geometrias, by = "codigo")

con <- DBI::dbConnect(duckdb::duckdb(), dbdir = ":memory:")
DBI::dbWriteTable(con, "paises_carga", paises_geo)
DBI::dbExecute(con, paste(
  "CREATE TABLE paises_geo AS",
  "SELECT * EXCLUDE (wkt), wkt::GEOMETRY AS geometry FROM paises_carga"
))
DBI::dbExecute(con, paste(
  "CREATE VIEW paises AS SELECT pais, continente, esperanza_de_vida,",
  "poblacion, pib_per_capita, codigo FROM paises_geo"
))
DBI::dbRemoveTable(con, "paises_carga")
DBI::dbExecute(con, "SET enable_external_access = false")
onStop(function() DBI::dbDisconnect(con, shutdown = TRUE))

validar_select <- function(consulta) {
  consulta <- trimws(consulta)
  if (!grepl("^SELECT\\b", consulta, ignore.case = TRUE)) stop("Solo se permiten consultas SELECT.")
  if (!grepl("\\bpaises\\b", consulta, ignore.case = TRUE)) stop("La consulta debe usar paises.")
  if (grepl(";", consulta, fixed = TRUE)) stop("Envía una sola consulta y no incluyas punto y coma.")
  consulta
}

# prompt ------------------------------------------------------------------
saludo <- paste(readLines("greeting.md", warn = FALSE), collapse = "\n")
prompt_sistema <- paste(readLines("prompt.md", warn = FALSE), collapse = "\n")

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 08 · Tool + mapa",
  sidebar = sidebar(
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Filtra o controla el mapa..."),
    width = 400
  ),
  layout_columns(
    value_box("Países", textOutput("n_filas"), showcase = icon("earth-americas"), theme = "text-primary"),
    value_box("Esperanza de vida", textOutput("vida"), showcase = icon("heart-pulse"), theme="text-primary"),
    value_box("Población", textOutput("poblacion"), showcase = icon("people-group"), theme = "text-primary"),
    card(plotOutput("plot")),
    card(mapgl::maplibreOutput("map", height = "500px")),
    col_widths = c(4, 4, 4, 6, 6),
    row_heights = c(1, 3)
  )
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  consulta_actual <- reactiveVal("SELECT * FROM paises")
  variable_mapa <- reactiveVal("esperanza_de_vida")

  data <- reactive({
    consulta <- paste(
      "SELECT q.*, ST_AsText(g.geometry) AS wkt",
      paste0("FROM (", consulta_actual(), ") q"),
      "JOIN paises_geo g USING (codigo)"
    )
    DBI::dbGetQuery(con, consulta) |> sf::st_as_sf(wkt = "wkt", crs = 4326)
  })

  output$n_filas   <- renderText(nrow(data()))
  output$vida      <- renderText(paste0(round(mean(data()$esperanza_de_vida), 1), " años"))
  output$poblacion <- renderText(paste0(round(sum(data()$poblacion) / 1e6), " millones"))

  output$plot <- renderPlot({
    tinyplot(esperanza_de_vida ~ pib_per_capita, data = paises, pch = 19, col = "#d2d2d2", log = "x")
    tinyplot_add(data = sf::st_drop_geometry(data()), col = "#0E4F5A", cex = 1.5)
  })

  output$map <- mapgl::renderMaplibre({
    df <- data()
    req(nrow(df))
    df$valor_mapa <- switch(variable_mapa(),
      poblacion = log10(df$poblacion),
      pib_per_capita = log10(df$pib_per_capita),
      df$esperanza_de_vida
    )
    valores <- rangos_mapa[[variable_mapa()]]
    colores <- mapgl::interpolate("valor_mapa", values = valores, stops = c("#dcebea", "#0E4F5A"))

    mapgl::maplibre(style = mapgl::carto_style("positron"), bounds = df) |>
      mapgl::add_fill_layer(
        id = "paises", source = df, fill_color = colores, fill_opacity = 0.8,
        fill_outline_color = "white", tooltip = "pais"
      ) |>
      mapgl::add_globe_control()
  })

  # tools -----------------------------------------------------------------
  consultar_paises <- function(consulta) {
    DBI::dbGetQuery(con, validar_select(consulta))
  }

  actualizar_dashboard <- function(consulta) {
    consulta <- validar_select(consulta)
    resultado <- DBI::dbGetQuery(con, consulta)

    consulta_actual(consulta)
    list(
      mensaje = "Dashboard y mapa actualizados.",
      registros = nrow(resultado),
      esperanza_promedio = round(mean(resultado$esperanza_de_vida), 1),
      poblacion_total = sum(resultado$poblacion),
      pib_promedio = round(mean(resultado$pib_per_capita))
    )
  }

  cambiar_variable <- function(variable) {
    variable_mapa(variable)
    paste("Mapa coloreado por", variable)
  }

  ir_a_pais <- function(pais, zoom = 4) {
    ubicacion <- DBI::dbGetQuery(con, paste(
      "SELECT DISTINCT p.pais, g.longitud, g.latitud",
      "FROM paises p JOIN paises_geo g USING (codigo)",
      "WHERE lower(p.pais) = lower(?)"
    ), params = list(pais))
    if (!nrow(ubicacion)) stop("No encontré ese país.")

    mapgl::maplibre_proxy("map", session) |>
      mapgl::fly_to(center = c(ubicacion$longitud[1], ubicacion$latitud[1]), zoom = zoom)

    paste("Mapa centrado en", ubicacion$pais[1])
  }

  # chat ------------------------------------------------------------------
  chat <- ellmer::chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)

  chat$register_tool(tool(
    consultar_paises,
    "Ejecuta una consulta SELECT de solo lectura sobre la tabla DuckDB paises.",
    arguments = list(
      consulta = type_string("Consulta DuckDB SELECT sobre la tabla paises, sin punto y coma.")
    )
  ))

  chat$register_tool(tool(
    actualizar_dashboard,
    "Filtra, ordena o reinicia los datos reactivos mostrados en el dashboard y el mapa.",
    arguments = list(
      consulta = type_string("SELECT * FROM paises con WHERE u ORDER BY, sin punto y coma.")
    )
  ))

  chat$register_tool(tool(
    cambiar_variable,
    "Cambia la variable utilizada para colorear los países del mapa.",
    arguments = list(
      variable = type_enum(
        c("esperanza_de_vida", "poblacion", "pib_per_capita"),
        "Variable con la que se colorea el mapa."
      )
    )
  ))

  chat$register_tool(tool(
    ir_a_pais,
    "Vuela hacia un país en el mapa.",
    arguments = list(
      pais = type_string("Nombre del país en español."),
      zoom = type_number("Nivel de zoom, normalmente entre 2 y 6.", required = FALSE)
    )
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, tool_mode = "sequential", stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. La tool actualiza la cámara del mapa existente mediante un proxy.
2
2. Registra ir_a_pais y describe sus argumentos.
3
3. Mantiene el flujo del chat con las tools.

App 08 · Ejecutar y explorar

shiny::runApp("app-08")

Pide

«Colorea el mapa por población.» Después: «Vuela a Colombia».

Observa

El cambio de color vuelve a renderizar el mapa. El vuelo mueve la cámara mediante un proxy.

04 · Conectar herramientas con MCP

App 09 · Consultar un servicio externo y graficar sus datos con R.

¿Qué agrega MCP?

MCP es un protocolo para descubrir y ejecutar herramientas de otros servicios desde nuestra aplicación.

  • Hasta ahora definimos las tools en R; con MCP recibimos sus nombres, argumentos y descripciones del servidor.
  • {mcptools} las convierte en tools de {ellmer}. El modelo decide cuándo llamarlas.
  • El servidor consulta los datos; el modelo usa el resultado para responder.

Usaremos un servidor comunitario de datos del FMI, sin clave propia. El modelo sigue necesitando OPENAI_API_KEY.

El mismo modelo, sin y con MCP

Respuestas reales cacheadas: mismo modelo, mismo prompt neutral y conversaciones independientes.

Pregunta Sin MCP Con MCP
¿Cuál fue la tasa de desempleo de España en 2025? Año: 2025. Unidad: porcentaje. Fuente: INE (EPA). Nota: aún no hay datos oficiales para 2025. 2025: 10.525% (unidad: porcentaje de la fuerza laboral). Fuente: IMF, Labor Statistics (LS), España (ESP).

Modelo: gpt-5-nano · Caché: 2026-11-01 00:53:50 UTC.

Compara cifra, precisión, año y fuente. Las llamadas se conservan en app-09/data/comparacion.json.

App 09 · Datos remotos, gráfico local

El MCP ofrece tools para descubrir bases, resolver códigos y consultar series; no dibuja el gráfico.

Nuestra tool graficar_serie() recibe las observaciones recuperadas y actualiza el gráfico y la tabla.

mcp.json indica a qué servidor conectarse:

{
  "mcpServers": {
    "imf": {
      "url": "https://imf.caseyjhand.com/mcp"
    }
  }
}

App 09 · Registrar las herramientas

mcp_tools() descubre las tools remotas. Después registramos la función de gráficos definida en R.

chat <- chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)
chat$set_tools(mcptools::mcp_tools("mcp.json"))

El registro de graficar_serie() conserva el patrón de las apps anteriores.

App 09 · El código

library(shiny)
library(bslib)
library(ellmer)
library(shinychat)
library(mcptools)
library(tinyplot)

# prompt ------------------------------------------------------------------
saludo <- paste(readLines("greeting.md", warn = FALSE), collapse = "\n")
prompt_sistema <- paste(readLines("prompt.md", warn = FALSE), collapse = "\n")

# user interface ----------------------------------------------------------
ui <- page_sidebar(
  title = "App 09 · MCP + datos del FMI",
  sidebar = sidebar(
    shinychat::chat_ui("chat", messages = saludo, placeholder = "Consulta una serie del FMI..."),
    width = 400
  ),
  plotOutput("plot", height = "400px"),
  tableOutput("table"),
  fillable = FALSE
)

# server ------------------------------------------------------------------
server <- function(input, output, session) {
  grafico_actual <- reactiveVal(NULL)

  output$plot <- renderPlot({
    grafico <- req(grafico_actual())
    tinyplot(
      valor ~ anio | pais, data = grafico$datos, type = "b", pch = 19,
      main = grafico$titulo, xlab = "Año", ylab = grafico$unidad
    )
  })
  output$table <- renderTable({ req(grafico_actual())$datos }, digits = 3, striped = TRUE, hover = TRUE)

  # tool ------------------------------------------------------------------
  graficar_serie <- function(anios, valores, paises, titulo, unidad) {
    datos <- data.frame(anio = anios, valor = valores, pais = paises)
    grafico_actual(list(datos = datos, titulo = titulo, unidad = unidad))
    datos
  }

  # chat ------------------------------------------------------------------
  chat <- chat_openai(model = "gpt-5-nano", system_prompt = prompt_sistema)
  chat$set_tools(mcptools::mcp_tools("mcp.json"))
  chat$register_tool(tool(
    graficar_serie,
    "Grafica series anuales recuperadas del MCP y muestra sus datos en el dashboard.",
    arguments = list(
      anios = type_array(type_integer(), "Año de cada observación."),
      valores = type_array(type_number(), "Valores exactos devueltos por el MCP, en el mismo orden."),
      paises = type_array(type_string(), "País de cada observación, en el mismo orden."),
      titulo = type_string("Título con el indicador y la fuente consultada."),
      unidad = type_string("Unidad indicada por la fuente, por ejemplo porcentaje.")
    )
  ))

  observeEvent(input$chat_user_input, {
    stream <- chat$stream_async(input$chat_user_input, tool_mode = "sequential", stream = "content")
    shinychat::chat_append("chat", stream)
  })
}

shinyApp(ui, server)
1
1. Carga el cliente MCP para descubrir herramientas externas.
2
2. La tool local conserva el patrón de actualización reactiva para graficar los datos.
3
3. Crea el chat y registra las herramientas descubiertas en mcp.json.
4
4. Agrega la tool local de gráficos junto a las herramientas remotas.
5
5. Envía al chat el stream de respuestas, llamadas y resultados de herramientas.

App 09 · Ejecutar y explorar

shiny::runApp("app-09")

Pide

«Consulta la población de Chile entre 2020 y 2025 y grafícala».

Observa

El chat muestra las tools remotas del MCP y la llamada local a graficar_serie(). Comprueba el indicador, las unidades y qué años son proyecciones.

05 · Aplicaciones más completas

Casos 91 y 92 · Combinar los patrones del taller y organizar las responsabilidades de las tools.

Separar responsabilidades

Estos dos ejemplos comparten una organización de tools.

Responsabilidad 91 · Colombia territorial 92 · Series de Banrep
Resolver resolver_catalogo() resolver_serie()
Obtener datos consultar_indicador() consultar_sdmx()
Mostrar visualizar_indicador() visualizar_serie()
Inspeccionar inspeccionar_vista() inspeccionar_vista()

La 91 consulta datos locales; la 92 usa un servicio remoto.

App 91 · Colombia territorial

shiny::runApp("app-91-colombia-territorial")

Pide

«Colorea los departamentos según el porcentaje de hogares con internet».

Observa

El mapa y la leyenda cambian; la respuesta usa los datos de la vista.

21 indicadores en 32 departamentos y Bogotá · ECV 2025 y geometría del MGN 2018.

App 92 · Series del Banco de la República

shiny::runApp("app-92-banrep-sdmx")

Pide

«Grafica la TRM de 2025 y dime cómo terminó el año».

Observa

El gráfico y la tabla muestran la serie descargada, con su unidad y período. El modelo inspecciona la vista para explicar el resultado.

Cierre

La interfaz conserva su función y suma una nueva forma de expresar intención.