Skip to content

Apps, documents, APIs, MCP servers and pipelines

This page describes what each kind of project becomes once hub deploy has built it, at which address it is reached, and who may reach it.

Apps and documents

A Shiny app in R or Python, a Shiny document, or a static Quarto or R Markdown document becomes a tile on the apps page at https://apps.hub.<your domain>/app/<name>. It opens for anyone who logs in with your identity provider and is in its access-groups. The hub starts one container per user when that user first opens it, from the image the deploy built. A static document is rendered at build time, and its container only serves the files. The build has none of the app's environment: no .Renviron, no env from hub.yaml and no pipeline store. A document that needs any of them must be a Shiny document, which runs when it is opened, or be rendered by a pipeline.

Opening an app takes a few seconds while its container starts. prestart: N in hub.yaml keeps N containers warm. To replace them with the new build, each deploy then restarts the apps server (about a minute).

Who is using the app

In R, hub::user(session) and hub::groups(session) return the user and their groups, and hub::in_group("finance", session) tells whether the user is in a group. In Python, the same values are in the request headers X-SP-UserId and X-SP-UserGroups. The hub sets both on every request, and the browser cannot forge them. Group names arrive in upper case (FINANCE) whatever your identity provider sends, so compare them without regard to case, as in_group() does. Do not read SHINYPROXY_USERNAME from the environment: it describes the container, and a warm container was started before any user opened it. When you run the app locally, both return nothing, so an app that depends on them should handle that case.

Environment and secrets

When you deploy, a .Renviron or .env next to the sources becomes the app's secret, and the app's process receives its contents as environment variables. hub env set adds or overrides keys without a deploy. Your hub admin can also define an optional set of variables that reaches every app in the same way (Variables for every app shows how). The app's process runs as an unprivileged user, with the limits that resources sets: two cores and two gigabytes by default.

A static document is rendered by the build, which receives neither the secret nor the variables under env: in hub.yaml, so its code cannot read them. A document that needs them must be a Shiny document (server: shiny in Quarto, runtime: shiny in R Markdown), which renders in its container on the hub.

APIs

A plumber or plumber2 API in R, or a FastAPI application in Python, runs as one process behind https://apps.hub.<your domain>/api/<name>/. A FastAPI application must be the object named app in its file. The hub runs an R API with plumber2 when renv.lock lists plumber2 and not plumber. When the lock file lists both or neither, it runs plumber2 if the API file uses it and plumber otherwise. An API does not appear on the apps page. A redeploy or rollback restarts it without failing any requests. access-groups in hub.yaml decides who may call it, and access-clients does the same for systems outside the hub. The hub checks every call before the API sees it. There are three ways to call an API.

From the hub, as yourself, without a credential. From an IDE session, a Shiny app or a pipeline run, call the address inside the hub, http://api-<name>/, which hub::api_url("<name>") returns. The hub knows which user the calling process belongs to (for a pipeline run, its owner) and checks access-groups. The API receives the caller in X-SP-UserId and X-SP-UserGroups. A call from inside the hub to the public URL carries no identity, so use the internal address.

From outside, with a token from your identity provider. Send Authorization: Bearer <token> to the public URL. A token issued to the client that people log in with identifies that user, with their groups. With Cognito, send the ID token. A token issued to another client identifies a machine. The hub admits it when its client id is in access-clients, and the API sees the caller as client:<id>. This is standard OAuth and works from any language. The administrator of your identity provider registers machine clients.

Tokens require api_token_issuer in the hub's configuration. Without it, the hub accepts no tokens. Tokens of people also require api_token_client_id, the client that people log in with. Without it, the hub treats every token as a machine's. With an identity provider other than Cognito, api_token_user_claim and api_token_groups_claim name the claims that carry the user and the groups.

With the key, for a caller that has no identity. hub apikey <name> prints the URL, the user api and the key. Only the owner of the API, its maintainers and hub admins can see the key. Call the API with curl -u api:<key> <url>. The key bypasses access-groups, so the owner decides whom to give it to, and the API sees no caller. --rotate replaces the key. The API's own documentation page (/__docs__/ for plumber, /docs for FastAPI) asks the browser for the key.

Inside the API, hub::user(request) and hub::groups(request) return who is calling, or NULL for a caller that uses the key. In plumber2, pass the handler's request argument. In plumber, pass req. In Python, read the headers. To restrict one endpoint to a group in plumber2:

if (!hub::in_group("finance", request)) plumber2::abort_forbidden()

