Build
Apps
On this page
What an app is
An app is a program with a server: source code plus a Procfile, a small file that says how to start each part of the program. Valet builds the code, runs it, and serves it at its own address. Use an app when an artifact isn't enough: the tool has to save data, keep a secret, or run code somewhere other than the browser.
A Procfile has one line per process type:
web: node server.js
worker: node jobs.jsThe web process gets the app's traffic. It must listen on the port
named in the PORT env var. Other types, such as worker, run in the
background.
Valet builds Node.js, Python and Go apps. It detects the language from
files in your code, such as package.json, requirements.txt or
go.mod. Ruby, PHP, Java and .NET aren't supported.
Apps follow the Twelve-Factor App principles for software that runs as a service:
- Dependencies are declared in the code, in files such as
package.json, and the build installs them. - Settings and secrets come from env vars, not from files in the code.
- A database or an LLM is a resource the app reaches through env vars.
- Each build becomes a numbered release.
- Processes keep no data on local disk, start and stop at any time, and scale by running more of a type.
- The app writes its logs to standard output and standard error, and Valet collects them.
Publish an app
Ask your agent:
Publish this as a Valet app called shop.Your agent creates the app, then publishes the source:
create_service
{ "kind": "app", "name": "shop" }publish_app
{
"name": "shop",
"title": "Shop",
"description": "Takes orders for team merchandise.",
"files": [
{ "path": "Procfile", "content": "web: gunicorn app:app" },
{ "path": "requirements.txt", "content": "flask==3.1.0\ngunicorn==23.0.0\n" },
{ "path": "app.py", "content": "from flask import Flask\n\napp = Flask(__name__)\n…" }
]
}An app needs a Procfile at the root. With files, publish_app
carries text only. An agent that can run git publishes from a Git
push instead, which sends only what changed and carries any file; see
Publish with Git. With the CLI,
valet apps create makes the app and links the folder, and
valet deploy commits every file in the folder except .git and
.valet, then pushes only what changed. Either way, Valet then builds
the app and starts it.
The app serves at https://<name>.<org>.valet.run, such as
https://shop.acme.valet.run. Its address shows an error page until
the first build is running.
Builds
A build fetches your code, detects the language, installs its dependencies, runs its build step and packages the result. A build can't read the app's env vars, so a build step can't use a secret.
publish_app returns before the build finishes, with the build's ID.
Your agent follows it with get_build, which returns the build's
state, the end of its log and the release it made. valet deploy
prints the build log as it runs, then each process as it starts, then
the address. valet deploy --no-wait prints the build's ID and returns.
valet builds show <id> reads that build later, and -f follows it as
valet deploy would. valet builds lists the app's recent builds and
their ids.
When a build fails, ask your agent:
The shop build failed. Read the build log and fix the cause.A build stops after 15 minutes, and the built app can be at most 512 MiB. Both are defaults.
Releases and rolling back
Each successful build becomes a release: v1, v2 and so on. Changing
an env var or attaching a resource also makes a new release.
A blocking release policy can refuse a release. The app keeps running its current release. See Release policies to review findings in the dashboard's Releases tab.
list_releases
{ "name": "shop" }There is no rollback command. To roll back, publish an earlier release's code again; it becomes a new, higher release. Ask your agent:
Roll shop back to v4.It finds the release with list_releases, reads its files with
get_source, then publishes them.
Processes and scaling
The formation is how many processes of each type run. The first
deploy starts one web process and none of the others, so scale a
worker up yourself:
scale_service
{ "name": "shop", "process_type": "worker", "count": 1 }
get_service
{ "name": "shop" }Each type runs 0 to 10 processes.
Each process runs on a 0.25 vCPU, 1 GB machine in one of two sizes.
economy-1x, the default, sleeps after 30 minutes without traffic and
wakes on the next request. standard-1x is the same machine and never
sleeps, for a tool that must answer at once:
scale_service
{ "name": "shop", "process_type": "web", "size": "standard-1x" }Changing size restarts the type's processes. Give a count and a size
together as
web=2:standard-1x.
Idling and waking
A web process on economy-1x that gets no requests stops, and the
next request starts it again. A standard-1x process never stops. That request waits while the app starts, for up to
75 seconds. The app must start listening on its port within 60
seconds.
By default an economy-1x web process stops about 35 minutes after
its last request. Other process types never idle; they run until you scale them
to 0.
A process loses its local files when it stops or restarts. Keep data in a database.
Who can open it
Apps have the same three access modes as artifacts: private (organization members, after they sign in to Valet), password and public. New apps are private.
set_service_access
{ "name": "shop", "kind": "app", "mode": "password", "password": "<password>" }set_service_access takes the password as an argument, so it appears
in the conversation. To keep it out, set it in the dashboard.
Share with one person
Sharing gives one person access to one app, as it does for an artifact. They don't need a Valet account, and they get nothing else your organization owns.
Valet emails each person a link. The link itself grants access, so forwarding the email forwards access. An app runs server code, so the person can use everything the app does, including anything that changes data.
share_service
{ "name": "shop", "kind": "app", "emails": ["alice@example.com"], "expires_in": "7d" }To remove access:
unshare_service
{ "name": "shop", "kind": "app", "emails": ["alice@example.com"] }valet apps shares shop lists who has access. The app sees each person
as a guest visitor.
Call a private app from a script
A script, a daemon, or a CLI has no browser session. Give it a service token. Mint one for the app, and name it for what will use it:
No MCP tool does this. Use the CLI.
--expires takes a duration such as 720h or an RFC 3339 timestamp.
Without it, the token never expires. Valet prints the token once, so
store it where the script can read it.
The script sends the token in an Authorization header:
curl -H "Authorization: Bearer $SHOP_TOKEN" https://shop.acme.valet.run/reportsA token works in every access mode and admits one app only. Valet strips
the header before the request reaches your app. The app sees an
X-Valet-Visitor token of kind service, with a sub of
token:<id> and a token claim that holds the token's id and
name. No person is visiting, so it has no email or name. Your app
decides what a service visitor may do. An app that accepts only the
kinds it knows refuses it. See Know who is
visiting.
A request with a revoked, expired, or unknown token gets a 401. Valet does not fall back to a signed-in session on the same request.
List an app's tokens, and revoke one when its script retires or its value leaks:
No MCP tool does this. Use the CLI.
The app refuses a revoked token on its next request. Mint a new one with a new name to restore access. By default an app holds up to 20 live tokens.
Know who is visiting
Valet checks access before it forwards a request. It also tells the app
who is visiting. Every request the app receives carries an
X-Valet-Visitor header: a signed token that expires after two minutes.
The token's kind claim says how the visitor got in:
| Kind | Who it is |
|---|---|
member | A signed-in member of the organization. |
guest | A person the app was shared with. |
password | Someone who entered the app's password. |
anonymous | Anyone, on a public app. A public app always sees anonymous. |
channel | Valet, delivering a webhook or schedule. See Webhooks and schedules. |
service | A script holding a service token. See Call a private app from a script. |
The token carries these claims:
| Claim | Meaning |
|---|---|
sub | A stable ID: user:<id> for a member, share:<id> for a guest. |
email | A member's verified address. For a guest, the address the share went to; Valet has not verified it. |
name | A member's name. |
org | The organization that owns the app. |
resource | The app. |
A channel token has no sub, email or name, because no person is
visiting. It carries channel (the channel's id, name and type)
and delivery_key instead.
Valet sets three env vars on every app: VALET_VISITOR_ISSUER,
VALET_VISITOR_AUDIENCE and VALET_VISITOR_JWKS_URL. The last names the
public key set at https://api.valet.dev/.well-known/valet-visitor-jwks.json.
Check the token's signature against that key set, its issuer and
audience against the other two, and its expiry.
Three rules keep this safe:
- Verify the token on every request. Don't trust a header you haven't verified.
- Key your records to
sub, notemail. An address can change, and a guest's is only the address the share was sent to. - Keep no session of your own. Valet checks membership and shares on every request, so a session of the app's own would outlast a removal or a revoked share.
Your agent's apps skill carries verification code for Node, Python and Go.
Webhooks and schedules
An app can receive a webhook from another service, or a call on a schedule, without becoming public. Create a channel on the app and give it a path, the route in your app that handles each delivery:
create_channel
{
"name": "shop",
"channel": "stripe",
"channel_kind": "webhook",
"path": "/webhooks/stripe",
"secret_name": "STRIPE_WEBHOOK_SECRET",
"verify_scheme": "stripe"
}The MCP tool reads the signing secret from an env var you set first with
set_env_vars; secret_name names it.
An app takes webhook, cron and heartbeat channels. Create a cron or
heartbeat channel the same way, with --schedule or --every.
Valet gives a webhook channel its own URL. A webhook on an app must
verify its sender: use hmac-sha256 (the default), stripe, svix,
slack, mercury or static-token. Valet checks the signature at its
gateway and drops any request that fails, so your app never sees it. The
app stays private; only the channel's URL is open to the sender.
For a webhook, Valet posts the provider's request to POST <path>
unchanged: the same body, and the same signature headers. A cron or
heartbeat channel posts this JSON each time it fires:
{
"channel": "nightly-report",
"type": "cron",
"delivery_key": "01JC8...",
"fired_at": "2026-10-30T09:00:00Z",
"schedule": "weekdays at 9:00am"
}Every delivery carries an X-Valet-Visitor token of kind channel.
Check it as you would a member's token (see
Know who is visiting), and check that its
channel.name is the channel you expect. A sleeping app wakes for a
delivery.
The delivery contract:
- Return a 2xx status once you have the delivery. Anything else, a 404 included, makes Valet try again for about a day.
- Expect repeats and reordering. Valet delivers each message at
least once, and not always in order. Record each
delivery_keyyou handle and skip one you have seen. - Answer quickly. Do slow work after you return.
If deliveries keep failing, the channel reports
channel.delivery_failed with the status the app
returned. Fix the route, or change the channel's path.
For example, an app that handles a Stripe event:
app.post("/webhooks/stripe", express.raw({ type: "*/*" }), async (req, res) => {
const visitor = await verifyVisitor(req.get("X-Valet-Visitor"));
if (visitor.kind !== "channel") return res.sendStatus(403);
if (await seen(visitor.delivery_key)) return res.sendStatus(200);
const event = JSON.parse(req.body);
if (event.type === "invoice.paid") await markPaid(event.data.object);
await remember(visitor.delivery_key);
res.sendStatus(200);
});verifyVisitor is your check of the token against the key set. Valet
has already verified Stripe's signature, so the app needs no Stripe
secret.
Env vars and secrets
An env var is a named setting the app reads when it starts, such as an API key. Ask your agent:
Add my Stripe key to shop as STRIPE_KEY.set_env_vars
{ "name": "shop", "vars": { "STRIPE_KEY": "sk_live_..." } }Values are secret by default: Valet stores them encrypted and never
shows them back. "secret": false, or --plain in the CLI, stores a
value you can read back, such as a region. An app reads both kinds as
ordinary env vars. Setting one restarts a running app.
A value set over MCP passes through the conversation. To keep a secret out of it, enter the value in the dashboard instead.
Traces and OpenTelemetry
An app's OpenTelemetry SDK needs no configuration. Valet sets these variables for the app process, and the SDK reads them by default:
OTEL_EXPORTER_OTLP_ENDPOINT
- Value
http://127.0.0.1:4318, a receiver inside the app's container
OTEL_EXPORTER_OTLP_PROTOCOL
- Value
http/protobuf
OTEL_SERVICE_NAME
- Value
- the app's name
OTEL_RESOURCE_ATTRIBUTES
- Value
valet.service.kind=app
The receiver passes the app's spans to the organization's and the app's
drains, stamped with the
resource attributes listed there. Valet also records a
<method> <route> span for each request it serves, so a request into the
app and the spans the app creates share one trace. The app's SDK continues
the trace from the traceparent header Valet forwards. Valet removes any
valet.* resource attribute the app sets.
The receiver accepts logs too. An app's SDK logs join the app's log
stream next to its stdout. An SDK that exports logs and also prints them
shows each line twice. The receiver listens on 127.0.0.1:4318, so an app
that runs its own collector must put it on another port.
The receiver takes export requests of up to 4 MiB after decompression. One request holds at most 50,000 spans, log records, or metrics and their data points. It also holds at most 100,000 protobuf fields for traces and logs, or 400,000 for metrics. Each attribute is a few fields: a histogram data point with eight attributes is about 42. The receiver refuses a larger request with status 413 and drops it. If your exports hit a limit, lower your SDK's batch size or the number of metric series. A busy receiver answers 503, which OTLP exporters retry.
To send spans somewhere else, set your own OTEL_* variable with
set_env_vars or valet env. Your value replaces Valet's.
A running app has these variables only after it restarts onto the newest runtime image. A redeploy picks them up. An app with no drain that accepts traces sends them nowhere.
Add a database
An app can attach a resource: a database Valet runs for it. Ask your agent:
Add a Postgres database to shop.attach_resource
{ "name": "shop", "resource": "shop-db", "provider": "valet-postgresql", "plan": "dev" }Creating the database takes about a minute. Over MCP, the first call starts it, and your agent repeats the call until the database is attached. Valet then restarts the app with these env vars:
DATABASE_URL
- Holds
- The connection address, with its TLS settings
DATABASE_POOLED_URL
- Holds
- An address for many short connections
DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USER, DATABASE_PASSWORD
- Holds
- The same details, one per variable
Connect with DATABASE_URL. Destroying the app keeps the database;
destroying the database deletes its data.
See Resources for how administrators manage them.
Logs and restarts
get_logs
{ "name": "shop", "kind": "app" }
restart_service
{ "name": "shop" }get_logs returns recent log lines, and valet logs -f keeps printing
new ones as they arrive. Valet keeps logs for 72 hours.
Each time a process starts, Valet logs why and where the time went:
web.1 info Woke from idle in 4.1s (getting a machine 0.3s, loading the build 2.9s, starting the app 0.8s)A slow first request after a quiet spell shows up here as a wake. A
long starting the app is your app's own boot.
A process that crashes restarts on its own, up to four times. After
that Valet stops it, and valet ps shows the reason.
Rename or remove
rename_service
{ "name": "shop", "new_name": "store" }
destroy_service
{ "name": "store" }Renaming moves the app to the new address; the old one stops working. Destroying takes the app down at once, with no confirmation, and keeps its resources.
What apps can't do
- Hold connectors. Use an artifact or an agent to reach a connector's tools.
- Keep files on disk. A process loses its files when it stops.
- Take longer than 60 seconds to answer. A response that hasn't started by then gets an error page.
- Take Slack, email, or MCP channels. An app takes webhook, cron and heartbeat channels. See Webhooks and schedules.
- Run one-off processes. Valet runs only the Procfile's process
types, and has no release step. Run a task such as a database
migration from one of them, for example when
webstarts.
Next steps
- Resources: manage databases and model access.
- Release policies: check changes before they go live.