> ## Documentation Index
> Fetch the complete documentation index at: https://docs.truffle.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Install Steps Reference

> Every supported install step in truffile.yaml

Install steps run on the device, in order, when `truffile deploy` builds your app. They're declared under the top-level `steps:` array in `truffile.yaml`:

```yaml theme={null}
steps:
  - name: Install dependencies
    type: bash
    run: |
      pip install --no-cache-dir httpx
  - name: Copy app files
    type: files
    files:
      - source: ./my_app_foreground.py
  - name: Configure
    type: text
    fields:
      - name: api_key
        label: API Key
        type: password
        env: MY_APP_API_KEY
```

Valid app manifest step types are **`bash`**, **`files`**, **`text`**, **`oauth`**, and **`welcome`**. Browser/VNC steps are rejected by `truffile validate`.

<Note>
  The local CLI builder supports `oauth` with a model-friendly flow: it prints the authorization URL, accepts a pasted callback URL or authorization code, exchanges it for tokens, writes the token file into the app container, and injects the token file env var into later build steps and the final app process. If an update reaches an auth step that cannot complete non-interactively, fail clearly and ask the user to reauthenticate from Settings.
</Note>

***

## Step ordering

Steps execute top-to-bottom. The three practical rules:

1. **Install dependencies before anything that needs them.** Put your `bash` step that runs `pip install` early.
2. **Copy files before running them.** Any `bash` step or `text` validator that invokes a Python file needs a preceding `files` step that uploads it.
3. **Collect credentials after files are in place.** `text` validators and OAuth verify steps typically call one of your own Python scripts, so those scripts must already be on disk.
4. **Verify OAuth after the OAuth step.** Put a `bash` step after `oauth` that calls `python ./foreground.py --verify` and any background verify script.

A safe default order: `bash` (dependencies) → `files` (copy scripts) → `text` or `oauth` (credentials) → `bash` (verify/finalize).

### Common step fields

Most examples also use these optional fields:

| Field                  | Applies to        | Description                                                                                                            |
| ---------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `update_policy`        | all steps         | When the step should run on update. Examples use `run_on_update` for steps that should repeat when the app is updated. |
| `update_check`         | auth/config steps | Command used to decide whether existing auth/config is still valid before asking again.                                |
| `ui_state_on_show`     | interactive steps | UI state to surface when the step needs user input, often `user_interaction_ready`.                                    |
| `ui_state_on_complete` | interactive steps | UI state after completion, often `move_to_background`.                                                                 |

***

## `bash`

Runs a shell command inside the app's build container. Use it for installing dependencies, creating directories, pre-warming caches, etc.

```yaml theme={null}
- name: Install dependencies
  type: bash
  run: |
    apk add --no-cache gcc musl-dev libffi-dev openssl-dev
    pip install --no-cache-dir "httpx>=0.27.0" "cryptography>=42.0.0"
```

### Options

| Field  | Required | Description                                                                      |
| ------ | -------- | -------------------------------------------------------------------------------- |
| `name` | no       | Display label shown while the step runs. Defaults to `"bash"`.                   |
| `type` | yes      | Must be `"bash"`.                                                                |
| `run`  | yes      | Shell command to execute. Use a YAML block scalar (`\|`) for multi-line scripts. |

### Exit codes

If the command exits non-zero, the deploy aborts and the step is reported as failed:

```
✗ Step 'Install dependencies' failed with exit code 1
```

There's no retry. Write commands that are idempotent and handle their own errors — e.g. `mkdir -p` instead of `mkdir`, `pip install --no-cache-dir` instead of rebuilding caches.

### The container environment

The base image is Alpine-based (`apk` is the package manager, not `apt`). Python is preinstalled. Commands run with the working directory set to the app's exec directory (usually `/`).

<Tip>
  You can open a shell in the container mid-deploy to experiment with commands before codifying them. Run `truffile deploy --interactive <path>` — after your steps finish, you get an interactive shell inside the same container. Type `exit` or Ctrl+D to finalize the deploy.
</Tip>

***

## `files`

Uploads files from your app directory into the build container.

```yaml theme={null}
- name: Copy app files
  type: files
  files:
    - source: ./config.py
      destination: ./config.py
    - source: ./auth.py
      destination: ./auth.py
    - source: ./my_app_foreground.py
      destination: ./my_app_foreground.py
```

### Options