The API never receives the Authorization header that a caller sends. The hub removes it once it has checked the call, so neither a key nor a token reaches the API's code, and an API cannot run a login of its own on that header. A request body may be up to 100 MB. The hub refuses a larger one with status 413.

A call to the public URL arrives with X-Forwarded-Proto: https and the caller's address first in X-Forwarded-For. FastAPI reads both, so the links and redirects it builds use https. The hub also starts a FastAPI application with its root path set to /api/<name>, so its redirects and its /docs page stay under the API's address. A process inside the hub can set these headers to any value, so an API must not decide who is calling from them.

MCP servers

A project whose _server.yml contains engine: mcptools and tools: tools.R deploys as an MCP server. tools.R returns a named list of ellmer::tool() objects, and the hub serves them over HTTP at the same addresses as an API, with the same three ways in. The mcp template of hub::new_app() creates such a project. An MCP server does not appear on the apps page.

At every session start, the hub registers in the user's Claude Code and Posit Assistant every MCP server whose access-groups admit that user. Each server is registered under its name, at the internal address, without a credential. The session identifies the user, and the hub checks the groups on every call. Servers that no longer exist are removed again. After you deploy a server, your colleagues' agents have its tools at their next session start. Your own agent has them after a restart, or straight away if you run claude mcp add --transport http <name> http://api-<name>/. hub mcp lists the servers you have.

A client outside the hub, such as Claude Desktop on a laptop, uses the public URL that hub mcp shows and sends the key from hub apikey <name> as a basic-auth header. To check a server without any client, send a POST request with a JSON body to its internal address from a session:

$ curl -s -X POST http://api-<name>/ -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Pipelines

A directory with _targets.R deploys as a pipeline. The hub builds it like an app, with the lock file, system libraries and load check, and then runs it as a job instead of a web app. A pipeline does not appear on the apps page.

  • hub run <name> starts a run now and streams the log of tar_make(). schedule: in hub.yaml adds a timetable, which the hub reads in its own time zone. A pipeline runs once at a time.
  • hub runs <name> lists runs, and hub logs <name> [run] prints a log. Runs are kept for seven days. Of the runs that the schedule starts, the hub keeps at most the last ten that succeeded and the last ten that failed. Because a log can contain the pipeline's data, only the owner, the maintainers and admins can list the runs and read their logs.
  • Only the owner, the maintainers and admins can start a run. access-groups has no effect on a pipeline.
  • The _targets store is a persistent volume of size store-size, so runs are incremental, as they are locally. A run may last up to timeout minutes, 120 by default.
  • Secrets and env reach a run in the same way as they reach an app. A run calls APIs on the hub as its owner.
  • hub delete removes the store together with the pipeline.

Read a pipeline's results from an app

List the pipelines whose results an app needs under uses: in its hub.yaml. The hub then mounts each store read-only at /srv/pipelines/<name>, and hub::store("<name>") returns that path, for example in targets::tar_read(x, store = hub::store("sales-etl")). In the IDE, where no store is mounted, the same call returns _targets, the store in the working directory. The code runs unchanged there, and reads the results of the project you run it in.

You can use a pipeline that you own, or one that lists one of your groups in its access-groups. The hub checks this again whenever it starts the app, so a pipeline that no longer admits you, or that someone else has deployed under the same name, is not mounted.

A static document cannot use a store, because the hub renders it at build time, where no store is mounted. The deploy refuses uses: for one. Make it a Shiny document (runtime: shiny in R Markdown, server: shiny in Quarto), which reads the store when it is opened, or render the document in the pipeline.

This mounts one volume into several containers, which not every hub can do. A hub on one machine can. On a cluster, it needs a storage class that supports it. On a hub that cannot, the deploy refuses uses: and says so.

Build from your own Dockerfile

If the directory contains a Dockerfile, the hub skips detection and builds that file as it is. Your Dockerfile has to copy in the sources itself. The hub runs the result as the type you give in hub.yaml: an app on port 3838 by default, an API on port 8000 for plumber, fastapi or mcp, or a pipeline for targets, whose image runs it with the store mounted at /srv/app/_targets. The deploy refuses any other type. The hub adds nothing to such an image: no lock-file restore, no system libraries and no load check. Its container runs as the user that the image names, without a few kernel capabilities that no app needs, such as raw network access.

By default, a hub in renv mode accepts a Dockerfile and a hub in stack mode refuses it. Your hub admin can change that with deploy_allow_dockerfile in the hub's configuration.