input → renderPatrones progresivos para conectar conversación, datos e interfaz
2026-11-01
Preparar la app y conversar
Consultar y actualizar datos
Actuar sobre la interfaz
Conectar herramientas externas
Organizar las tools en aplicaciones más completas
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.
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)#| '!! 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)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)Selecciona Europa y luego Asia en el filtro de continente.
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.
{ellmer}Para usar OpenAI desde {ellmer}, configura la clave en un archivo .Renviron.
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.
{ellmer}Antes de conectarlo a una aplicación, probamos el cliente y la conversación de forma aislada.
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}.
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.
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.
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){ellmer} y {shinychat}.
chat.
«¿Cuántos países se muestran en el dashboard?»
El chat puede conversar, pero todavía no puede consultar los datos ni conocer el filtro seleccionado.
App 03–05 · Dar acceso a los datos y modificar el estado reactivo.
{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))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."
))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)Selecciona Europa y pregunta: «¿Cuántos países se muestran y cuál es su esperanza de vida promedio?»
El resumen del chat coincide con los indicadores. Cambia a Asia y vuelve a preguntar.
La tool consulta toda la tabla paises; el filtro manual del dashboard no limita esa consulta.
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)«Lista todos los países de Europa con su esperanza de vida, de mayor a menor.»
La tool consulta la tabla completa y devuelve el resultado al chat. La vista del dashboard permanece igual.
{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))Consultar devuelve datos al chat. Actualizar cambia la consulta reactiva que alimenta el dashboard.
La tool cambia el estado. Shiny propaga el cambio hasta los outputs.
actualizar_dashboard()
Recibe una consulta SQL.
consulta_actual()
Invalida sus dependientes.
data()
Ejecuta la nueva consulta.
Una escritura en consulta_actual() activa toda la cadena reactiva.
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)«Muestra solo los países de Europa en el dashboard.» Luego: «Reinicia el dashboard».
La tool cambia el estado reactivo: se actualizan los indicadores, el gráfico y la tabla.
App 06–08 · Abrir vistas, comparar controles y mover la cámara del mapa.
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))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.
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)«Abre una tabla con 10 países.» Después: «Grafica PIB per cápita versus esperanza de vida por continente».
Las tools abren modales. Pregunta «¿Qué patrón ves?» para usar la imagen devuelta al modelo.
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.
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».
Ambos caminos muestran los mismos países. Cambia un filtro manual para volver al modo de controles.
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])
}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)«Colorea el mapa por población.» Después: «Vuela a Colombia».
El cambio de color vuelve a renderizar el mapa. El vuelo mueve la cámara mediante un proxy.
App 09 · Consultar un servicio externo y graficar sus datos con R.
MCP es un protocolo para descubrir y ejecutar herramientas de otros servicios desde nuestra aplicación.
{mcptools} las convierte en tools de {ellmer}. El modelo decide cuándo llamarlas.Usaremos un servidor comunitario de datos del FMI, sin clave propia. El modelo sigue necesitando OPENAI_API_KEY.
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.
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:
mcp_tools() descubre las tools remotas. Después registramos la función de gráficos definida en R.
El registro de graficar_serie() conserva el patrón de las apps anteriores.
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)«Consulta la población de Chile entre 2020 y 2025 y grafícala».
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.
Casos 91 y 92 · Combinar los patrones del taller y organizar las responsabilidades de las tools.
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.
«Colorea los departamentos según el porcentaje de hogares con internet».
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.
«Grafica la TRM de 2025 y dime cómo terminó el año».
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.
La interfaz conserva su función y suma una nueva forma de expresar intención.
input → render