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:
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 oftar_make().schedule:inhub.yamladds a timetable, which the hub reads in its own time zone. A pipeline runs once at a time.hub runs <name>lists runs, andhub 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-groupshas no effect on a pipeline. - The
_targetsstore is a persistent volume of sizestore-size, so runs are incremental, as they are locally. A run may last up totimeoutminutes, 120 by default. - Secrets and
envreach a run in the same way as they reach an app. A run calls APIs on the hub as its owner. hub deleteremoves 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.