| Field   | Required | Description             |
| ------- | -------- | ----------------------- |
| `name`  | no       | Display label.          |
| `type`  | yes      | Must be `"files"`.      |
| `files` | yes      | A list of file entries. |

Each entry in `files`:

| Field         | Required | Description                                                        |
| ------------- | -------- | ------------------------------------------------------------------ |
| `source`      | yes      | Path relative to your app directory. Can be a file or a directory. |
| `destination` | yes      | Path in the container (usually relative to the exec cwd).          |

### File vs directory

If `source` points to a file, it's uploaded to `destination`.

If `source` points to a **directory**, every file inside it is uploaded recursively. `__pycache__` directories are skipped automatically. The `destination` is treated as a destination directory, and files are placed underneath it preserving the relative structure:

```yaml theme={null}
- name: Copy skills
  type: files
  files:
    - source: ./skills/
      destination: ./skills/
```

This uploads `./skills/foo.py` → `./skills/foo.py`, `./skills/subdir/bar.py` → `./skills/subdir/bar.py`, and so on.

### Missing files

If `source` doesn't exist, deploy fails immediately:

```
FileNotFoundError: no such file: /path/to/my-app/missing.py
```

`truffile validate` catches this before you even try to deploy — it resolves every `source` against the app directory and errors if anything is missing.

***

## `text`

Prompts the user for one or more values during deploy, stores them as environment variables on your foreground/background processes, and optionally runs a validator command to verify the inputs immediately.

Use `text` for API keys, access tokens, and other credentials the user can paste directly. Use `oauth` when the provider needs a browser authorization flow.

```yaml theme={null}
- name: Configure GitHub access token
  type: text
  content: |
    Enter a GitHub personal access token so Truffle can talk to GitHub on your behalf.

    To create one:
    1. Go to https://github.com/settings/tokens
    2. Click **Generate new token**
    3. Select the scopes you want (recommended: `repo`, `read:org`, `gist`)
    4. Copy the token and paste it below
  fields:
    - name: github_access_token
      label: GitHub Access Token
      type: password
      placeholder: ghp_...
      env: GITHUB_ACCESS_TOKEN
  validator:
    type: bash
    run: |
      python ./github_foreground.py --verify
    error_message: |
      Could not verify GITHUB_ACCESS_TOKEN. Check the token is valid and has the required scopes.
```

### Top-level options

| Field       | Required | Description                                                                                                     |
| ----------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `name`      | no       | Display label shown before prompting.                                                                           |
| `type`      | yes      | Must be `"text"`.                                                                                               |
| `content`   | no       | Markdown-ish text shown to the user before the field prompts. Use a block scalar (`\|`) for multi-line content. |
| `fields`    | yes      | List of input fields.                                                                                           |
| `validator` | no       | Optional bash command that runs after all fields are collected.                                                 |

### Field options

Each entry in `fields`:

| Field         | Required | Description                                                                                                           |
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `name`        | yes      | Internal field name.                                                                                                  |
| `label`       | no       | Label shown to the user. Falls back to `name` if omitted.                                                             |
| `type`        | no       | `"text"` (default) or `"password"`. `password` hides input as the user types.                                         |
| `placeholder` | no       | Hint shown in parentheses in the prompt, e.g. `API Key (sk_...)`.                                                     |
| `default`     | no       | Value used if the user presses Enter without typing anything.                                                         |
| `env`         | yes      | The environment variable name that receives the collected value. Must match what your app reads via `os.getenv(...)`. |

The value the user enters is exposed to your foreground and background processes via the env var named in `env`. The injection happens at the process level — by the time your Python code runs, `os.getenv("GITHUB_ACCESS_TOKEN")` returns the token the user entered.

### Validator options

The validator runs a bash command *after* all fields are collected, with the collected values already set in the environment. If the command exits non-zero, install fails with your `error_message`.

| Field           | Required | Description                                                        |
| --------------- | -------- | ------------------------------------------------------------------ |
| `type`          | yes      | Must be `"bash"`.                                                  |
| `run`           | yes      | Shell command. Typically calls `python ./your_script.py --verify`. |
| `error_message` | no       | Message shown to the user when the validator exits non-zero.       |

The env vars from your `fields` are prepended to the command, so `python ./foo.py --verify` runs with `MY_API_KEY='whatever the user typed'` already set.

<Tip>
  The standard pattern is to add a `--verify` flag to your foreground or background script and call it from the validator. The `BackgroundWorkerApp.main()` entry point handles `--verify` automatically — it calls `verify_worker()`, prints the result, and exits 0 or 1. For foreground apps you wire it up yourself with `argparse`.
</Tip>

### Single-field example

```yaml theme={null}
- name: Configure Exa API key
  type: text
  content: |
    Enter your Exa API key.

    Get one at https://dashboard.exa.ai/onboarding.
  fields:
    - name: exa_api_key
      label: Exa API Key
      type: password
      placeholder: exa_...
      env: EXA_API_KEY
  validator:
    type: bash
    run: |
      python ./exa_foreground.py --verify
    error_message: |
      Could not verify EXA_API_KEY. Confirm the key is valid and has access.
```

### Multi-field example

Multiple fields in a single step are all prompted in order, then a single validator runs with all values in the environment:

```yaml theme={null}
- name: Configure Kalshi API credentials
  type: text
  content: |
    Enter your Kalshi API credentials.

    Create an API key at https://kalshi.com/account/profile and paste both
    the key and the private key PEM below.
  fields:
    - name: kalshi_api_key
      label: Kalshi API Key
      type: password
      placeholder: 00000000-0000-0000-0000-000000000000
      env: KALSHI_API_KEY
    - name: kalshi_private_key
      label: Kalshi Private Key (PEM)
      type: password
      placeholder: -----BEGIN PRIVATE KEY-----
      env: KALSHI_PRIVATE_KEY
  validator:
    type: bash
    run: |
      python ./kalshi_background.py --verify
    error_message: |
      Could not verify Kalshi credentials. Check both the API key and private key are correct.
```

### Text field with a default

```yaml theme={null}
- name: Configure IMAP server
  type: text
  fields:
    - name: imap_server
      label: IMAP Server
      type: text
      placeholder: imap.gmail.com
      default: imap.gmail.com
      env: IMAP_SERVER
```

If the user just presses Enter, the value is `imap.gmail.com`.

***

## How env vars reach your process

Values collected by `text` steps are injected into your foreground/background processes *on top of* whatever's already in the `environment:` block under `metadata.foreground.process` or `metadata.background.process`.

For example, this yaml:

```yaml theme={null}
metadata:
  foreground:
    process:
      cmd: ["python", "my_app_foreground.py"]
      environment:
        PYTHONUNBUFFERED: "1"

steps:
  - type: text
    fields:
      - name: api_key
        type: password
        env: MY_APP_API_KEY
```

results in a process that sees both `PYTHONUNBUFFERED=1` *and* `MY_APP_API_KEY=<user input>` in its environment. You read the user-supplied value the same way as any other env var:

```python theme={null}
import os
api_key = os.getenv("MY_APP_API_KEY", "").strip()
if not api_key:
    raise RuntimeError("MY_APP_API_KEY is not set")
```

***

## `oauth`

Runs an OAuth authorization flow during install and writes the resulting token payload to a file in the app container. Use it when the provider requires browser authorization instead of a pasted API key.

There are two real patterns in the bundled examples:

* **Standard OAuth**: fixed authorization/token endpoints and client metadata, like `whoop`.
* **Remote MCP OAuth**: an MCP server exposes protected-resource metadata or uses dynamic client registration, like `notion`.

### Standard OAuth example

```yaml theme={null}
- name: WHOOP OAuth Sign-In
  type: oauth
  update_policy: run_on_update
  update_check: python ./foreground.py --verify
  provider: Whoop
  description: Sign in to WHOOP to connect profile, recovery, sleep, and workout data.
  client_id: your-client-id
  client_secret_env: null
  redirect_uri: https://truffle.net/api/oauth/callback
  auth_endpoint: https://api.example.com/oauth/authorize
  token_endpoint: https://api.example.com/oauth/token
  scopes:
    - offline
    - read:profile
  token_output_file: /root/.my-app/oauth.json
  token_file_env_name: MY_APP_TOKEN_FILE
  app_var_key: oauth_state
```

### Remote MCP OAuth example

```yaml theme={null}
- name: Notion OAuth Sign-In
  type: oauth
  update_policy: run_on_update
  update_check: python ./notion_foreground.py --verify
  provider: Notion
  description: Sign in to Notion to connect your workspace through Notion MCP.
  redirect_uri: https://truffle.net/api/oauth/callback
  scopes: []
  dynamic_client_registration: true
  resource_metadata_endpoint: https://mcp.notion.com/.well-known/oauth-protected-resource
  oauth_resource: https://mcp.notion.com
  client_name: Truffle
  auth_endpoint: https://mcp.notion.com/authorize
  token_endpoint: https://mcp.notion.com/token
  client_id_env: NOTION_CLIENT_ID
  client_secret_env: null
  token_output_file: /root/.notion-oauth/tokens.json
  token_file_env_name: NOTION_OAUTH_TOKEN_FILE
  app_var_key: notion_oauth_state
```

### OAuth fields

| Field                         | Required    | Description                                                                               |
| ----------------------------- | ----------- | ----------------------------------------------------------------------------------------- |
| `provider`                    | yes         | Human-readable provider name shown during auth.                                           |
| `description`                 | no          | Short explanation shown to the user.                                                      |
| `redirect_uri`                | yes         | Callback URL registered with the provider.                                                |
| `auth_endpoint`               | yes         | Provider authorization endpoint.                                                          |
| `token_endpoint`              | yes         | Provider token endpoint.                                                                  |
| `scopes`                      | no          | List of OAuth scopes. Use `[]` when the remote MCP server handles its own scope defaults. |
| `client_id`                   | usually     | Static OAuth client ID.                                                                   |
| `client_id_env`               | alternative | Env var where a dynamically registered client ID is written/read.                         |
| `client_secret`               | optional    | Static client secret when the app owns one.                                               |
| `client_secret_env`           | optional    | Env var for the client secret. Use `null` for public/DCR clients with no secret.          |
| `dynamic_client_registration` | no          | Set `true` for remote MCP OAuth providers that support DCR.                               |
| `resource_metadata_endpoint`  | remote MCP  | OAuth protected-resource metadata URL.                                                    |
| `oauth_resource`              | remote MCP  | Resource audience for the remote MCP server.                                              |
| `client_name`                 | remote MCP  | Client display name used during DCR.                                                      |
| `token_output_file`           | yes         | Path where the installer writes token JSON in the app container.                          |
| `token_file_env_name`         | yes         | Env var exposed to the app process with the token file path.                              |
| `app_var_key`                 | yes         | App-scoped key used to persist OAuth state.                                               |
| `update_check`                | no          | Verify command to avoid unnecessary reauth when tokens still work.                        |

### App-side OAuth helper

In Python, subclass `OAuth` for normal providers or `RemoteMcpOAuth` for remote MCP providers. The token helper should load from the env var named by `token_file_env_name`, expose `get_oauth_payload()`, and provide a `--verify` path that exits non-zero when the token is missing or expired.

The `whoop` example uses `WhoopOAuth(OAuth)` and stores tokens at `WHOOP_TOKEN_STORE_PATH`. The `notion` example uses `NotionAuth(RemoteMcpOAuth)` and stores tokens at `NOTION_OAUTH_TOKEN_FILE`.

For public/dynamically registered remote MCP clients, the CLI uses PKCE automatically when no client secret is available. Token JSON also includes refresh metadata such as `client_id`, `client_secret` when present, `redirect_uri`, `token_endpoint`, and `resource`, so app-side helpers can refresh later without needing separate env vars.

***

## `welcome`

Shows informational install content and requires no credentials. Use it when the app is ready to run after files are copied and optional verification has passed.

```yaml theme={null}
- name: Connect Viator
  type: welcome
  update_policy: run_on_update
  ui_state_on_complete: move_to_background
  content: |
    Viator adds travel experience search and detail lookup tools.

    No sign-in is required. Continue to finish installing the app.
```

The `viator` example uses this pattern after a `bash` verify step because its upstream MCP server does not require user auth.

***

## Validating before you deploy

`truffile validate` catches most step-level problems before you ever hit the device:

* Unknown step types are rejected with a clear message that lists the supported manifest types.
* `vnc` / browser setup steps are rejected with a clear message telling the user to install through Symphony Settings.
* Missing required fields (`type`, `run` on bash, `files` on files) produce errors.
* Missing `source` files in `files` steps are caught.
* Python syntax errors in any uploaded `.py` file are caught.

Run `truffile validate ./my-app` before every `truffile deploy` — it's nearly instant and catches everything except runtime failures in your `bash` commands and `text` validators.
