# Admin API documentation Source: https://docs.tinfoil.sh/admin/admin-api Guide to API key, billing, and container routes available to organization admin API tokens ## Introduction The Admin API lets you manage regular organization API keys, inspect organization billing data, and access container-related APIs with an admin API key. Admin API keys are for organizations, not personal accounts. ### Common use cases * **Automating key management**: Create, rotate, or delete API keys programmatically, and set expiration dates or token limits based on your business logic. * **Building custom dashboards**: Display usage metrics, cost breakdowns by model, and historical trends using the billing and time-series endpoints. * **Monitoring usage**: Query aggregated or per-key usage statistics to track costs and token consumption. * **Managing containers programmatically**: Create, validate, update, stop, start, and delete containers. Inspect and update deployments, and manage org secrets, SSH keys, custom domains, and registry credentials. If you need per-user usage metrics for billing, the recommended approach is to run a [proxy server](/guides/proxy-server#usage-metrics-for-billing) that tracks token counts via response headers — rather than creating a separate API key per user in your system. ### Authentication Admin API keys provide programmatic access to your account resources. Admin keys are prefixed with `admin_` and must be included in the Authorization header as a Bearer token. Need to create an admin API key? Follow our step-by-step guide: [Getting a Tinfoil Admin Key](/admin/get-admin-key) Admin keys provide programmatic access to organization settings, including managing API keys, reading billing data, and administering container resources. Do not share admin keys or expose them in browsers, client-side code, or public repositories. Revoke admin keys when team members with admin access leave your organization. ```bash theme={"dark"} Authorization: Bearer YOUR_ADMIN_KEY ``` ### Available endpoints This page covers the following endpoints: #### API key management * `GET /api/keys` - List API keys * `POST /api/keys` - Create a new API key * `POST /api/keys/update` - Update an API key name or token cap * `DELETE /api/keys/:key` - Delete an API key #### Billing & usage * `GET /api/billing/usage` - Get aggregated usage statistics for all keys * `POST /api/billing/usage/key` - Get usage statistics for a specific key * `GET /api/billing/time-series` - Get time series data * `GET /api/billing/transactions` - Get transaction history #### Container endpoints * **Lifecycle & deployment** * `POST /api/containers/validate-name` - Validate a container name or custom domain before deploy * `POST /api/containers/validate` - Validate `tinfoil-config.yml` before deploy * `GET /api/containers/hosts` - List hosts available to the organization * `GET /api/containers` - List containers * `GET /api/containers/:id` - Get a specific container * `POST /api/containers` - Create a container * `POST /api/containers/:id/relaunch` - Relaunch a running or failed container * `POST /api/containers/:id/stop` - Stop a running container * `POST /api/containers/:id/start` - Start a stopped container * `DELETE /api/containers/:id` - Delete a container * `GET /api/containers/:id/update` - Get in-progress update status * `POST /api/containers/:id/update/accept` - Promote a ready staged update * `POST /api/containers/:id/update/cancel` - Cancel an in-progress update * `POST /api/containers/:id/github-connection` - Toggle GitHub App connection * `GET /api/containers/:id/metrics` - Get resource metrics * **Deployments** * `GET /api/deployments` - List deployments with instance counts * `PATCH /api/deployments/:id` - Update deployment settings * `POST /api/deployments/:id/update` - Update all or selected instances * **Related resources** * `GET /api/secrets` - List org secrets * `POST /api/secrets` - Create a secret * `GET /api/secrets/:name` - Get a secret's metadata * `PUT /api/secrets/:name` - Update a secret * `DELETE /api/secrets/:name` - Delete a secret * `GET /api/ssh-keys` - List org SSH keys * `POST /api/ssh-keys` - Create an SSH key * `DELETE /api/ssh-keys/:name` - Delete an SSH key * `GET /api/domains` - List custom domains * `POST /api/domains` - Add a domain * `POST /api/domains/:domain/verify` - Verify a domain * `DELETE /api/domains/:domain` - Delete a domain * `GET /api/registry-credentials` - List private registry credential status * `PUT /api/registry-credentials/:registry` - Create or update private registry credentials * `DELETE /api/registry-credentials/:registry` - Delete private registry credentials *** ## API Key Management ### List API Keys Returns all regular (non-admin) API keys in your organization. Keys you created are returned in full. Keys created by other members are masked (for example, `tk_12345***`). #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/keys" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} [ { "key": "tk_your_full_key_value_here", "name": "Production Key", "disabled": false, "expires_at": "2026-12-31T23:59:59Z", "max_tokens": 1000000, "metadata": { "environment": "production" }, "tokens_used": 450000, "input_tokens_used": 250000, "output_tokens_used": 200000, "request_count": 1200, "is_owner": true, "created_at": "2026-04-01T00:00:00Z", "last_used_at": "2026-04-09T12:34:56Z" }, { "key": "tk_abcde**************************", "name": "Staging Key", "disabled": false, "expires_at": null, "max_tokens": null, "metadata": {}, "tokens_used": 0, "input_tokens_used": 0, "output_tokens_used": 0, "request_count": 0, "is_owner": false, "created_at": "2026-04-02T00:00:00Z", "last_used_at": "2026-04-08T09:00:00Z" } ] ``` ### Create API Key Creates a new regular API key for your organization. Requires active token billing for the organization. #### Request Body Name for the API key. Must contain only alphanumeric characters, hyphens, underscores, spaces, and periods. ISO 8601 timestamp when the key should expire. If not provided, the key doesn't expire. Maximum number of tokens this key can use. If not provided, no limit is enforced. Custom metadata to attach to the key. Maximum size: 5KB. #### Example Request ```bash theme={"dark"} curl -X POST https://api.tinfoil.sh/api/keys -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{ "name": "Production API Key", "expires_at": "2026-12-31T23:59:59Z", "max_tokens": 1000000, "metadata": { "environment": "production", "team": "backend" } }' ``` #### Response ```json theme={"dark"} { "key": "tk_your_new_key_value_here", "name": "Production API Key", "disabled": false, "expires_at": "2026-12-31T23:59:59Z", "max_tokens": 1000000, "metadata": { "environment": "production", "team": "backend" }, "tokens_used": 0, "input_tokens_used": 0, "output_tokens_used": 0, "request_count": 0, "is_owner": true, "created_at": "2026-04-09T00:00:00Z", "last_used_at": null } ``` ### Update API Key Updates an existing regular API key in your organization. You can update any non-admin key in the organization, provided you have the full key value. #### Request Body The API key value to update, as returned by `GET /api/keys`. New display name for the API key. New token cap for the key. Set this to `0` to clear the existing cap. #### Example Request ```bash theme={"dark"} curl -X POST https://api.tinfoil.sh/api/keys/update -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{ "key": "tk_your_full_key_value_here", "name": "Staging API Key", "max_tokens": 0 }' ``` #### Response ```json theme={"dark"} { "message": "API key updated" } ``` ### Delete API Key Deletes a regular API key in your organization. You can delete any non-admin key, provided you have the full key value. #### Path Parameters The API key value to delete (for example, `tk_your_full_key_value_here`). #### Example Request ```bash theme={"dark"} curl -X DELETE https://api.tinfoil.sh/api/keys/tk_your_full_key_value_here -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} { "message": "API key deleted" } ``` *** ## Billing & Usage ### Get Usage Statistics Retrieves aggregated usage statistics for your organization for the specified time period. #### Query Parameters Time period for usage statistics. If omitted, returns all-time usage. Valid values: * `5m` - Last 5 minutes * `15m` - Last 15 minutes * `30m` - Last 30 minutes * `1h` - Last hour * `24h` - Last 24 hours * `7d` - Last 7 days * `30d` - Last 30 days * `60d` - Last 60 days * `90d` - Last 90 days #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/billing/usage?time=7d" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} { "tokens": 1500000, "input_tokens": 800000, "output_tokens": 700000, "requests": 3200, "cost": 15.00, "keys": { "Production Key": { "total_tokens": 1000000, "total_input_tokens": 600000, "total_output_tokens": 400000, "total_requests": 2000, "cost": 10.00, "models": { "glm-5-2": { "tokens": 600000, "input_tokens": 350000, "output_tokens": 250000, "requests": 1200, "cost": 6.00 }, "gpt-oss-120b": { "tokens": 400000, "input_tokens": 250000, "output_tokens": 150000, "requests": 800, "cost": 4.00 } } }, "Development Key": { "total_tokens": 500000, "total_input_tokens": 200000, "total_output_tokens": 300000, "total_requests": 1200, "cost": 5.00, "models": { "llama3-3-70b": { "tokens": 500000, "input_tokens": 200000, "output_tokens": 300000, "requests": 1200, "cost": 5.00 } } } } } ``` The `keys` object is grouped by API key name. ### Get Usage by Key Retrieves usage statistics for a specific regular API key in your organization. #### Query Parameters Time period for usage statistics. If omitted, returns all-time usage. Valid values: * `5m` - Last 5 minutes * `15m` - Last 15 minutes * `30m` - Last 30 minutes * `1h` - Last hour * `24h` - Last 24 hours * `7d` - Last 7 days * `30d` - Last 30 days * `60d` - Last 60 days * `90d` - Last 90 days #### Request Body The API key value to query (for example, `tk_your_full_key_value_here`), as returned by `GET /api/keys`. #### Example Request ```bash theme={"dark"} curl -X POST "https://api.tinfoil.sh/api/billing/usage/key?time=7d" -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{"key": "tk_your_full_key_value_here"}' ``` #### Response ```json theme={"dark"} { "prompt_tokens": 800000, "completion_tokens": 700000, "requests": 3200, "cost": 15.00 } ``` ### Get Time Series Data Retrieves time-series usage data for your organization over the specified period. #### Query Parameters Time period for the time series. Valid values: * `5m` - Last 5 minutes * `15m` - Last 15 minutes * `30m` - Last 30 minutes * `1h` - Last hour * `24h` - Last 24 hours * `7d` - Last 7 days * `30d` - Last 30 days * `60d` - Last 60 days * `90d` - Last 90 days The response interval is fixed per window: `5m` → `5s`, `15m` → `15s`, `30m` → `30s`, `1h` → `1m`, `24h` → `15m`, `7d` → `2h`, `30d` → `8h`, and `60d`/`90d` → `24h`. Empty buckets are included as zero-value data points. #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/billing/time-series?time=24h" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} { "data_points": [ { "time": "2026-04-08T12:00:00Z", "tokens": 50000, "input_tokens": 30000, "output_tokens": 20000, "requests": 100, "models": { "glm-5-2": { "tokens": 30000, "input_tokens": 18000, "output_tokens": 12000, "requests": 60 }, "llama3-3-70b": { "tokens": 20000, "input_tokens": 12000, "output_tokens": 8000, "requests": 40 } } }, { "time": "2026-04-08T12:15:00Z", "tokens": 75000, "input_tokens": 45000, "output_tokens": 30000, "requests": 150, "models": { "glm-5-2": { "tokens": 75000, "input_tokens": 45000, "output_tokens": 30000, "requests": 150 } } } ], "interval": "15m0s" } ``` ### Get Transaction History Retrieves invoice and standalone charge history for your organization. Viewing transaction history requires organization admin access. If the organization does not have a Stripe customer yet, the response is: ```json theme={"dark"} { "transactions": [] } ``` #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/billing/transactions" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} { "transactions": [ { "id": "in_1234567890", "date": "2024-01-01T00:00:00Z", "type": "Invoice", "description": "Monthly subscription", "amount": 99.00, "status": "completed", "invoice_url": "https://invoice.stripe.com/i/..." }, { "id": "ch_0987654321", "date": "2024-01-15T12:30:00Z", "type": "Charge", "description": "API Usage", "amount": 25.50, "status": "completed" } ] } ``` *** ## Containers Admin API keys can access the same container APIs as a browser session, as long as the key belongs to the target organization. Create, start, and relaunch operations require an active container subscription. Read-only endpoints and cleanup operations such as list, get, stop, and delete do not require an active subscription. Private registry endpoints require private registry access to be enabled for the organization. ### Lifecycle & Deployment #### Validate Container Name Checks whether a container name is valid and available for the current organization. You can also validate a custom domain before creating or relaunching a container. #### Request Body Container name. Must be lowercase alphanumeric with hyphens, max 64 characters. Whether to validate the name for debug mode. Custom domain to validate. Existing container UUID when validating a relaunch that keeps the same custom domain. #### Example Request ```bash theme={"dark"} curl -X POST "https://api.tinfoil.sh/api/containers/validate-name" -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{ "name": "my-app", "debug": false, "custom_domain": "api.example.com" }' ``` #### Response ```json theme={"dark"} { "available": true, "name": "my-app" } ``` #### Validate Container Config Validates the `tinfoil-config.yml` in a repository tag before create, replace, or relaunch. #### Request Body GitHub repository in `owner/repo` format. Git tag to validate. Existing container UUID. When present, instance-limit checks are skipped for relaunch validation. Existing container UUID. When present, instance-limit checks are skipped for replace validation. #### List Hosts Returns the container hosts available to the organization, including the default host and the GPU values available on each host. #### Response ```json theme={"dark"} [ { "name": "default-host", "is_default": true, "available_gpu_values": [1, 2, 4] } ] ``` #### List Containers Returns all containers in your organization. Responses may also include `ssh_port`, `host_name`, `host_gpu_type`, and `host_cpu_type` when available. #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/containers" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Response ```json theme={"dark"} [ { "id": "550e8400-e29b-41d4-a716-446655440000", "deployment_id": "7f8a4b3e-63c1-4bde-899b-36a35dfb1fc2", "name": "my-app", "repo": "myorg/my-app", "status": "ready", "domain": "my-app.myorg.tinfoil.dev", "current_tag": "v1.2.0", "cpus": 2, "gpus": 0, "memory_mb": 4096, "debug": false, "created_at": "2026-04-01T00:00:00Z" } ] ``` #### Get Container Returns details for a specific container. #### Path Parameters The container UUID. #### Example Request ```bash theme={"dark"} curl "https://api.tinfoil.sh/api/containers/550e8400-e29b-41d4-a716-446655440000" -H "Authorization: Bearer YOUR_ADMIN_KEY" ``` #### Create Container Creates and deploys a new container. The repository must contain a `tinfoil-config.yml` at the specified tag. #### Request Body Container name. Must be lowercase alphanumeric with hyphens. GitHub repository in `owner/repo` format. Git tag to deploy. The tag must have a published GitHub release. Environment variables as key-value pairs. Names of existing org secrets to inject. Names of existing org SSH keys to inject. Enable debug mode. Number of GPUs to allocate. Requires GPU access for the organization. Verified custom domain for the container. Target host name. Requires target host selection to be enabled for the organization. Existing container UUID to replace. #### Example Request ```bash theme={"dark"} curl -X POST "https://api.tinfoil.sh/api/containers" -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{ "name": "my-app", "repo": "myorg/my-app", "tag": "v1.2.0", "variables": { "LOG_LEVEL": "info" }, "secrets": ["DATABASE_URL"], "debug": false }' ``` #### Response Returns the created container object with status `deploying`. The container transitions to `ready` once the deployment completes. #### Relaunch Container Relaunches a running or failed container with a new tag, updated configuration, or both. Running single-GPU and CPU-only containers usually use a blue-green deployment flow. Multi-GPU updates use a different flow and cannot always be canceled once started. #### Path Parameters The container UUID. #### Request Body All fields are optional. Omitted fields keep their current values. New git tag to deploy. New environment variables. This replaces the full existing variable set. New secret-name list. This replaces the full existing secret set. New SSH key-name list. New GPU count. Toggle debug mode. Set or clear a custom domain. Pass an empty string to revert to the auto-generated domain. #### Example Request ```bash theme={"dark"} curl -X POST "https://api.tinfoil.sh/api/containers/550e8400-e29b-41d4-a716-446655440000/relaunch" -H "Authorization: Bearer YOUR_ADMIN_KEY" -H "Content-Type: application/json" -d '{ "tag": "v1.3.0" }' ``` #### Stop Container Stops a running container. The container record is preserved and can be started again later. #### Start Container Starts a stopped container. You can optionally pass the same body fields as relaunch to update configuration at start time. #### Delete Container Permanently deletes a container and its running enclave. The deployment remains while it has other instances. Outstanding billing is finalized before deletion. Returns `204 No Content` on success. #### Get Update Status Returns the status of an in-progress relaunch or restart. #### Response ```json theme={"dark"} { "has_update": true, "update_deployment_id": "dep_123", "update_tag": "v1.3.0", "update_status": "deploying" } ``` If no update is in progress: ```json theme={"dark"} { "has_update": false } ``` #### Accept Update Promotes a ready staged update and returns the updated container. #### Cancel Update Cancels an in-progress update and returns `204 No Content`. Multi-GPU updates cannot be canceled once started. #### Toggle GitHub App Connection Sets whether the container is connected to a GitHub App installation for its repo owner. #### Request Body Whether GitHub App connectivity should be enabled. #### Get Container Metrics Returns CPU, GPU, and memory utilization time series for a container. The `time` query parameter defaults to `24h`. ### Deployments A deployment groups all container instances in an organization that use the same GitHub repository. #### List Deployments Returns deployments with aggregate instance counts and settings. ```json theme={"dark"} [ { "id": "7f8a4b3e-63c1-4bde-899b-36a35dfb1fc2", "repo": "myorg/my-app", "default_staging": false, "instance_count": 2, "ready_count": 1, "failed_count": 1, "stopped_count": 0, "deploying_count": 0 } ] ``` There is no separate `GET /api/deployments/:id` endpoint. Resolve a deployment by ID or repository from the list response. #### Update Deployment Settings Updates shared settings and returns the deployment. Default staging mode for new instances and deployment-wide updates. #### Update Deployment Instances Updates every eligible instance, or a selected set, to one repository tag. Repository release tag to deploy. Staging override for this request. Omit it to use the deployment default. Container UUIDs to update. Omit it to update every instance. The response contains one result per targeted instance with status `updating`, `skipped`, or `failed`. Instances are skipped when they are not running or failed, or already have an update in progress. ### Related Resources #### Secrets Org secrets are encrypted values injected into containers at deploy time. Secret names must be `UPPER_SNAKE_CASE` or `kebab-case`. Returns all org-level secrets as metadata only. Secret values are never returned. Returns metadata for a single secret, including which containers use it. Creates a new org secret. #### Request Body Secret name. Secret value. Updates the value of an existing org secret. Deletes an org secret. If the secret is currently used by any container, the API returns `409 Conflict` and includes the blocking container names. #### SSH Keys SSH key names must be kebab-case, for example `my-deploy-key`. Returns all org-level SSH keys. Adds a new org SSH key for debug-mode containers. #### Request Body SSH key name in kebab-case. SSH public key, for example `ssh-ed25519 AAAA...`. Deletes an org SSH key. If the key is currently used by any container, the API returns `409 Conflict` and includes the blocking container names. #### Custom Domains Returns all custom domains for the organization, including verification details and which containers use each domain. Adds a custom domain for verification and returns the TXT and CNAME records required for setup. #### Request Body Domain name to register, for example `api.example.com`. Checks DNS records and updates the domain's verification state. Deletes a custom domain. If the domain is currently used by any container, the API returns `409 Conflict` and includes the blocking container names. #### Registry Credentials Private registry credentials are supported for `ghcr`, `gcr`, and `dockerhub`. Returns credential status for each supported registry, including whether credentials exist, whether they are expired, and when they were last updated. Creates or updates credentials for a supported registry. #### Path Parameters Registry identifier: `ghcr`, `gcr`, or `dockerhub`. #### Request Body For `ghcr`: ```json theme={"dark"} { "username": "myuser", "token": "ghp_xxxxxxxxxxxx" } ``` For `gcr`: ```json theme={"dark"} { "key": "{...service account json...}" } ``` For `dockerhub`: ```json theme={"dark"} { "username": "myuser", "token": "dckr_pat_xxxxxxxxxxxx" } ``` Deletes credentials for a supported registry. *** ### Error Responses Example error response: ```json theme={"dark"} { "error": "invalid admin API key", "code": "UNAUTHORIZED" } ``` Common error codes: | Code | HTTP Status | Description | | ------------------ | ----------- | ------------------------------------------------------------------------------------- | | `UNAUTHORIZED` | 401 | Invalid or expired admin API key | | `FORBIDDEN` | 403 | Your admin key no longer has access to this organization or action | | `BAD_REQUEST` | 400 | Invalid request parameters | | `NOT_FOUND` | 404 | Resource not found, or the referenced API key is not available through this admin key | | `PAYMENT_REQUIRED` | 402 | Active subscription required | | `INTERNAL_ERROR` | 500 | Server error | # Getting a Tinfoil admin key Source: https://docs.tinfoil.sh/admin/get-admin-key Step-by-step guide for generating Tinfoil Admin API key for administrative operations ### Step 1: Access Your Dashboard Log into your Tinfoil account and click the **"Dashboard"** button in the top right corner. Tinfoil dashboard ### Step 2: Navigate to Admin Tab Click on the **"Admin"** tab in the dashboard navigation to access the admin key management page. Admin tab selection ### Step 3: Name Your Admin Key Enter a name in the **"Key name"** field under **"Create new admin API key"**. Naming your admin key ### Step 4: Create Admin Key Click the **"Create Admin Key"** button to generate your new admin API key. Admin keys provide programmatic access to organization settings, including managing API keys, setting rate limits, and modifying billing configuration. Do not share admin keys or expose them in browsers, client-side code, or public repositories. Admin keys are automatically revoked when the creator is removed from the organization or demoted from admin. Create admin key button ### Step 5: Copy Your Admin Key Your new admin key will appear in the list. Click the **copy** button to copy it, or the **eye** button to reveal the full key. Copy or view your admin key ### Step 6: Test in Postman (Optional) You can test your admin key in Postman or any API client. Enter the API endpoint URL, for example: ``` GET https://api.tinfoil.sh/api/billing/usage?time=24h ``` Postman URL setup ### Step 7: Set Bearer Token In the **Auth** tab, set the type to **Bearer Token** and paste your admin key in the **Token** field. Setting Bearer token ### Step 8: Get Results Click **Send** and verify that you get a successful response. The response body contains your usage data. API response results ## Admin Key Capabilities Admin keys provide programmatic access to manage your own API keys, query usage metrics, and access billing data. When listing keys, you see all organization keys but only your own keys are returned in full -- keys created by other members are masked. For complete documentation of available endpoints, see the [Admin API Documentation](/admin/admin-api). # Building your own image Source: https://docs.tinfoil.sh/containers/building-images Build and publish a Docker image for your own source code, then reference it from tinfoil-config.yml. The [quickstart](/containers/quickstart) deploys a **pre-built Docker image**. If you have another pre-built image, reference it directly in the `image` field of your [`tinfoil-config.yml`](/containers/configuration). If instead you have source code you want to deploy, you'll need to build an image for it first. How you do this depends on the visibility of the repository: * **Public source code** → use [`tinfoil-public-containers-template`](https://github.com/tinfoilsh/tinfoil-public-containers-template). When deploying public code you can do it all in one repo. The `public-containers` template contains one workflow that builds the image, substitutes the real digest into the config at the tagged release commit, and publishes the release. It consolidates the `tinfoil-containers-hello-world` repo and the `tinfoil-containers-template` repo. * **Private source code** → keep your code in its own private repo and use [`tinfoil-containers-hello-world`](https://github.com/tinfoilsh/tinfoil-containers-hello-world) as the build-and-publish pattern (Dockerfile + workflow that pushes to GHCR). Then reference the published image from a `tinfoil-containers-template` fork like the quickstart. If the **published image** is private (not just the source), Tinfoil needs registry credentials to pull it at deploy time. See [Private images](/containers/private-images). Public images work without any configuration. ## Image digests in the release workflow The `image` field must pin a SHA256 digest because this is what the attestation commits to and what clients verify. How the digest gets into your config depends on your setup: * **Pre-built image** (the [quickstart](/containers/quickstart) template, or any external image you pull): put the real digest in your config directly. * **Build your own image** (the [public-containers template](https://github.com/tinfoilsh/tinfoil-public-containers-template)): use a placeholder digest on `main`: ```yaml tinfoil-config.yml theme={"dark"} image: "ghcr.io/your-org/your-image@sha256:00000.." # placeholder ``` When you trigger a release, `tinfoil-release.yml` builds the image, substitutes the real digest into the config on the **tagged release commit** (not on `main`), then runs the measurement and publish workflow against that commit. `main` keeps the placeholder. For example, here's the release for [`v0.0.118`](https://github.com/tinfoilsh/confidential-model-router/releases/tag/v0.0.118) of [`confidential-model-router`](https://github.com/tinfoilsh/confidential-model-router): GitHub release page showing the published measurements and attestation You can see that it contains a detached commit ([`d3aad2e`](https://github.com/tinfoilsh/confidential-model-router/commit/d3aad2ec4aa12ad75d61d194d0f880e161633755)) that doesn't belong to any branch. If we click on it we can see the placeholder being replaced with the real digest: GitHub commit page showing the release workflow replacing the placeholder digest with the real one The measurement and publish workflow runs against that commit, producing a GitHub release with the enclave measurements. # Managing containers from the CLI Source: https://docs.tinfoil.sh/containers/cli Use the Tinfoil CLI to manage the full container lifecycle without opening the dashboard. The [Tinfoil CLI](https://github.com/tinfoilsh/tinfoil-cli) lets you manage deployments and individual containers, along with secrets, SSH keys, registry credentials, and custom domains. This page walks through the full container lifecycle from the terminal. ## Prerequisites Install the CLI: ```bash theme={"dark"} curl -fsSL https://github.com/tinfoilsh/tinfoil-cli/raw/main/install.sh | sh ``` Or download a binary from the [releases page](https://github.com/tinfoilsh/tinfoil-cli/releases). A Docker image is published at `ghcr.io/tinfoilsh/tinfoil-cli`. You also need a Tinfoil organization with Containers enabled, the same prerequisite as the [quickstart](/containers/quickstart). ## Authenticating Container management endpoints accept an **admin API key** scoped to a single organization. Create one from the dashboard's **Admin** tab by following [Getting a Tinfoil admin key](/admin/get-admin-key), then run: ```bash theme={"dark"} tinfoil login # prompts for the key (no shell history) tinfoil login --api-key admin_xxx # non-interactive, e.g. for CI tinfoil whoami # confirm the saved credential tinfoil logout # delete the saved credential ``` Credentials are written to `~/.tinfoil/config.json` with mode `0600`. Two environment variables override the saved values for one-off invocations: | Variable | Purpose | | -------------------------- | ------------------------------------------------------- | | `TINFOIL_API_KEY` | Admin key (`admin_...`) | | `TINFOIL_CONTROLPLANE_URL` | Controlplane URL (defaults to `https://api.tinfoil.sh`) | Admin keys carry the organization ID, so the CLI never asks for an org. To act on a different org, log out and log in with that org's admin key. ## Inspecting your organization ```bash theme={"dark"} tinfoil deployment list # repositories and aggregate instance counts tinfoil deployment get myorg/my-app-config tinfoil container list # all containers in the org tinfoil container get my-app # full detail for one container tinfoil container hosts # which container hosts your org may target ``` Pass `-o json` to get machine-readable output suitable for scripting: ```bash theme={"dark"} tinfoil container list -o json | jq '.[] | select(.status == "failed")' ``` Deployments can be referenced by **repository name** (`owner/repo`) or **deployment UUID**. Containers can be referenced by **name** or **container UUID**. If you have a debug-mode and a production-mode container with the same name, pass `--debug-mode` to disambiguate. ## Publishing a config release Before a tag can be deployed, the config repo must publish a measured release. The CLI uses the GitHub App connected to your Tinfoil organization, so you do not need a GitHub token or the `gh` CLI. Fetch the current config, then open a pull request with your local version: ```bash theme={"dark"} tinfoil repo config get myorg/my-app-config --raw > ./tinfoil-config.yml tinfoil repo config pr myorg/my-app-config --file ./tinfoil-config.yml ``` Use `--file -` to read the config from stdin, and `--body` to add a pull request description. Repository commands also support `-o json`. Check whether the pull request has been merged: ```bash theme={"dark"} tinfoil repo pr status myorg/my-app-config 42 ``` After it is merged, inspect the suggested version and trigger the **Tinfoil Release** workflow: ```bash theme={"dark"} tinfoil repo build info myorg/my-app-config tinfoil repo build run myorg/my-app-config --version v1.0.0 ``` The command prints the GitHub Actions URL. Follow it and wait for both release workflow phases to finish before deploying the tag. See [Updating & lifecycle](/containers/updates#starting-an-update) for the dashboard and direct GitHub alternatives. ## Deploying a container Once you have a measured release in your config repo (see [quickstart](/containers/quickstart) for the GitHub setup), deploy it: ```bash theme={"dark"} tinfoil container create my-app \ --repo myorg/my-app-config \ --tag v1.0.0 \ --variable LOG_LEVEL=info \ --variable PORT=8080 \ --secret DATABASE_URL \ --custom-domain api.example.com ``` | Flag | What it does | | ---------------------- | --------------------------------------------------------------- | | `--repo` | GitHub `owner/repo` containing `tinfoil-config.yml` (required) | | `--tag` | Release tag to deploy (required) | | `--variable KEY=VALUE` | Environment variable; repeatable | | `--secret NAME` | Organization or repository secret to mount; repeatable | | `--ssh-key NAME` | Org SSH key (debug containers only); repeatable | | `--debug` | Deploy in [debug mode](/containers/debug-mode) | | `--staging` | Deploy in [staging mode](/containers/staging-mode) | | `--custom-domain` | Use a [verified custom domain](/containers/custom-domains) | | `--host` | Pin to a specific host (see `tinfoil container hosts`) | | `--replace ID` | Atomically replace an existing container (frees its GPUs first) | The command returns once the deployment is queued. Poll for readiness with `tinfoil container get my-app`. ## Managing deployments One deployment contains all container instances in your organization that use the same GitHub repository. Inspect its aggregate status and instance counts with: ```bash theme={"dark"} tinfoil deployment list tinfoil deployment get myorg/my-app-config ``` Set whether new instances and deployment-wide updates use [staging mode](/containers/staging-mode) by default: ```bash theme={"dark"} tinfoil deployment settings myorg/my-app-config --default-staging true ``` Update all eligible instances to one release: ```bash theme={"dark"} tinfoil deployment update myorg/my-app-config --tag v1.0.1 ``` To update only selected instances, repeat `--instance` with each container UUID: ```bash theme={"dark"} tinfoil deployment update myorg/my-app-config \ --tag v1.0.1 \ --instance 550e8400-e29b-41d4-a716-446655440000 \ --instance 6ba7b810-9dad-11d1-80b4-00c04fd430c8 ``` Pass `--staging true` or `--staging false` to override the deployment's default for one update. Only running or failed instances are eligible. An instance is skipped if it is in any other state or already has an update in progress. Results are reported per instance, and the command exits non-zero if any instance is skipped or fails. ## Lifecycle ```bash theme={"dark"} # Stop a running container (DB row preserved, traffic stops, billing pauses) tinfoil container stop my-app # Start a stopped container (optionally with config overrides) tinfoil container start my-app tinfoil container start my-app --tag v1.0.1 # Redeploy a running container with new config (blue-green) tinfoil container relaunch my-app --tag v1.0.1 tinfoil container relaunch my-app --variable LOG_LEVEL=debug tinfoil container relaunch my-app --secret NEW_KEY --secret OTHER_KEY # Delete (irreversible) tinfoil container delete my-app ``` `start` and `relaunch` support `--tag`, `--variable`, `--secret`, `--ssh-key`, `--debug`, `--staging`, `--custom-domain`, and `--host`. Any override you pass replaces the stored value entirely. For example, `--secret A --secret B` sets the secrets list to `[A, B]`, not `[A, B, ...existing]`. `tinfoil container delete` permanently removes the container, its secret bindings, and its DNS records. There is no undo. ## Managing in-progress updates Updates triggered by `relaunch` go through a blue-green window. While the new version is booting, you can inspect or cancel it: ```bash theme={"dark"} tinfoil container update status my-app # show update status (pending/started/ready/failed) tinfoil container update accept my-app # promote a "ready" candidate tinfoil container update cancel my-app # discard the candidate, keep current ``` [Staging](/containers/staging-mode) containers always wait for `update accept`; production containers promote automatically once the candidate is ready. ## Secrets Manage organization secrets with `tinfoil secret`: ```bash theme={"dark"} tinfoil secret list # Create tinfoil secret create DATABASE_URL --value-file ./db.url echo -n "$STRIPE_KEY" | tinfoil secret create STRIPE_SECRET_KEY --value-file - # Update value (containers using it are marked stale; redeploy to pick up the new value) tinfoil secret set DATABASE_URL --value-file ./db.url # Inspect (the value itself is never returned) tinfoil secret get DATABASE_URL # Delete (fails if any container references it) tinfoil secret delete DATABASE_URL ``` Manage secrets available only to one repository with `tinfoil repo secret`: ```bash theme={"dark"} tinfoil repo secret list myorg/my-app-config # Create tinfoil repo secret create myorg/my-app-config DATABASE_URL --value-file ./db.url # Update, inspect, or delete tinfoil repo secret set myorg/my-app-config DATABASE_URL --value-file ./db.url tinfoil repo secret get myorg/my-app-config DATABASE_URL tinfoil repo secret delete myorg/my-app-config DATABASE_URL ``` The same secret name can have different values in different repositories. An organization secret and a repository secret cannot share a name within the same organization. `--value-file` accepts `-` for stdin, which is the recommended way to set secrets — it avoids leaking the value via shell history or process listings. See [secrets and env vars](/containers/secrets-and-env-vars) for the underlying model. ## SSH keys [Debug-mode](/containers/debug-mode) containers authorize SSH access using public keys registered at the org level: Choose one of the supported input forms: ```bash theme={"dark"} tinfoil ssh-key list # Read from a file tinfoil ssh-key create laptop --public-key-file ~/.ssh/id_ed25519.pub # Or read from stdin cat ~/.ssh/id_ed25519.pub | tinfoil ssh-key create laptop --public-key-file - # Or pass the public key inline tinfoil ssh-key create laptop --public-key "ssh-ed25519 AAAA..." tinfoil ssh-key delete laptop ``` Reference keys at deploy time with `--ssh-key NAME` on `tinfoil container create` (or `relaunch` / `start`). ## Registry credentials For [private images](/containers/private-images), set credentials per registry. Tinfoil supports `ghcr`, `gcr`, and `dockerhub`: ```bash theme={"dark"} tinfoil registry list # GitHub Container Registry: classic PAT with read:packages tinfoil registry set ghcr --username my-gh-user --token ghp_xxx # Google Artifact Registry / Container Registry: service-account JSON tinfoil registry set gcr --key-file ./gcp-sa.json # Docker Hub: PAT with read permission tinfoil registry set dockerhub --username my-docker-user --token dckr_xxx # Remove credentials for a registry tinfoil registry delete ghcr ``` ## Custom domains ```bash theme={"dark"} tinfoil domain list # Register a domain — output includes the TXT/CNAME records to configure tinfoil domain add api.example.com # Re-check DNS after configuring records tinfoil domain verify api.example.com # Remove (fails if any container uses it) tinfoil domain delete api.example.com ``` Once a domain is verified, deploy a container against it with `tinfoil container create ... --custom-domain api.example.com`. See [custom domains](/containers/custom-domains) for the DNS record details and troubleshooting. ## Connecting to a deployed container `tinfoil container connect ` resolves a container's enclave domain and source repo, then runs a verified local proxy — equivalent to `tinfoil-proxy -e -r ` (the [standalone proxy CLI](/local-proxy/cli)) but without copy-pasting either value: ```bash theme={"dark"} tinfoil container connect my-app -p 3301 ``` The proxy binds to `127.0.0.1` by default. Pass `--bind
` to use another interface: ```bash theme={"dark"} tinfoil container connect my-app --port 3301 --bind 0.0.0.0 ``` Only bind beyond localhost on a trusted network because other hosts may then send requests through the proxy. In another terminal, send requests to `http://localhost:3301` exactly as you would to your container's domain. The proxy verifies attestation on startup and pins the TLS certificate for subsequent requests. See [connecting](/containers/connecting) for the full client story (SDKs, raw `tinfoil http`). Debug-mode containers do not pass attestation, so `connect` (and any other `SecureClient` flow) will refuse to use them. SSH into debug containers directly via `ssh -p root@console.tinfoil.sh` — the dashboard shows the exact command on the container's card. ## Resource metrics ```bash theme={"dark"} tinfoil container metrics my-app --time 24h ``` Returns CPU, GPU, and memory utilization buckets as JSON. Useful for piping into `jq` or a chart tool. ## Scripting tips * `-o json` is supported on every list/get command. * Exit codes are non-zero on failure, with the controlplane error message printed to stderr. * All commands respect `TINFOIL_API_KEY` / `TINFOIL_CONTROLPLANE_URL`, so the CLI is safe to use from CI — store the admin key as a secret and pass it through the environment instead of running `tinfoil login`. * `--verbose` and `--trace` increase log verbosity for debugging connectivity issues. # Example configs Source: https://docs.tinfoil.sh/containers/config-examples Complete tinfoil-config.yml examples, from a minimal API server to a GPU inference server and multi-container setups. ## Minimal config ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 2 memory: 8192 containers: - name: "api" image: "ghcr.io/myorg/api-server:v1.0.0@sha256:abc123..." command: ["--port", "8000"] shim: upstream-port: 8000 paths: - /health - /api/* ``` ## With environment variables and secrets ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 4 memory: 16384 containers: - name: "api" image: "ghcr.io/myorg/api-server:v2.1.0@sha256:def456..." env: - PORT: "8080" - LOG_LEVEL: "info" - NODE_ENV: "production" secrets: - DATABASE_URL - STRIPE_SECRET_KEY command: ["--port", "8080"] shim: upstream-port: 8080 paths: - /health - /api/* - /webhooks/* ``` ## With outbound network access ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 4 memory: 16384 networks: backend: egress: allowlist allow: - api.stripe.com containers: - name: "api" image: "ghcr.io/myorg/api-server:v2.1.0@sha256:def456..." networks: [backend] secrets: - STRIPE_SECRET_KEY command: ["--port", "8080"] shim: upstream-port: 8080 paths: - /api/* ``` ## GPU inference server (vLLM) ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 16 memory: 65536 gpus: 1 models: - name: "gemma-4-31b-it" repo: "google/gemma-4-31B-it@419b2efe421994fdfd3394e621983d4cc511cd4f" mpk: "0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab_62578683904_59fe9787-ed93-577a-9fd9-a7804c932a11" containers: - name: "inference" image: "vllm/vllm-openai:v0.14.1@sha256:6fc52be4609fc19b09c163be2556976447cc844b8d0d817f19bc9e1f44b48d5a" runtime: nvidia gpus: all ipc: host command: [ "--model", "/tinfoil/mpk/mpk-0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab", "--served-model-name", "gemma-4-31b-it", "--port", "8001" ] shim: upstream-port: 8001 paths: - /v1/chat/completions - /v1/completions - /health - /metrics ``` ## Multi-container setup ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 8 memory: 32768 containers: - name: "api" image: "ghcr.io/myorg/api-server:v1.0.0@sha256:abc123..." env: - PORT: "8080" secrets: - DATABASE_URL command: ["--port", "8080"] - name: "worker" image: "ghcr.io/myorg/worker:v1.0.0@sha256:def456..." env: - QUEUE_URL: "redis://localhost:6379" secrets: - API_SECRET shim: upstream-port: 8080 paths: - /health - /api/* ``` # Networking & routing Source: https://docs.tinfoil.sh/containers/config-networking Control container egress with named networks and expose ports and paths through the shim. By default a container has **no network access** — it cannot reach the internet or other containers. The exception is the container the [shim routes to](#routing): the enclave wires the shim to it automatically, with no configuration on your part. You only need a `networks` block when a container has to make **outbound** connections (calling an external API) or talk to **another container** in the same enclave. The `networks` block requires `cvm-version` 0.10.0 or newer. ## Defining networks `networks` is a top-level map of named networks. Each has an `egress` policy that controls what containers attached to it can reach: | `egress` | Meaning | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `closed` (default) | No outbound access. Containers on the same `closed` network can talk to each other, but not to the internet. Use this for internal container-to-container communication. | | `allowlist` | Outbound allowed only to the hostnames in `allow`. Everything else is blocked. | | `open` | Outbound allowed to any public address. Private/internal ranges (RFC 1918, link-local, loopback) are always blocked. | ```yaml theme={"dark"} networks: backend: egress: allowlist allow: - api.stripe.com - api.openai.com public: egress: open internal: {} # empty body = egress: closed ``` | Field | Type | Description | | ------------------------ | ------ | ------------------------------------------------------------------------------- | | `networks..egress` | string | One of `closed`, `allowlist`, `open`. Defaults to `closed`. | | `networks..allow` | list | Hostnames allowed for `egress: allowlist`. Only valid when `egress: allowlist`. | Rules for `allow` entries: * Must be **hostnames** (e.g. `api.example.com`), not IP addresses; wildcards are not supported. * Allow every hostname the request may contact, including redirect targets and separate CDN or API hostnames. * Hostnames are re-resolved periodically (about every 60 seconds), so allowlisting works with rotating DNS. Only IPv4 addresses are allowlisted today. Network names must be lowercase alphanumeric with hyphens, at most 15 characters. The name `shim-net` is reserved for the enclave's internal shim-to-container channel and cannot be used (see [Routing](#routing)). ## Attaching containers to networks List the networks a container joins under its `networks` field: ```yaml theme={"dark"} networks: backend: egress: allowlist allow: [api.stripe.com] internal: {} containers: - name: api image: ghcr.io/myorg/api@sha256:... networks: [backend, internal] # egress via backend; talk to peers on internal - name: worker image: ghcr.io/myorg/worker@sha256:... networks: [internal] # no egress; can reach `api` over internal ``` * A container may attach to any number of networks, but **at most one** may have `egress` other than `closed`. * Containers on a shared network reach each other by container name (e.g. `http://api:8000`). * A container with no `networks` (and that isn't the shim's target) has no connectivity. ## Routing The `shim` section controls which ports and paths your container exposes. | Field | Type | Required | Description | | ------------------------- | ------- | -------- | --------------------------------------------------------------------------------------------- | | `shim.upstream-port` | integer | Yes | Port your container listens on | | `shim.paths` | list | Yes | URL paths to expose (supports `*` wildcards) | | `shim.upstream-container` | string | No | Which container receives traffic. Defaults to the first container in `containers`. | | `shim.origins` | list | No | Allowed CORS origins for browser clients. When set, requests from other origins are rejected. | Only listed paths are reachable from outside the enclave. Any request to an unlisted path is rejected with a 404 error code. The enclave reaches the target container over a private internal channel — you don't declare a network for it. **The `shim-net` channel.** The shim reaches your upstream container over an automatic, private Docker bridge named `shim-net` (a fixed `172.31.255.0/30` subnet). It carries only shim → container traffic and is always closed to the internet. You don't create it, and the name `shim-net` is reserved — you can't declare a network called `shim-net` or attach a container to it yourself. # Runtime & security Source: https://docs.tinfoil.sh/containers/config-runtime Security defaults applied to every container, plus healthchecks and restart policies that govern the container lifecycle. ## Security defaults The enclave applies hardened defaults to every container, so the attested config honestly reflects what runs. These differ from stock Docker, so a container that relied on Docker's permissive defaults may need adjustment. | Behavior | Default | How to change it | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | | **All capabilities dropped** | Containers start with zero Linux capabilities. | Add only what you need with `cap_add`. There is no `cap_drop`. | | **No new privileges** | `no-new-privileges:true` is always set, blocking setuid-based privilege escalation. | Cannot be disabled. | | **Read-only root filesystem** | `read_only: true`. | Set `read_only: false`, or mount writable scratch space with `tmpfs` / `volumes`. | | **Process limit** | `pids_limit: 65536`, a backstop against fork bombs. | Set an explicit `pids_limit` (`-1` for unlimited). | | **Attested config mount** | `/tinfoil` is mounted read-only, exposing the verified config and attestation so clients can audit what's running. | Not configurable. | The most common migration surprises are the **read-only root filesystem** and **dropped capabilities**. If your app writes to its root filesystem, set `read_only: false` or add a `tmpfs` mount for its scratch directories. If it needs a capability (for example `SYS_ADMIN` for sandboxing, or `NET_RAW` for `ping`), list it under `cap_add`. The read-only `/tinfoil` mount contains: | Path | Contents | | -------------------------------- | ----------------------------------------------------------------- | | `/tinfoil/config.yml` | The verified `tinfoil-config.yml` this enclave booted with | | `/tinfoil/attestation.json` | The enclave's attestation document | | `/tinfoil/container-status.json` | Runtime status of the launched containers | | `/tinfoil/mpk/mpk-` | Mounted [model weights](/containers/models), when `models` is set | ## Healthchecks Add a `healthcheck` block to a container to have the enclave verify it's actually ready before the deployment transitions to **Running**. Without one, the container is considered ready the moment Docker starts it — fine for fast-starting apps, but a problem for workloads with long startup (model loading, cache warm-up) where the process is up but isn't serving yet. ```yaml theme={"dark"} containers: - name: "inference" image: "vllm/vllm-openai:v0.14.1@sha256:..." command: ["--port", "8001"] healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8001/health"] interval: 30s timeout: 5s start_period: 30m ``` | Field | Type | Description | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `test` | list | Command to run inside the container. Prefix with `CMD` to exec directly, or `CMD-SHELL` to run through a shell. Exit code 0 = pass, non-zero = fail. | | `interval` | duration | How often to run the check (e.g. `30s`). Docker default: 30s. | | `timeout` | duration | How long a single check can take before it's counted as a failure (e.g. `5s`). Docker default: 30s. | | `retries` | integer | Consecutive failures after `start_period` before the container is marked unhealthy. Docker default: 3. | | `start_period` | duration | Grace period after container start during which failures don't count toward `retries` (e.g. `30m`). Docker default: 0s. | **How it's used during boot.** The enclave's boot process polls Docker's health state every 5 seconds once the container starts and waits until Docker reports the container `Healthy` before finishing boot. If Docker reports `Unhealthy` (i.e. `retries` consecutive failures after `start_period` has elapsed), the deployment fails and the last healthcheck output is surfaced as the error detail. The test command runs *inside* the container, so whatever you invoke (`curl`, `wget`, a language runtime) has to be available in the image. For an inference server like vLLM that already exposes `/health`, a `curl -sf http://localhost:/health` check is idiomatic. `start_period` is usually the most important field. If your container takes 15 minutes to load model weights, set `start_period` to at least 20 minutes — otherwise failing checks during the load phase will burn through `retries` and the deployment will fail before your app ever gets a chance to serve. **See also.** The schema is taken from Docker Compose — see the [Compose healthcheck reference](https://docs.docker.com/reference/compose-file/services/#healthcheck) for the full semantics (exit codes, `CMD-SHELL` vs `CMD`, disabling an inherited check with `disable: true`). ## Restart policy By default, a container whose process exits stays exited. Set `restart` to have Docker automatically restart the process if it crashes — useful for long-running servers that should stay up across transient failures. ```yaml theme={"dark"} containers: - name: "inference" image: "vllm/vllm-openai:v0.14.1@sha256:..." restart: always ``` | Value | Behavior | | ---------------- | ------------------------------------------------------------------------- | | `no` | Don't restart. This is the default when `restart` is omitted. | | `always` | Restart the container regardless of exit status. | | `on-failure` | Restart only if the process exits with a non-zero status. | | `unless-stopped` | Like `always`, but don't restart if the container was stopped explicitly. | **Interaction with healthchecks.** The restart policy fires when the container **process exits** — it has no effect when Docker marks the container `Unhealthy` (the process keeps running; only its health state changes). During boot, the enclave fails the deployment on `Unhealthy` regardless of `restart`. Once the container has been declared `Healthy`, `restart` governs what happens if the process later dies. **See also.** Taken from Docker Compose — see the [Compose restart reference](https://docs.docker.com/reference/compose-file/services/#restart). # Configuration reference Source: https://docs.tinfoil.sh/containers/configuration The tinfoil-config.yml file declares everything that runs inside your enclave: containers, resources, models, and routing. Tinfoil measures it and publishes the measurement so clients can verify exactly what is deployed. ## Overview Every Tinfoil Container requires a `tinfoil-config.yml` file in the root of your GitHub repository. This file defines the enclave runtime, resource allocation, container configuration, and request routing. For a pre-filled `tinfoil-config.yml`, follow along with the [quickstart guide](/containers/quickstart). ## File location The file must be named `tinfoil-config.yml` and placed at the root of your repository. Tinfoil fetches this file from GitHub when you deploy or validate a container. The repository must be public. Tinfoil measures your config at each tag and publishes the measurement to the Sigstore transparency log, so clients can verify against it. The Docker image the config references can still be private — see [Private images](/containers/private-images). ## Top-level fields | Field | Type | Required | Description | | ------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | | `cvm-version` | string | Yes | Confidential VM version. Use the current value from the [template](https://github.com/tinfoilsh/tinfoil-containers-template). | | `cpus` | integer | Yes | Number of CPU cores | | `memory` | integer | Yes | RAM in megabytes | | `gpus` | integer | No | Number of GPUs to attach to the enclave (`1` or `8`). Omit for CPU-only workloads. | | `models` | list | No | Verified model-weight artifacts to mount in the enclave. See [Model weights](/containers/models). | | `networks` | object | No | Named container networks and their egress policies. See [Networking & routing](/containers/config-networking). | | `containers` | list | Yes | One or more container definitions | | `shim` | object | Yes | Port and path routing configuration. See [Routing](/containers/config-networking#routing). | ### Valid resource values | Resource | Valid values | | --------------- | ---------------------------------------------------------------------------------------- | | **CPUs** | 2, 4, 8, 16, 32 | | **Memory (MB)** | 8192, 16384, 32768, 65536, 131072, 262144, 524288 | | **GPUs** | 1 or 8 at the top level; each GPU container also needs `runtime: nvidia` and `gpus: all` | Memory values correspond to 8 GB, 16 GB, 32 GB, 64 GB, 128 GB, 256 GB, and 512 GB respectively. ## Container spec Each entry in the `containers` list defines a container to run inside the enclave. | Field | Type | Required | Description | | -------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | Yes | Container identifier | | `image` | string | Yes | Docker image with SHA256 digest (e.g. `image:tag@sha256:...`) | | `command` | list | No | Command arguments passed to the container | | `entrypoint` | list | No | Override the container's entrypoint | | `working_dir` | string | No | Override the working directory | | `user` | string | No | Run as a specific `uid:gid`. Defaults to the image's user. | | `env` | list | No | Environment variables (see below) | | `secrets` | list | No | Secret names — values are managed in the dashboard or with the CLI | | `runtime` | string | No | Container runtime — set to `nvidia` for GPU workloads | | `gpus` | string/int | No | GPU allocation for this container — typically `all`. See [GPU configuration](#gpu-configuration). | | `ipc` | string | No | IPC mode — set to `host` | | `pid` | string | No | PID namespace — `host` to share the host PID namespace (rare, security-sensitive) | | `networks` | list | No | Networks this container attaches to. See [Networking & routing](/containers/config-networking). | | `volumes` | list | No | Bind mounts (e.g. `/mnt/ramdisk/data:/data`) | | `devices` | list | No | Host device passthrough (e.g. `/dev/dri:/dev/dri`) | | `tmpfs` | map | No | Writable in-memory mounts, e.g. `/tmp: "size=100m"`. Useful with a read-only root filesystem. | | `read_only` | bool | No | Read-only root filesystem. **Defaults to `true`** — see [Security defaults](/containers/config-runtime#security-defaults). | | `cap_add` | list | No | Linux capabilities to grant, without the `CAP_` prefix. All are dropped by default — see [Security defaults](/containers/config-runtime#security-defaults). | | `security_opt` | list | No | Extra Docker security options. `no-new-privileges:true` is always applied. | | `shm_size` | string | No | Shared-memory size, e.g. `2g`. (defaults to 50% of the container's `memory`) | | `memory` | string | No | Memory limit for this container, e.g. `512m` | | `cpus` | float | No | CPU limit for this container, e.g. `2.0` | | `pids_limit` | integer | No | Max processes/threads. **Defaults to `65536`**; `-1` for unlimited. | | `healthcheck` | object | No | Docker-style healthcheck run inside the container. See [Healthchecks](/containers/config-runtime#healthchecks). | | `restart` | string | No | Restart policy if the container process exits. One of `no` (default), `always`, `on-failure`, `unless-stopped`. See [Restart policy](/containers/config-runtime#restart-policy). | | `stop_signal` | string | No | Signal used to stop the container (e.g. `SIGTERM`) | | `stop_timeout` | integer | No | Seconds to wait before force-killing on stop | The `image` field must include a SHA256 digest (e.g. `image:tag@sha256:...`), not only a mutable tag such as `:latest`. The digest pins the exact image binary and ensures it can be verified in the transparency log. To get the digest, run `docker pull && docker inspect --format='{{index .RepoDigests 0}}' `. If you build your own image as part of the release workflow, use a placeholder digest (`sha256:0000...`) on `main` — the release workflow substitutes the real digest into the tagged commit. See [Building images](/containers/building-images#image-digests-in-the-release-workflow). ### GPU configuration GPU workloads use a two-step allocation: 1. **Attach GPUs to the enclave** with the top-level `gpus` field, set to either `1` or `8`. NVIDIA confidential computing restricts enclaves to those two sizes. 2. **Expose GPUs to a container** with `runtime: nvidia` and a container-level `gpus` value. Use `gpus: all` to give the container every GPU attached to the enclave — this is the right choice for both single-GPU and most multi-GPU setups. Individual indices (e.g. `gpus: "0,1"`) are only needed when running multiple containers in an 8-GPU enclave and splitting GPUs between them. GPU containers typically also set `ipc: host` so the NVIDIA runtime can share memory with host processes. ### Environment variable formats Environment variables support two formats: ```yaml theme={"dark"} env: - PORT: "8080" # Hardcoded value (YAML map syntax) - LOG_LEVEL: "info" secrets: - DATABASE_URL # Looked up from repository or organization secrets - API_KEY ``` ## Model weights GPU inference containers often need large model weights in addition to the Docker image that runs the server. Use the top-level `models` list to mount verified model-weight artifacts prepared in the dashboard's **Models** tab. See [Model weights](/containers/models) for the field reference, the preparation workflow, and a complete vLLM example. ## Validation You can validate your config before deploying. In the dashboard, click **New Container** and select your repo and tag. Tinfoil fetches your `tinfoil-config.yml` and checks that: * CPU, memory, and GPU values are valid * Resource usage is within your org's limits * Model weight references are valid when `models` is set * Referenced secrets exist for the repository or organization * The container image is accessible and includes a SHA256 digest If validation fails, the dashboard shows specific error messages explaining what needs to be fixed. ## Container naming constraints | Constraint | Rule | | :----------------- | :--------------------------------------------------------------------- | | Allowed characters | Lowercase letters, numbers, hyphens | | Start/end | Cannot start or end with a hyphen | | Max length | 64 characters | | Uniqueness | Unique per organization (production and debug namespaces are separate) | ### Examples | Name | Valid? | | :------------------ | :------------------------------ | | `my-api` | Yes | | `data-processor-v2` | Yes | | `MyAPI` | No — uppercase not allowed | | `my_api` | No — underscores not allowed | | `my api` | No — spaces not allowed | | `-my-api` | No — cannot start with a hyphen | | `my-api-` | No — cannot end with a hyphen | ## Template The [tinfoil-containers-template](https://github.com/tinfoilsh/tinfoil-containers-template) repo contains a ready-to-use `tinfoil-config.yml` with the latest `cvm-version` value. Create a new repo from this template to get started quickly. # Making requests Source: https://docs.tinfoil.sh/containers/connecting Use Tinfoil's SecureClient SDKs or the CLI to make attested requests to your container. Tinfoil's `SecureClient` verifies the enclave before sending any data — same attestation flow as Tinfoil's inference API. Every request is authenticated against the container's attestation report and TLS certificate. You need two values to connect: * **``** — your container's hostname (e.g. `myapp.myorg.containers.tinfoil.dev`) * **``** — the GitHub repo linked to your container, in `org/repo` format The Rust `SecureClient` constructor retains an API-key argument for inference API compatibility, but raw requests through `http_client()` do not use it. Pass an empty string for custom containers, then add application authentication to individual requests if your service requires it. ## GET requests ```python Python theme={"dark"} from tinfoil import SecureClient client = SecureClient( enclave="", repo="", ) # Attestation is verified automatically response = client.get("https:///") print(response.status_code) ``` ```go Go theme={"dark"} import tinfoil "github.com/tinfoilsh/tinfoil-go" client, err := tinfoil.NewSecureClient( "", "", ) if err != nil { panic(err) } // Attestation is verified automatically resp, err := client.Get("https:///") ``` ```typescript JavaScript theme={"dark"} import { SecureClient } from "tinfoil"; const client = new SecureClient({ enclaveURL: "https://", configRepo: "", }); await client.ready(); // Attestation is verified automatically const response = await client.fetch( "https:///" ); ``` ```rust Rust theme={"dark"} use tinfoil::SecureClient; #[tokio::main] async fn main() -> Result<(), Box> { let mut client = SecureClient::new( "", "", "", ); client.verify().await?; // http_client() returns a verified, attestation-pinned reqwest::Client let response = client.http_client()? .get(format!("https://{}/", "")) .send() .await?; println!("{}", response.status()); Ok(()) } ``` ## POST requests ```python Python theme={"dark"} import json from tinfoil import SecureClient client = SecureClient( enclave="", repo="", ) response = client.post( "https:///", headers={"Content-Type": "application/json"}, body=json.dumps({"key": "value"}).encode(), ) print(response.status_code) print(response.body.decode()) ``` ```go Go theme={"dark"} import ( "strings" tinfoil "github.com/tinfoilsh/tinfoil-go" ) client, err := tinfoil.NewSecureClient( "", "", ) if err != nil { panic(err) } resp, err := client.Post( "https:///", "application/json", strings.NewReader(`{"key": "value"}`), ) ``` ```typescript JavaScript theme={"dark"} import { SecureClient } from "tinfoil"; const client = new SecureClient({ enclaveURL: "https://", configRepo: "", }); await client.ready(); const response = await client.fetch( "https:///", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: "value" }), } ); const data = await response.json(); ``` ```rust Rust theme={"dark"} use serde_json::json; use tinfoil::SecureClient; #[tokio::main] async fn main() -> Result<(), Box> { let mut client = SecureClient::new( "", "", "", ); client.verify().await?; let response = client.http_client()? .post(format!("https://{}/", "")) .json(&json!({ "key": "value" })) .send() .await? .error_for_status()? .json::() .await?; println!("{}", response); Ok(()) } ``` The client verifies the enclave is running the expected code from the pinned repo, then pins the TLS certificate for all subsequent requests. If anything doesn't match, the connection fails. ## Using the local proxy You can also connect to your container with the standalone [Tinfoil Proxy CLI](/local-proxy/cli) (`tinfoil-proxy`), which runs a local reverse proxy that verifies attestation and forwards requests: ```bash theme={"dark"} tinfoil-proxy \ -e \ -r \ -p 3301 ``` Then send requests to `http://localhost:3301` as if it were your container: ```bash theme={"dark"} curl http://localhost:3301/ \ -H "Content-Type: application/json" \ -d '{"key": "value"}' ``` ## Using the CLI For one-off verified requests without running a proxy, use `tinfoil http`: ```bash theme={"dark"} tinfoil http post https:/// \ -e \ -r \ -H "Content-Type: application/json" \ -b '{"key": "value"}' ``` Add any other request headers with repeatable `-H, --header` flags, such as `-H "Authorization: Bearer "`. If you've logged in with `tinfoil login`, use `tinfoil container connect ` to skip looking up the container URL and config repo by hand — the CLI resolves both from the container name and starts the same verified proxy: ```bash theme={"dark"} tinfoil container connect my-api -p 3301 ``` The proxy binds to `127.0.0.1` by default. Use `--bind
` to choose another interface, for example when connecting from another container: ```bash theme={"dark"} tinfoil container connect my-api --port 3301 --bind 0.0.0.0 ``` Only bind beyond localhost on a trusted network because other hosts may then send requests through the proxy. See [Managing containers from the CLI](/containers/cli) for the rest of the management surface. Debug mode containers do not pass attestation. This is by design — debug enclaves trade confidentiality for inspectability. # Custom domains Source: https://docs.tinfoil.sh/containers/custom-domains Configure and verify custom domains for your Tinfoil Containers. ## Default domain Every Tinfoil Container gets a default URL based on its name and your organization's slug: ``` https://..containers.tinfoil.dev ``` For example, a container named `api` in an org with slug `acme` would be available at `https://api.acme.containers.tinfoil.dev`. Debug-mode containers use a separate domain: ``` https://.debug..containers.tinfoil.dev ``` ## Custom domains If your organization has a configured domain suffix, you can assign a custom domain during deployment. Custom domains are an enterprise feature. [Contact us](mailto:contact@tinfoil.sh) to enable custom domains and set up a domain suffix for your organization. ### Setting a custom domain 1. When creating or updating a deployment, enter your desired domain in the **Custom Domain** field 2. The domain must be a subdomain of your org's allowed suffix (e.g. if your suffix is `example.com`, you could use `api.example.com`) ### Validation rules * Must be a valid subdomain of your org's allowed suffix * No protocol prefix (`https://` is added automatically) * No path, wildcard, or port * Must not conflict with existing container domains in your org ## Domain verification When you add a custom domain, you need to verify ownership by creating two DNS records: a **CNAME** record that points your domain to Tinfoil's infrastructure, and a **TXT** record that proves you control the domain. Once both records are detected, your domain is marked as verified and can be used for deploying containers. ### CNAME record | Field | Value | | ------------------ | ----------------------------------------------------------------------- | | **Type** | CNAME | | **Host / Name** | Your custom domain (e.g. `api.example.com`) | | **Target / Value** | The hash target shown in the dashboard (e.g. `a1b2c3d4e5f6.tf-dns.com`) | This record routes traffic for your domain to Tinfoil's servers. ### TXT record | Field | Value | | --------------- | ------------------------------------------------------------------------------------------------- | | **Type** | TXT | | **Host / Name** | `_tinfoil.` (e.g. `_tinfoil.api.example.com`) | | **Value** | The verification string shown in the dashboard (e.g. `tf-domain-verify=api.example.com,a1b2c3d4`) | This record proves you own the domain. The value includes both the domain name and a unique nonce generated for your organization. ### Completing verification After creating both DNS records, return to the Tinfoil dashboard and click the **Refresh** button on your domain. The system performs a live DNS lookup to check for the expected records. If both are found, the domain status changes to **Verified**. It usually takes a few minutes for DNS changes to take effect so you may need to wait. ## Using the CLI Register the domain, configure the returned DNS records, and then verify it: ```bash theme={"dark"} tinfoil domain add api.example.com tinfoil domain verify api.example.com tinfoil container create my-api \ --repo myorg/my-api-config \ --tag v1.0.0 \ --custom-domain api.example.com ``` See [Managing containers from the CLI](/containers/cli#custom-domains) for listing and deleting domains. ## Troubleshooting ### DNS propagation delays DNS changes do not take effect instantly. CNAME and TXT records typically propagate within a few minutes, but in some cases it can take up to 48 hours, especially if you recently changed nameservers. You can check whether your records have propagated by running these commands in a terminal: ```bash theme={"dark"} # Check TXT record dig @8.8.8.8 +short TXT _tinfoil.api.example.com # Check CNAME record dig @8.8.8.8 +short CNAME api.example.com ``` Replace `api.example.com` with your actual domain. Using `@8.8.8.8` queries Google's public DNS, which avoids stale results from your local resolver cache. ### Common mistakes **Wrong TXT record host.** The TXT record must be created on the `_tinfoil` subdomain, not the root domain. For example, if your custom domain is `api.example.com`, the TXT host should be `_tinfoil.api.example.com`, not `api.example.com`. **Missing the underscore prefix.** The host must start with `_tinfoil.` (including the leading underscore). Some DNS providers strip leading underscores; double-check the saved record matches what you entered. **Cloudflare proxy enabled.** If you use Cloudflare, make sure the CNAME record has the proxy toggled **off** (DNS only / grey cloud). The orange-cloud proxy rewrites the CNAME target, which prevents verification from succeeding. **Extra whitespace or quotes in TXT value.** Some DNS providers add surrounding quotes automatically. The TXT value should be exactly the string shown in the dashboard, without extra quotes or whitespace. **Incorrect CNAME target.** The CNAME must point to the exact `*.tf-dns.com` address shown in the dashboard. Pointing to a different address or to an IP will not work. ### Stale local DNS cache If `dig @8.8.8.8` returns the correct records but the dashboard still shows the domain as unverified, your local machine may have cached old results. You can flush your local DNS cache: ```bash theme={"dark"} # macOS sudo dscacheutil -flushcache && sudo killall -HUP mDNSResponder # Linux (systemd-resolved) sudo systemd-resolve --flush-caches ``` After flushing, try the Refresh button again in the dashboard. ### Domain still not verified If your records have been live for more than an hour and verification still fails: 1. Confirm you added the records to the correct DNS zone. If your domain is managed by a different provider than your registrar, make sure you are editing DNS at the active provider. 2. Check for conflicting records. Some providers do not allow a CNAME at the same level as other record types (CNAME flattening rules). If you have an existing A or AAAA record on the same host, remove it or use a subdomain instead. 3. Try querying an authoritative nameserver directly: ```bash theme={"dark"} # Find the authoritative nameserver dig +short NS example.com # Query it directly dig @ns1.example.com +short TXT _tinfoil.api.example.com ``` If you remove the TXT verification record after your domain is verified, the domain may become unverified on the next check. Keep both DNS records in place for as long as you use the domain with Tinfoil. # Debug mode Source: https://docs.tinfoil.sh/containers/debug-mode Deploy debug instances of your Tinfoil Containers with SSH access for troubleshooting. ## What is debug mode? Debug mode lets you deploy a separate instance of your container with SSH access and logging enabled. This gives you a way to inspect the enclave runtime, troubleshoot startup issues, and test configuration changes — without affecting your production container. ## When to use debug mode * **Container startup failures**: SSH in to check why your application isn't starting * **Configuration issues**: Verify that environment variables and secrets are set correctly * **Runtime debugging**: Inspect processes, network, and filesystem inside the enclave * **Testing changes**: Validate a new config or image is working as expected before deploying to production ## How it works Debug containers are fully independent instances. They run on a separate domain and have their own lifecycle, so you can deploy, update, and delete them without touching production instances. | | Production | Debug | | ------------------ | ------------------------------------- | ------------------------------------------- | | **Domain** | `..containers.tinfoil.dev` | `.debug..containers.tinfoil.dev` | | **SSH access** | No | Yes | | **Docker logging** | No | Optional | | **Attestation** | Yes | No | **Debug containers do not pass attestation.** Tinfoil's `SecureClient` will refuse to connect to a debug instance because debug enclaves are not confidential. This is by design as debug mode trades confidentiality for inspectability. Never inject production secrets or send production or otherwise sensitive data to a debug container. A container named `api` can have both a production and a debug instance running simultaneously. ## Deploying a debug container 1. In the **All Containers** tab, click **New Container** 2. Toggle **Debug Mode** on 3. Select one or more SSH keys from your organization's key list (see below) 4. Configure the rest of the container as normal 5. Click **Deploy Container** ## Managing SSH keys Before deploying a debug container, add your SSH public keys to the organization. ### Adding keys 1. Go to the **SSH Keys** tab in the Containers section 2. Click **Add SSH Key** 3. Paste your public key ## Connecting via SSH Once your debug container is running, the dashboard shows the SSH connection command on the container's card. It looks like: ```bash theme={"dark"} ssh -p root@console.tinfoil.sh ``` This gives you a shell inside the enclave where you can inspect running processes, check logs, verify environment variables, and debug your application. ## Promoting to production Once you've finished testing with a debug container, you can deploy it as a production enclave directly from the dashboard: 1. On the debug container, click **Update** 2. In the modal, select **Deploy to prod** 3. The container deploys as a production enclave with the same configuration but with debug access and logging disabled This uses a blue-green deployment — the debug container keeps running until the production instance is ready. ## Using the CLI The [Tinfoil CLI](/containers/cli) handles the SSH key registry and the debug-mode deploy: ```bash theme={"dark"} # Register your public key (once per developer) tinfoil ssh-key list tinfoil ssh-key create laptop --public-key-file ~/.ssh/id_ed25519.pub # Deploy a debug container (separate from any production container with the same name) tinfoil container create my-api \ --repo myorg/my-api-config \ --tag v1.0.0 \ --debug \ --ssh-key laptop # Promote the debug container to production tinfoil container relaunch my-api --debug false --debug-mode ``` When two containers share a name (one debug, one production), `--debug-mode` selects the debug container. You can also use its UUID. # Deployment lifecycle Source: https://docs.tinfoil.sh/containers/deployments Manage deployments and their container instances. ## Deployments A **deployment** is a single GitHub repository. It stores shared settings and gives the dashboard and CLI one place to inspect or update those instances together. Dashboard view of a deployment showing aggregate instance counts Each **container instance** in a deployment group is an independent enclave with its own name, status, resources, domain, and lifecycle. A deployment does not add load balancing or automatically update its instances when a release is published. The dashboard shows each deployment with aggregate counts for running, deploying, failed, and stopped instances. Dashboard view of a deployment expanded to show individual container instances Repository-scoped secrets belong to this deployment namespace. They are available only to containers using that repository and move with it when the repository is renamed. If Tinfoil merges an existing destination namespace for the same repository, resolve duplicate secret names before the rename can complete. A destination belonging to a different repository is isolated and never merged. ## Inspecting a deployment Use either the deployment UUID or its `owner/repo` name: ```bash theme={"dark"} tinfoil deployment list tinfoil deployment get owner/repo ``` Pass `-o json` for machine-readable output. To set the staging mode used by default for new instances and deployment-wide updates: ```bash theme={"dark"} tinfoil deployment settings owner/repo --default-staging true ``` Manage secrets for the same repository with `tinfoil repo secret list owner/repo`. See [Secrets & environment variables](/containers/secrets-and-env-vars) for scope and naming rules. See [Updating & lifecycle](/containers/updates#updating-a-repository-deployment) to update every instance, or a selected set, to the same release. ## Container lifecycle Individual containers move through these backend states. The dashboard presents **Ready** as **Running**: | Status | Description | | ------------------- | --------------------------------------------------------------- | | **Pending** | Deployment is queued | | **Deploying** | Image is being pulled and the enclave is booting | | **Started** | The enclave is running and startup checks are still in progress | | **Ready / Running** | Container is live and serving traffic | | **Failed** | Something went wrong — check your config and Docker image | | **Stopping** | The deployment is shutting down | | **Stopped** | The deployment is stopped but its saved configuration remains | The dashboard polls for status updates automatically and updates the deployment counts. For updating a running container, rolling back, canceling an in-progress update, or recovering from a failed update, see [Updating & lifecycle](/containers/updates). ## Stopping and starting a container Open a container's actions in the dashboard and click **Stop** to shut down its enclave without deleting its saved configuration. Click **Start** to deploy it again with the saved settings. The start dialog also lets you select updated settings before deployment. ## Deleting a container Click **Delete** on a container to permanently remove it. This: * Stops the running enclave * Cancels any in-progress update * Deletes the container's secret bindings * Removes the container from your org Deletion is irreversible. The container's configuration, secret bindings, and environment variables are permanently removed. ## Using the CLI The [Tinfoil CLI](/containers/cli) exposes both deployment-level inspection and the individual container lifecycle: ```bash theme={"dark"} tinfoil deployment list # repositories and instance counts tinfoil deployment get owner/repo # one deployment tinfoil container create my-api --repo owner/repo --tag v1.0.0 tinfoil container list # all containers in the org tinfoil container get my-api # full detail (status, host, resources) tinfoil container stop my-api # pause without deleting tinfoil container start my-api # resume with saved config tinfoil container delete my-api # permanent ``` See the [CLI reference](/containers/cli) for create flags, update controls, and scripting tips. # Resource limits & quotas Source: https://docs.tinfoil.sh/containers/limits Understand the resource limits and naming constraints for Tinfoil Containers. ## Organization quotas Each organization can have up to **10 container instances**. Need higher limits? [Contact us](mailto:contact@tinfoil.sh) to discuss your requirements. ## Resource options Each container specifies its resource requirements in `tinfoil-config.yml`. Available options: | Cores | Config value | | :---- | :----------- | | 2 | `cpus: 2` | | 4 | `cpus: 4` | | 8 | `cpus: 8` | | 16 | `cpus: 16` | | 32 | `cpus: 32` | | RAM | Config value | | :----- | :--------------- | | 8 GB | `memory: 8192` | | 16 GB | `memory: 16384` | | 32 GB | `memory: 32768` | | 64 GB | `memory: 65536` | | 128 GB | `memory: 131072` | | 256 GB | `memory: 262144` | | 512 GB | `memory: 524288` | GPU deployments support either `gpus: 1` or `gpus: 8`. GPU access must be enabled for your organization. Start with the smallest tier that works for your workload and scale up if you see high CPU utilization or slow response times. Over-provisioning wastes resources and you can always resize by redeploying with an updated `tinfoil-config.yml`. ## Choosing the right size Pick resources based on what your application actually needs, not what you think it might need. A few rules of thumb: * **Compiled languages** (Go, Rust, C++) are memory-efficient — 8 GB is often enough. * **Interpreted runtimes** (Python, Node.js, Ruby) typically need 16 GB+ for production workloads. * **CPU cores should match your concurrency model.** A single-threaded app won't benefit from 32 cores. A multi-process web server (e.g. Gunicorn with workers) should have roughly one core per worker. * **Measure first.** Run your app locally with resource limits to understand its actual footprint before deploying. # Model weights Source: https://docs.tinfoil.sh/containers/models Prepare Hugging Face model weights for a Tinfoil Container. ## Overview The **Models** tab prepares Hugging Face model weights for use inside a Tinfoil Container. It does not deploy an inference server by itself. Instead, it creates a verified model-weight artifact and gives you the `models:` block to add to `tinfoil-config.yml`. Use this when you are deploying a GPU inference container, such as vLLM, and want the model weights to be pinned and verified separately from the Docker image. Your Docker image still needs to contain the inference server runtime. The Models tab prepares the weights that the runtime will load. ## Why this exists Enclave attestation proves what code and configuration were present when the enclave booted. Model weights are usually loaded from disk after boot, so they need their own integrity commitment. Tinfoil uses Modelwrap to turn a pinned Hugging Face commit into a read-only model package with a dm-verity root hash. The enclave config commits to that root hash, and dm-verity verifies each disk read while the inference server loads the model. For the full technical explanation, read [How Tinfoil Proves Exactly What Model Is Running](https://tinfoil.sh/blog/2026-02-03-proving-model-identity). ## Prepare weights 1. Open the [Tinfoil Dashboard](https://dash.tinfoil.sh) 2. Go to **Tinfoil Containers** > **Models** 3. Enter the Hugging Face repo in `owner/model` form 4. Use the auto-filled commit, or paste a specific commit SHA 5. Add an HF token if the repo is gated or private 6. Click **Prepare weights** Large models can take several minutes to wrap. When the job finishes, copy the generated `models:` block into your config repo. ## Add the model block The generated block looks like this: ```yaml tinfoil-config.yml theme={"dark"} models: - name: "gemma-4-31b-it" repo: "google/gemma-4-31B-it@419b2efe421994fdfd3394e621983d4cc511cd4f" mpk: "0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab_62578683904_59fe9787-ed93-577a-9fd9-a7804c932a11" ``` | Field | Type | Required | Description | | ------ | ------ | -------- | ------------------------------------------------------------------ | | `name` | string | Yes | Local identifier for the model artifact | | `repo` | string | Yes | Hugging Face repo pinned to a commit, in `owner/model@commit` form | | `mpk` | string | Yes | Model package metadata generated by the Models tab | The `mpk` value is generated by Tinfoil and includes the model root hash, verity offset, and verity UUID. Keep it exactly as generated. ## Point your server at the mounted model At boot, Tinfoil verifies the model artifact and mounts it read-only under `/tinfoil/mpk`. In your inference server command, use: ```yaml tinfoil-config.yml theme={"dark"} command: [ "--model", "/tinfoil/mpk/mpk-0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab", "--served-model-name", "gemma-4-31b-it", "--port", "8001" ] ``` The path uses only the root hash portion of the `mpk` value: ```text theme={"dark"} /tinfoil/mpk/mpk- ``` ## Example vLLM config ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 16 memory: 65536 gpus: 1 models: - name: "gemma-4-31b-it" repo: "google/gemma-4-31B-it@419b2efe421994fdfd3394e621983d4cc511cd4f" mpk: "0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab_62578683904_59fe9787-ed93-577a-9fd9-a7804c932a11" containers: - name: "inference" image: "vllm/vllm-openai:v0.14.1@sha256:..." runtime: nvidia gpus: all ipc: host restart: always command: [ "--model", "/tinfoil/mpk/mpk-0900ca6b913db0036792149d3ea5862986d66a6964b010e998f56fbb7e1276ab", "--served-model-name", "gemma-4-31b-it", "--port", "8001" ] healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8001/health"] interval: 30s timeout: 5s start_period: 30m shim: upstream-port: 8001 paths: - /v1/chat/completions - /v1/models - /health ``` After editing the config, commit it and trigger the **Tinfoil Release** workflow with a new version. Wait for both phases to complete, then deploy or update from the **All Containers** tab. ## Updating weights To update a model, prepare the new Hugging Face commit from the **Models** tab, replace the `repo` and `mpk` values in `tinfoil-config.yml`, then release a new version via the **Tinfoil Release** workflow. Keep each deployment pinned to a specific Hugging Face commit. Avoid relying on a moving default branch for production workloads. # Overview Source: https://docs.tinfoil.sh/containers/overview Run your containers in Tinfoil's secure enclaves with our privacy guarantees At Tinfoil, **everything that touches plaintext client data** runs inside secure enclaves. This includes all our hosted AI models and the rest of our critical security infrastructure. Tinfoil Containers makes that same infrastructure available for running your own applications, with the same privacy, transparency, and security guarantees. Tinfoil Containers run any Docker image inside an AMD SEV or Intel TDX secure enclave. The container's memory is encrypted in hardware, isolated from the host, and invisible to Tinfoil. You and any client connecting to it can verify that fact themselves via [remote attestation](/containers/connecting). GPU workloads run inside NVIDIA confidential-computing enclaves with the same attestation. [Contact us](mailto:contact@tinfoil.sh) to enable GPU access for your org. ## What's public, what's private A Tinfoil Container deployment splits into two artifacts with different visibility rules: * The configuration (`tinfoil-config.yml`) **must live in a public repo.** Tinfoil reads it at deploy time to compute the enclave measurement, and your users' SDKs read it (via the [Sigstore transparency log](https://docs.sigstore.dev/logging/overview/)) at request time to verify the running enclave matches what you published. * Your **source code and Docker image can stay private.** The config pins the image by SHA256 digest, and the attestation commits to that exact digest — Tinfoil doesn't need to read your code to verify it. See [Private images](/containers/private-images) for registry credentials. Anything that's *part of the measurement* must be readable; anything that's *measured by* the config doesn't have to be. ## Limitations Tinfoil Containers have some limitations to work around when building on top of them: * **No persistent disk.** The enclave filesystem is a ramdisk. You can write to it, but everything is lost when the container restarts or redeploys. For durable storage, see the [persistent storage tutorial](/containers/persistent-storage). * **No inbound private networking.** Your container is reachable over the public internet. You'll have to build in appropriate authentication yourself. * **No built-in load balancing.** Each container is one enclave instance. A deployment can contain multiple independently managed instances, but Tinfoil does not balance traffic between them. * **No SSH access by default.** Since your container runs in a secure enclave, you can't shell into it by default. To troubleshoot, enable [debug mode](/containers/debug-mode), which deploys a separate instance of your container with SSH access. **Debug containers do not pass attestation.** Tinfoil's `SecureClient` will refuse to connect to a debug instance because debug enclaves are not confidential. Never use debug mode for production traffic or sensitive data. ## Getting started Deploy your first container. A ready-to-deploy template that ships a working hello-world container with an env var and a secret. ### Configuration The full `tinfoil-config.yml` schema for runtime, resources, and routing. Configure registry credentials so Tinfoil can pull private images. Manage environment variables and encrypted secrets. Use your own domain instead of the default .containers.tinfoil.dev URL. Prepare Hugging Face weights for GPU inference containers. ### Operations SDK and CLI examples for making attested requests. Manage deployment settings and individual container instances. Blue-green updates, rollback, cancel, and recovery. SSH into a separate debug instance for troubleshooting. Stage updates and promote them manually after testing. Manage the full container lifecycle from the terminal. ### Reference CPU, memory, naming constraints, and org quotas. Security, reliability, and deployment best practices. Common issues and how to fix them. # Persistent storage with the buckets sidecar Source: https://docs.tinfoil.sh/containers/persistent-storage Store end-user data in S3 from a Tinfoil Container without exposing plaintext outside the enclave. Tinfoil Containers have [no persistent disk](/containers/overview#limitations): the enclave filesystem is a ramdisk, and everything on it is lost on restart or redeploy. The standard way to persist data is object storage like Amazon S3, but writing plaintext to S3 would hand your users' data to the storage provider and break the enclave's privacy guarantees. The [tinfoil-buckets-sidecar](https://github.com/tinfoilsh/tinfoil-buckets-sidecar) solves this. It runs as a second container inside your enclave and exposes an S3-compatible API on the enclave's internal network. On every write it encrypts the object with AES-256-GCM before forwarding it to S3; on every read it fetches the ciphertext, decrypts it, and verifies the authentication tag. Plaintext and keys only ever exist inside the secure enclave. S3 (and anyone with access to the bucket) sees only ciphertext. ```mermaid theme={"dark"} flowchart LR user["End user
(holds key)"] -->|request + key| app subgraph enclave["Secure enclave"] app["Your app"] -->|S3 API + key headers| sidecar["Buckets sidecar (:9000)
encrypts / decrypts"] end sidecar -->|ciphertext only| s3[("Amazon S3")] ``` ## End users hold the keys You operate the enclave, but the data belongs to your end users, so each user's data is sealed under a key only that user holds. Keys cannot live inside the enclave: it has no persistent disk, so a key generated and kept there would be lost on the first restart, making everything stored under it unrecoverable. Instead, each end user generates a 32-byte AES-256 key on their own device (or derives one from a passphrase using a key derivation function such as HKDF) and sends it along with each request to your app. Your app authenticates the user, then forwards the key to the sidecar in a per-request header. The key exists inside the enclave only for the lifetime of the request and is never persisted, so neither you as the operator nor Tinfoil can decrypt the stored data. Each user's key is the confidentiality boundary for their data. A user who loses their key permanently loses everything stored under it, so client applications must store keys durably (device keychain, passkey-wrapped backup, or similar). ## Tutorial This walks through adding encrypted S3 storage to an existing container deployment. For a complete runnable deployment, see the [persistent storage example](https://github.com/tinfoilsh/tinfoil-persistent-storage-example). ### 1. Create a bucket and IAM credentials Create an S3 bucket and an IAM user whose credentials the sidecar will use. Attach a policy scoped to the bucket(s) the sidecar should reach — IAM is the enforcement point for which buckets are accessible: ```json theme={"dark"} { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts" ], "Resource": ["arn:aws:s3:::YOUR-BUCKET/*"] }, { "Effect": "Allow", "Action": [ "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:GetBucketLocation" ], "Resource": ["arn:aws:s3:::YOUR-BUCKET"] } ] } ``` Add the credentials as two [secrets](/containers/secrets-and-env-vars) in the Tinfoil dashboard: `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Use `tinfoil secret create SECRET_NAME --value-file ./path` for organization scope or `tinfoil repo secret create owner/repo SECRET_NAME --value-file ./path` for repository scope. These credentials only ever touch ciphertext. ### 2. Add the sidecar to your config Run the sidecar alongside your app in multitenant mode and put both on a shared [network](/containers/config-networking). The sidecar needs egress to reach S3; your app reaches it at `http://buckets:9000` by container name: ```yaml tinfoil-config.yml theme={"dark"} cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 4 memory: 8192 networks: storage: egress: open containers: - name: "app" image: "ghcr.io/myorg/my-app:v1.0.0@sha256:abc123..." networks: [storage] env: - BUCKETS_URL: "http://buckets:9000" - name: "buckets" image: "ghcr.io/tinfoilsh/tinfoil-buckets-sidecar@sha256:def456..." networks: [storage] env: - PORT: "9000" - AWS_REGION: "us-east-2" - MULTITENANT: "true" secrets: - AWS_ACCESS_KEY_ID - AWS_SECRET_ACCESS_KEY shim: upstream-port: 8080 paths: - /health - /api/* ``` ### 3. Have each end user generate a key Client applications generate 32 random bytes per user, base64-encode them, and store the key durably on the user's side. The equivalent of: ```bash theme={"dark"} openssl rand -base64 32 ``` Alternatively, derive the key from a passphrase the user already holds, so there is nothing extra to store. The client sends this key to your app with each request that reads or writes stored data. The connection to the enclave is attested and encrypted end-to-end, so the key is only ever visible inside the enclave. ### 4. Make per-user requests from your app For each authenticated request, your app forwards two headers to the sidecar: the user's key, and a tenant id your app derives from the user's verified identity (never from user input). Any S3 SDK works, configured with the sidecar as the endpoint, path-style addressing, and sequential single-request transfers; credentials can be any throwaway values, since SDKs refuse to send unsigned requests: ```python theme={"dark"} import boto3 from botocore.config import Config from boto3.s3.transfer import TransferConfig s3 = boto3.client( "s3", endpoint_url="http://buckets:9000", region_name="us-east-2", aws_access_key_id="tinfoil-sidecar", aws_secret_access_key="tinfoil-sidecar", config=Config(s3={"addressing_style": "path"}), ) # Attach the authenticated user's tenant id and key to every request def tenant_headers(request, **kwargs): request.headers["X-Tinfoil-Tenant-Id"] = f"user-{verified_user_id}" request.headers["X-Tinfoil-Encryption-Key"] = user_key_base64 s3.meta.events.register("before-send.s3", tenant_headers) # Force sequential multipart uploads and disable ranged-GET downloads tc = TransferConfig(max_concurrency=1, multipart_threshold=5 * 1024**4) s3.upload_file("./upload.bin", "YOUR-BUCKET", "docs/report.pdf", Config=tc) s3.download_file("YOUR-BUCKET", "docs/report.pdf", "./roundtrip.pdf", Config=tc) ``` The bucket comes from the request path (`s3://bucket/key`), so one sidecar can serve multiple buckets; reachability is enforced by the IAM policy from step 1. Equivalent Java and AWS CLI setups are in the sidecar's [guides](https://github.com/tinfoilsh/tinfoil-buckets-sidecar/tree/main/guides). ## How multitenancy works * Objects are transparently namespaced under `/` in the backing bucket, so one tenant can never address another's objects. Listing and reads within a tenant see plain object keys with the prefix stripped. * The sidecar trusts the two headers — your app is the authentication layer, which is why the tenant id must come from the user's verified identity. * Key and tenant id are decoupled, so a user can rotate keys without moving data. The caller tracks which key decrypts which object; the wrong key returns `400 DecryptionFailed`. If the enclave stores only its own operational data rather than per-user data, the sidecar also runs in single-key mode: omit `MULTITENANT` and supply one `ENCRYPTION_KEY` secret instead. See the [sidecar README](https://github.com/tinfoilsh/tinfoil-buckets-sidecar#configure). ## Differences from normal S3 Because the sidecar maintains streaming AES-GCM cipher state, a few S3 behaviors differ: | Behavior | Constraint | | :---------------- | :-------------------------------------------------------------------------------------------------------------- | | Addressing | Path-style only (`forcePathStyle` or equivalent) | | Multipart uploads | Sequential parts (`max_concurrency=1`); non-last parts must be multiples of 16 bytes | | Ranged GETs | Not supported — download whole objects | | GET responses | Buffered in memory to verify the authentication tag; 1 GiB default, configurable via `BUFFER_SIZE` up to 64 GiB | | ETags | Synthetic — not usable for client-side integrity checks | High-level SDK transfer managers satisfy the multipart constraints with the configuration shown above. For objects larger than the buffer cap, the sidecar supports a streaming mode (`DANGEROUS_DELAYED_AUTH`) that requires a verifying client; see the [design notes](https://github.com/tinfoilsh/tinfoil-buckets-sidecar#design-decisions-constraints) before using it. ## Relevant repositories * [tinfoil-buckets-sidecar](https://github.com/tinfoilsh/tinfoil-buckets-sidecar) — the sidecar itself, with CLI and SDK guides * [tinfoil-persistent-storage-example](https://github.com/tinfoilsh/tinfoil-persistent-storage-example) — minimal deployable example # Private images Source: https://docs.tinfoil.sh/containers/private-images How attestation works with private source code, and how to configure authentication for private container registries. ## Overview Tinfoil's own stack is fully open source, which is how anyone can audit what runs inside our enclaves. But your application doesn't need to be. When an enclave boots, the hardware fingerprints everything inside it and signs that fingerprint with the chip manufacturer's key. On every connection, the client can verify that the code actually running is exactly the code that was committed to. If you're open source, anyone can inspect that code themselves. If you're not, you can choose to have a third-party auditor review it and certify that a given image hash meets their requirements. The fingerprint is public and permanently committed to, even if the source code is not available. Attestation is performed against the pinned SHA256 digest in your configuration, so verification works the same way whether your image is public or private. If your image isn't public, you can have a third-party auditor review it and certify that a given hash meets their requirements; the fingerprint stays public and permanently committed to either way. A Tinfoil Container deployment has two artifacts, and they follow different visibility rules: * **The config repo must be public.** Tinfoil measures `tinfoil-config.yml` at the tag you deploy and publishes the measurement to the Sigstore transparency log. Clients verify against that log, so the repo's contents need to be inspectable. * **The Docker image can be private.** Your config pins the image by SHA256 digest, and the enclave's attestation commits to that exact digest. Whether the image bytes are publicly pullable doesn't affect verifiability — the digest already pins them. The rest of this page covers authentication for private image registries. ## Registry authentication Registry authentication is an enterprise feature. [Contact us](mailto:contact@tinfoil.sh) to enable private registry support for your organization. If your Docker images are stored in a private registry, you need to add registry credentials so Tinfoil can pull them during deployment. Supported registries are GitHub Container Registry (`ghcr.io`), Google Artifact Registry (`*-docker.pkg.dev`) / Container Registry (`gcr.io`), and Docker Hub (`docker.io`). ## Adding credentials 1. Go to the **Registry Credentials** tab in the Containers section of the dashboard 2. Click **Configure** for your registry type 3. Enter your authentication credentials 4. Click **Save** Credentials are stored encrypted and scoped to your organization. Once configured, any container in your org can pull images from that registry. ## GitHub Container Registry (ghcr.io) Tinfoil needs a **personal access token** with read access to your packages. ### Creating a classic PAT Classic PATs are the most reliable option for ghcr.io — they work with all packages, including those not linked to a specific repository. See [GitHub's guide to creating a classic PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) for full details. 1. Go to [github.com/settings/tokens](https://github.com/settings/tokens) 2. Click **Generate new token** > **Generate new token (classic)** 3. Give it a descriptive name (e.g. "Tinfoil registry read-only") 4. Set an **expiration** — 90 days is a reasonable default 5. Under scopes, check only **`read:packages`** 6. Click **Generate token** and copy it immediately If your GitHub organization uses **SAML SSO**, you must authorize the token for your org after creating it. Go to [github.com/settings/tokens](https://github.com/settings/tokens), find the token, click **Configure SSO**, then click **Authorize** next to your organization name. Without this step, pulls from your org's packages will fail with a 403. See [GitHub's SSO authorization guide](https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-saml-single-sign-on/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on). ### What to enter in the dashboard * **Username**: Your GitHub username * **Token**: The personal access token you just created GitHub's fine-grained personal access tokens **do not support GitHub Packages** (including ghcr.io). You must use a classic PAT for pulling container images. ## Google Artifact Registry / Container Registry Google Container Registry (`gcr.io`) was shut down in 2025. Projects that migrated to Artifact Registry can still use `gcr.io` URLs, which now route to Artifact Registry under the hood. Tinfoil supports both `gcr.io` and `*-docker.pkg.dev` image URLs. Tinfoil needs a **service account JSON key** with read access to your container images. See [Google's service account creation guide](https://cloud.google.com/iam/docs/service-accounts-create) and [Artifact Registry authentication docs](https://cloud.google.com/artifact-registry/docs/docker/authentication) for full details. ### Creating a service account 1. Open the [Google Cloud Console](https://console.cloud.google.com) 2. Select your project 3. Go to **IAM & Admin** > **Service Accounts** 4. Click **Create Service Account** 5. Name it (e.g. "tinfoil-registry-reader") and click **Create and Continue** 6. Grant the role **Artifact Registry Reader** (`roles/artifactregistry.reader`) — for project-wide access, grant it at the project level — for a single repo, grant it on the specific Artifact Registry repository instead 7. Click **Done** ### Creating and downloading the JSON key See [Google's guide to creating service account keys](https://cloud.google.com/iam/docs/keys-create-delete#creating) for full details. 1. In the Service Accounts list, click on the service account you just created 2. Go to the **Keys** tab 3. Click **Add Key** > **Create new key** 4. Select **JSON** and click **Create** 5. A `.json` file will download — store it securely. This file can only be downloaded once; if you lose it, you must create a new key. ### What to enter in the dashboard * **Service Account Key**: Paste the **entire contents** of the downloaded JSON key file Service account JSON keys **do not expire by default**. However, your organization may enforce key expiration via the `constraints/iam.serviceAccountKeyExpiryHours` organization policy. If this policy is set, keys will stop working after the configured duration and you'll need to create a new one. ## Docker Hub Tinfoil needs a **personal access token** with read permissions. See [Docker's access token documentation](https://docs.docker.com/security/for-developers/access-tokens/) for full details. ### Creating an access token 1. Log in to [Docker Home](https://app.docker.com) 2. Click your profile avatar > **Account settings** 3. Go to **Personal access tokens** in the left sidebar 4. Click **Generate new token** 5. Enter a description (e.g. "Tinfoil registry read") 6. Set the permission to **Read** 7. Set an **expiration date** 8. Click **Generate** and copy the token immediately ### What to enter in the dashboard * **Username**: Your Docker Hub username * **Token**: The access token you just created Docker Hub access tokens support an **expiration date** set at creation time. The expiration cannot be changed after creation — you must create a new token if you need a different expiration. If your account has **two-factor authentication** enabled, PATs are required for CLI access (passwords will not work). ## Troubleshooting ### "Requires authentication" error during deployment This means the container image is in a private registry but no credentials are configured for that registry type. **Fix**: Go to the **Registry Credentials** tab and configure credentials for the registry shown in the error message. The dashboard includes a direct link to the tab in the error. ### "Expired or been revoked" error during deployment This means credentials are configured but the registry rejected them. The token may have expired, been revoked, or lost access. **Fix**: Go to the **Registry Credentials** tab and update your credentials with a fresh token. After updating, the expired credentials banner will clear automatically. Common causes: * **GitHub PAT expired** — classic PATs can be set with an expiration date. Create a new one and update it in the dashboard. * **GitHub PAT not authorized for SSO** — if your org enabled SAML SSO after you created the token, you need to re-authorize it. * **Google service account key disabled or deleted** — check the service account in Google Cloud Console. Create a new key if needed. * **Google IAM permissions changed** — verify the service account still has `roles/artifactregistry.reader`. * **Docker Hub token expired or revoked** — Docker Hub tokens have mandatory expiration dates. Create a new access token in Docker Hub settings. ### Expired credentials banner When Tinfoil detects that your registry credentials have been rejected during a deployment attempt, an amber banner appears on the Active tab. This banner persists until you update the credentials in the Registry Credentials tab. ## Using the CLI The [Tinfoil CLI](/containers/cli) sets the same per-registry credentials, which is convenient when rotating tokens from a script: ```bash theme={"dark"} tinfoil registry list # GitHub Container Registry: classic PAT with read:packages tinfoil registry set ghcr --username my-gh-user --token ghp_xxx # Google Artifact Registry / Container Registry: service-account JSON tinfoil registry set gcr --key-file ./gcp-sa.json # Docker Hub: PAT with read permission tinfoil registry set dockerhub --username my-docker-user --token dckr_xxx # Remove credentials for a registry tinfoil registry delete ghcr ``` Pass `--key-file -` to read the GCR key from stdin instead of disk. Once credentials are stored, any container in the org can pull images from that registry. # Production checklist Source: https://docs.tinfoil.sh/containers/production-checklist A checklist for running Tinfoil Containers in production. Not every item here will apply to your container. Use this as a starting point and adapt it to your workload. ## Security * **Don't use debug mode in production.** Debug mode enables SSH access and logging that you don't want in a production enclave. Deploy a separate debug instance for troubleshooting. * **Use secrets for all sensitive values.** Database URLs, API keys, tokens, and credentials should be stored as [secrets](/containers/secrets-and-env-vars#secrets), not as environment variables in your config file or repo. * **Use minimal registry credentials.** If you're pulling from a private registry, create access tokens with read-only scope. See [registry auth](/containers/private-images). * **Rotate secrets periodically.** Update secret values in the dashboard, then redeploy your container to pick up the new values. * **Only expose necessary paths.** The `paths` list in your config acts as an allowlist — only list the endpoints your app needs to serve externally. ## Reliability * **Use blue-green updates.** Always update running containers via the [Update flow](/containers/updates) rather than deleting and redeploying. This gives you zero-downtime deploys and the ability to fix things if the new version is broken. Note: [multi-GPU containers](/containers/updates#gpu-container-updates) have downtime during updates by default. * **Validate releases in staging.** Before switching production traffic, deploy the new version in [staging mode](/containers/staging-mode) and promote it after testing. Reserve debug mode for troubleshooting with non-sensitive data. ## Resources * **Right-size your container.** Start with the smallest resource tier that works and scale up if needed. Over-provisioning wastes resources; under-provisioning causes OOM kills and slow responses. See [resource options](/containers/limits#resource-options) for guidance. * **Monitor memory usage.** If your container is using close to its memory limit, it may get killed under load. Allocate headroom above your typical usage. ## Networking * **Set up a custom domain.** For production APIs, use a [custom domain](/containers/custom-domains) rather than the default `*.containers.tinfoil.dev` URL. This gives you control over DNS and makes it easier to migrate later. * **Listen on the right port.** Your app must listen on the port you configured as `upstream-port` in `tinfoil-config.yml`. ## Secrets hygiene * **Watch for stale secrets.** The dashboard shows a warning when secrets have been updated since the container was last deployed. Redeploy promptly after rotating secrets. * **Don't log secret values.** Even though secrets are only available inside the enclave, avoid printing them to stdout/stderr. Treat the enclave runtime the same way you'd treat any production server. * **Clean up unused secrets.** Remove organization and repository secrets that are no longer referenced by any container. ## Deployment workflow * **Start from the template.** Create your repo from the [tinfoil-containers-template](https://github.com/tinfoilsh/tinfoil-containers-template) to get the latest `cvm-version` value. * **Pin images with SHA256 digests.** Always use `image:tag@sha256:...` in your config. This ensures the exact image binary is verified and recorded in the transparency log. If you build your own image, the release workflow handles this — use a placeholder on `main` and it substitutes the real digest at the tagged commit. See [Building images](/containers/building-images#image-digests-in-the-release-workflow). * **Tag releases in Git.** Use semantic versioning or a consistent tagging scheme so you can track which version is deployed and roll back to a known-good tag. # QuickStart Demo Source: https://docs.tinfoil.sh/containers/quickstart Deploy your first Tinfoil Container in minutes. This dashboard-first walkthrough takes you through the full Tinfoil Containers flow end-to-end using the [`tinfoil-containers-template`](https://github.com/tinfoilsh/tinfoil-containers-template). The template ships with a pre-built hello-world image, so there's nothing to build — you'll deploy a demo container to a real Tinfoil enclave, manage a secret, send a verified request, and see how updates work. To deploy from the terminal instead, see [Managing containers from the CLI](/containers/cli). By the end you'll understand the moving parts well enough to swap in your own image. Here's what you'll do: * Create a repo from the template and review its `tinfoil-config.yml`. * Release a version and add a secret (`GREETING_TOKEN`). * Deploy the container to an enclave and make a verified request to it. * Learn how to update the instance. ## Prerequisites * An organization in the [Tinfoil Dashboard](https://dash.tinfoil.sh) (Containers is an org-level feature). * An active Tinfoil Containers subscription for your organization. ## 1. Create a repo from the template Use [`tinfoil-containers-template`](https://github.com/tinfoilsh/tinfoil-containers-template) to make a new repo: click **Use this template** → **Create a new repository**. Give it any name you'd like, and make sure it's **public**. The `tinfoil-config.yml` needs to be public so the instance can be independently verified — Tinfoil fetches it at deploy time to compute the enclave measurement, and your users' SDKs fetch it (via the [Sigstore transparency log](https://docs.sigstore.dev/logging/overview/)) to check that the running enclave matches what you published. The Docker image itself can still be private — see [Private images](/containers/private-images). ## 2. Read the `tinfoil-config.yml` This is the file that defines your deployment. It's already wired up: ```yaml tinfoil-config.yml theme={"dark"} # Tinfoil Container Configuration # For updates: edit this file, commit, run the workflow (e.g., v0.0.2) cvm-version: "" # TODO: Use latest version from https://github.com/tinfoilsh/cvmimage cpus: 2 memory: 8192 containers: - name: "hello-world" image: "ghcr.io/tinfoilsh/tinfoil-containers-hello-world@sha256:" env: - MESSAGE: "Hello from Tinfoil" secrets: - GREETING_TOKEN shim: upstream-port: 8080 paths: - /* ``` The `containers` section defines a `hello-world` container, which points to a prebuilt Tinfoil image, [`tinfoil-containers-hello-world`](https://github.com/tinfoilsh/tinfoil-containers-hello-world). It accepts an environment variable and a secret, then reports the message and whether the secret is present. For now, leave it alone — we'll customize it later. See the [configuration reference](/containers/configuration) for the full schema, with [networking & routing](/containers/config-networking), [runtime & security](/containers/config-runtime), and [example configs](/containers/config-examples) on their own pages. Deploying a Hugging Face model with an inference server like vLLM? Prepare the model weights first in the dashboard's **Models** tab, then add the generated `models:` block to your config. See [Model weights](/containers/models). ## 3. Release a version Releasing creates the Git tag, measures the image, signs the attestation, and publishes a GitHub release. The dashboard drives the whole flow, so you don't need to touch the command line. The template includes the required `tinfoil-release.yml` and `tinfoil-release-publish.yml` GitHub Actions workflows. If you use a custom config repository, copy both workflows from the template before publishing your first release. 1. Open the [Tinfoil Dashboard](https://dash.tinfoil.sh) and go to **Containers** → **Repositories** → select your repo that you just created. Note: you may need to install the Tinfoil GitHub App first. 2. Edit the config. The dashboard renders `tinfoil-config.yml` as editable fields (resources, containers, env, secrets). The template config is already complete, so for this first release a trivial change (for example, the `MESSAGE` value) is enough. 3. Click **Open Pull Request**. The dashboard opens a PR on your repo with the config change. Review and merge it. 4. Follow the **Release** steps shown in the dashboard to publish version `v0.0.1`. After the release runs, wait \~1 min before deploying — the tag won't appear in the dashboard's picker until the measurement is computed and the GitHub release is published. Prefer the command line? Trigger the **Tinfoil Release** workflow directly: ```bash theme={"dark"} gh workflow run tinfoil-release.yml -f version=v0.0.1 ``` Or via the GitHub UI: **Actions** → **Tinfoil Release** → **Run workflow** → enter `v0.0.1`. Two workflows run back-to-back: `tinfoil-release.yml` creates the tag, then auto-dispatches `tinfoil-release-publish.yml`, which runs [`measure-image-action`](https://github.com/tinfoilsh/measure-image-action) to compute the enclave measurement and publish the release. ## 4. Add the `GREETING_TOKEN` secret The config declares a `GREETING_TOKEN` secret. That means we'll have to add a value for it before deploying. Tinfoil Dashboard will refuse to deploy unless this secret exists. To add the value: 1. Open the [Tinfoil Dashboard](https://dash.tinfoil.sh) 2. Navigate to **Tinfoil Containers** → **Secrets** 3. Click **Add Secret**, enter the name `GREETING_TOKEN` and any value 4. Save ## 5. Deploy In the dashboard: 1. **All Containers** tab → **New Container** 2. Enter a container name (lowercase, hyphens allowed — e.g. `hello-world`) 3. Select your new repository (enter `owner/repo` manually or pick from connected repos) 4. The `v0.0.1` tag will auto-select 5. Confirm the env vars and secrets shown match your config 6. Click **Deploy Container** The status will show **Deploying** while the image is pulled and the enclave boots. After a minute or two it flips to **Running**. Click **Connect GitHub** in the dashboard to install the Tinfoil GitHub App on your repo. This lets Tinfoil mark a successfully deployed release as GitHub `latest`. It does not update running containers when a new release is published. ## 6. Update the instance To roll out a change — new image digest, new env var, new path — edit `tinfoil-config.yml` and release a new version, all from the dashboard. 1. Go to **Containers** → **Repositories** → select your repo and edit `tinfoil-config.yml`. For example, change the `MESSAGE` env var: ```yaml tinfoil-config.yml theme={"dark"} env: - MESSAGE: "A new message!" ``` 2. Click **Open Pull Request**, then review and merge it. 3. Follow the **Release** steps shown in the dashboard to publish `v0.0.2`. 4. Click **Update** on your container and select `v0.0.2`. This triggers a [blue-green update](/containers/updates) (no downtime). After committing your config change: ```bash theme={"dark"} gh workflow run tinfoil-release.yml -f version=v0.0.2 ``` Or via the GitHub UI: **Actions** → **Tinfoil Release** → **Run workflow** → enter `v0.0.2`. ## 7. Make a request Your container is live at `https://..containers.tinfoil.dev`. To test it out: ```bash theme={"dark"} curl https://..containers.tinfoil.dev/ ``` This raw `curl` request checks connectivity but does not verify the enclave. Do not send sensitive data this way. You should see your updated message: ``` MESSAGE: A new message! GREETING_TOKEN: present ``` For real-time, attested requests (where the SDK verifies the enclave measurement before sending data), use one of the [Tinfoil SDKs](/containers/connecting). Once you're ready to deploy your own code instead of the prebuilt template image, see [Building your own image](/containers/building-images). # Secrets & environment variables Source: https://docs.tinfoil.sh/containers/secrets-and-env-vars Manage environment variables and secrets for your Containers. ## What to use when Tinfoil Containers supports two different kinds of configuration values: Secret values are accessible to Tinfoil infrastructure during deployment. If your threat model requires that Tinfoil cannot access certain values, [contact us](mailto:contact@tinfoil.sh) about your use case. | | Environment variables | Secrets | | :------------- | :------------------------------------------------------ | :--------------------------------------------------------- | | **Stored in** | `tinfoil-config.yml` (in your repo) | Encrypted storage (AWS Secrets Manager) | | **Visible to** | Anyone with repo access | **Tinfoil infrastructure**, no public access | | **Set via** | Config file | Declared in config; values managed in the dashboard or CLI | | **Use for** | Non-sensitive config (ports, log levels, feature flags) | Sensitive values (API keys, database URLs, tokens) | Environment variables and secrets are both *declared* in the config. Env var values are also set in the config file; secret values are managed in the dashboard or CLI. Declaring a secret in `tinfoil-config.yml` and selecting it for a deployment are separate steps. The declaration identifies the environment variable expected by the container. During deployment, select every declared secret that should be injected. With the CLI, pass each name using `--secret NAME`. ## Environment variables ### Config File Define environment variables in the `env` field of your container configuration file: ```yaml tinfoil-config.yml theme={"dark"} containers: - name: api image: "ghcr.io/myorg/api-server:v1.0.0@sha256:" env: - PORT: "8080" - LOG_LEVEL: "info" - NODE_ENV: "production" ``` ### Dashboard During deployment, the dashboard displays the environment variables (and secret names) defined in your `tinfoil-config.yml`. These values are read-only. To change them, update the config file in your repo and release a new version via the **Tinfoil Release** workflow. ### Reserved variables `DOMAIN` is a reserved environment variable set to your container's public domain. Tinfoil populates it automatically at deploy time and uses it to bind the enclave's attested identity and TLS certificate to your domain, and to scope request validation. You don't need to set it — and you shouldn't define your own `DOMAIN` in `env`, since it's managed for you. ## Secrets Secrets are stored in AWS Secrets Manager and injected into your container as environment variables at deploy time. They are not exposed in the dashboard UI or your Git repository. ### Scopes Organization secrets are available to every repository in your organization. Repository secrets are available only to containers deployed from one `owner/repo`. The same name can exist in different repositories, with a different value in each. A name cannot be shared between organization and repository scope within the same organization. ### Creating To create an organization secret: 1. Go to the **Secrets** tab in the Containers section of the dashboard 2. Click **Add Secret** 3. Enter a name (e.g. `DATABASE_URL`) and value 4. Click **Save** To create a repository secret, open the repository in the **Repositories** tab and add it in the **Repository secrets** section. ### Referencing List secret names in the `secrets` field of your container spec. At deploy time, Tinfoil resolves each name from the container's repository secrets or the organization secrets: ```yaml tinfoil-config.yml theme={"dark"} containers: - name: api image: "ghcr.io/myorg/api-server:v1.0.0@sha256:" env: # ... secrets: - DATABASE_URL - STRIPE_SECRET_KEY ``` When deploying a container, the dashboard shows which secrets your config references. If your config references a secret that doesn't exist yet, the dashboard warns you and prevents deployment until it's created. ### Updating Edit a secret's value in the **Secrets** tab or the repository's **Repository secrets** section at any time. Updating a secret does **not** automatically update running containers. To pick up the new value you must redeploy. Redeployment uses the [blue-green flow](/containers/updates), so there's no downtime. The dashboard shows a **stale secrets** indicator on containers that were deployed before their secrets were last updated. ### Deleting You cannot delete a secret that is referenced by any container in its scope. The dashboard shows which containers are using it. Remove the secret from those deployments, then delete it. ### Using the CLI The [Tinfoil CLI](/containers/cli) manages organization secrets with `tinfoil secret`: ```bash theme={"dark"} tinfoil secret list # Create — read the value from a file or stdin to avoid shell history tinfoil secret create DATABASE_URL --value-file ./db.url echo -n "$STRIPE_KEY" | tinfoil secret create STRIPE_SECRET_KEY --value-file - # Rotate (containers using it are marked stale; redeploy to pick up the new value) tinfoil secret set DATABASE_URL --value-file ./db.url # Inspect (the value itself is never returned) tinfoil secret get DATABASE_URL # Delete (fails if any container references it) tinfoil secret delete DATABASE_URL ``` Use `tinfoil repo secret` for repository secrets: ```bash theme={"dark"} tinfoil repo secret list myorg/my-app-config tinfoil repo secret create myorg/my-app-config DATABASE_URL --value-file ./db.url tinfoil repo secret set myorg/my-app-config DATABASE_URL --value-file ./db.url tinfoil repo secret get myorg/my-app-config DATABASE_URL tinfoil repo secret delete myorg/my-app-config DATABASE_URL ``` Reference secrets at deploy time with `--secret NAME` on `tinfoil container create`, `relaunch`, or `start`. See the [CLI secrets section](/containers/cli#secrets) for details. # Staging mode Source: https://docs.tinfoil.sh/containers/staging-mode Stage container updates for manual promotion. ## What is staging mode? Staging mode lets you test a ready update through Tinfoil's staging ingress before manually promoting it. It is intended for validating a tag before it replaces the currently serving instance. ## When to use staging mode * **Validating a new release tag** before switching production traffic * **Pinning a long-lived test instance** to a specific tag * **Manually controlling promotion** of ready updates instead of relying on the automatic blue-green flip ## How it works Staging containers use the same production certificate flow as normal containers. The difference is in update promotion: production containers auto-promote a ready update to live traffic, while staging containers hold it on the staging ingress until you manually promote or cancel it. When an update for a staging container becomes ready, the existing instance continues serving production traffic. The ready update candidate remains available through the Tinfoil staging ingress until you promote or cancel it. Staging mode is independent from [debug mode](/containers/debug-mode) — a container can be in staging, in debug, in both, or in neither. ## Deploying a staging container 1. In the **All Containers** tab, click **New Container** 2. Configure the container as normal 3. Tick the **Staging** checkbox above the launch button 4. Click **Deploy in Staging** You can also tick **Staging** in the **Update Instance** dialog or the **Start Container** dialog to deploy a new tag straight into staging without touching production. ## Updating a staging container To pick up a new tag on a staging container, click **Update**, select the tag, and click **Deploy in Staging**. If a staging update candidate becomes ready while the container itself is already in staging, click **Promote Update** to switch traffic to the staged instance, or **Cancel Update** to discard it. ## Promoting to production A staging container shows a **Deploy to Prod** button in its action sidebar. Click it to relaunch the container with the same tag and configuration in production mode. The new instance goes through the normal blue-green relaunch flow and, once ready, is marked as the GitHub `latest` release. This is the recommended path. If you instead want to deploy a new tag directly to production, open the **Update** dialog, leave the **Staging** checkbox unchecked, and click **Update** — the same end-state is reached. The GitHub `latest` release is bumped only when the post-deploy mode is neither staging nor debug. ## Setting a repository default Set staging as the default for new instances and deployment-wide updates: ```bash theme={"dark"} tinfoil deployment settings myorg/my-api-config --default-staging true ``` Repository updates inherit this setting unless you pass `--staging true` or `--staging false`: ```bash theme={"dark"} tinfoil deployment update myorg/my-api-config --tag v1.0.1 tinfoil deployment update myorg/my-api-config --tag v1.0.1 --staging false ``` ## Using the CLI The [Tinfoil CLI](/containers/cli) supports the same staging flow: ```bash theme={"dark"} # Deploy directly into staging tinfoil container create my-api \ --repo myorg/my-api-config \ --tag v1.0.0 \ --staging # Push a new tag into staging without touching production tinfoil container relaunch my-api --tag v1.0.1 --staging true # Promote a "ready" staged candidate, or discard it tinfoil container update status my-api tinfoil container update accept my-api # promote tinfoil container update cancel my-api # discard # Promote to production by relaunching with staging=false tinfoil container relaunch my-api --staging false ``` # Troubleshooting Source: https://docs.tinfoil.sh/containers/troubleshooting Common issues and solutions when deploying and running Tinfoil Containers. For any failure, you can deploy a [debug mode](/containers/debug-mode) instance to SSH in and investigate. ## Instance failures ### Container stuck in "Deploying" or "Pending" The enclave may be taking longer than usual to boot, or the container image is large. **Check your image size.** Large images take longer to pull. Keep your Docker image lean — use multi-stage builds and minimal base images. **Wait a few minutes.** Initial deployments typically take 2-10 minutes for CPU-only workloads. If it's been more than 10 minutes, try deleting and redeploying. ### Container goes to "Failed" The enclave started but your application didn't pass health checks. The most common cause is a **port mismatch**; make sure your app listens on the port you configured as `upstream-port` in `tinfoil-config.yml`. If your config sets `PORT=8080` but your app listens on 3000, health checks will fail. **Check your startup command.** If your container's entrypoint or CMD is misconfigured, it may exit immediately. SSH into a debug mode instance to test manually. **Check for missing environment variables or secrets.** If your app requires a `DATABASE_URL` or API key at startup and it's not configured, it will crash. The dashboard warns about missing secrets during deployment! Make sure you've created and selected all required ones. ### "Invalid configuration" during validation The dashboard validates your `tinfoil-config.yml` before deploying. **Invalid CPU or memory values.** CPU must be one of: 2, 4, 8, 16, 32. Memory must be one of: 8192, 16384, 32768, 65536, 131072, 262144, 524288 (in MB). See [resource limits](/containers/limits). **Org quota exceeded.** You can have up to 10 containers per org. Check your current usage in the Active tab. **Name already taken.** Container names must be unique within your org. The dashboard checks this in real time. ### Container image can't be pulled **Missing SHA256 digest.** Images must include a SHA256 digest (e.g. `image:tag@sha256:...`). If your repo builds its own image, `main` carries a placeholder digest (`sha256:0000...`). See [Building images](/containers/building-images#image-digests-in-the-release-workflow). **Private registry without credentials.** If your image is in a private registry, add credentials in the [Registry Auth](/containers/private-images) tab. **Registry credentials expired or revoked.** If credentials are configured but the registry rejects them, you'll see an "expired or been revoked" error and an amber banner on the Active tab. Update your credentials in the Registry Credentials tab with a fresh token. See the [registry auth troubleshooting guide](/containers/private-images#troubleshooting) for common causes. **Image tag doesn't exist.** Double-check the Git tag you selected. The image may not have been built for that tag yet. **Wrong image reference.** Make sure the `image` field in `tinfoil-config.yml` matches your actual registry path (e.g. `ghcr.io/myorg/my-app`, not `myorg/my-app`). ### Config format issues **Started from scratch instead of the template.** The [tinfoil-containers-template](https://github.com/tinfoilsh/tinfoil-containers-template) includes the correct `cvm-version` and routing configuration. If you wrote your config from scratch, compare it against the template. **Missing routing config.** Every container needs a `shim` section with `upstream-port` and `paths`. Without it, no traffic can reach your container. **Paths not exposed.** Only paths listed in `paths` are reachable. If your app serves `/api/v1/users` but you only listed `/health`, API requests will be rejected. ## Runtime issues ### Application works locally but not in the enclave **Network differences.** Inside the enclave, your app runs in an isolated network. External services (databases, APIs) must be reachable over the public internet — there's no VPC peering or private network access. **Filesystem is ephemeral.** Don't rely on writing to disk for persistence. Data written to the local filesystem is lost when the enclave restarts. **No GPU access by default.** Containers run on CPUs unless your org has GPU access enabled. CPU-only workloads should not depend on CUDA or GPU libraries. **CUDA version mismatch.** If your container image requires a CUDA version newer than the enclave's driver supports, it will fail to start or crash. SSH into a [debug mode](/containers/debug-mode) instance and run `nvidia-smi` to check the installed driver version and the maximum supported CUDA version. Then pick a container image built for a compatible CUDA version (e.g. `cu128` for CUDA 12.8, `cu130` for CUDA 13.0). ### Secrets not available at runtime **Secret not selected during deployment.** Creating a secret in the Secrets tab doesn't automatically make it available to all containers. You must select which secrets a container can access when deploying. **Secret name mismatch or wrong scope.** The secret name must exactly match the name in your `tinfoil-config.yml` `secrets` list. Names are case-sensitive. Repository secrets must belong to the repository used by the container; organization secrets are available to every repository in the organization. **Stale secrets.** If you update a secret's value after deploying, the running container still has the old value. Redeploy to pick up changes. The dashboard shows a stale secrets indicator when this happens. ### Container is slow or unresponsive If your app is CPU- or memory-constrained, it may respond slowly or OOM-kill. Try scaling up (see [resource options](/containers/limits#resource-options)). Also note that if your application has a slow startup (large frameworks, JVM warmup, model loading), the initial requests after deployment may be slow. ## Update issues See [Updating & lifecycle](/containers/updates) for the full update flow. Common failure modes: ### New tag doesn't appear in the picker The **Tinfoil Release** workflow (or its auto-triggered publish phase) hasn't completed. Check your repo's **Actions** tab — if either phase failed, fix the issue and re-run the failed workflow. The tag shows up in the dashboard only after both phases succeed and `tinfoil-release-publish.yml` creates the GitHub release. ### Blue-green update stuck The new version follows the same lifecycle as a fresh deployment. If it's stuck in "Deploying", the same troubleshooting steps for [instance failures](#instance-failures) apply. You can always click **Cancel Update** to abort without affecting the running version. ### Updated version fails health checks Click **Cancel Update** to keep the current version running. Deploy a debug mode instance with the new tag to investigate, fix the issue, and try the update again. ## Inspecting from the CLI The [Tinfoil CLI](/containers/cli) is often the fastest way to triage a deployment without leaving the terminal: ```bash theme={"dark"} tinfoil deployment list # deployment-level status counts tinfoil deployment get owner/repo # inspect one deployment tinfoil container list # find the failing container tinfoil container get my-api # status, host, error message tinfoil container update status my-api # for stuck blue-green updates tinfoil container metrics my-api --time 1h # CPU / GPU / memory utilization (JSON) ``` Pair `tinfoil container get -o json` with `jq` to script health checks against your fleet. For a deployment-wide rollout, `tinfoil deployment update` reports one result per instance. A skipped result means the instance is neither running nor failed, or already has an update in progress. Use `tinfoil container get` to inspect that instance before retrying. ## Getting help If you're stuck, reach out at [contact@tinfoil.sh](mailto:contact@tinfoil.sh). # Updating & lifecycle Source: https://docs.tinfoil.sh/containers/updates Update a running container, roll back, cancel, and recover from failures. ## How updates work Tinfoil uses a blue-green update strategy: the new version boots alongside the current one, and traffic switches atomically once it reaches **Ready**. If the new version fails health checks, the current one keeps running — no rollback needed mid-update. Blue-green deployment Multi-GPU containers (`gpus: > 1`) can't run two versions simultaneously, so updates for them are not zero-downtime. See [GPU container updates](#gpu-container-updates). ## Starting an update An update has two stages: publish a deployable release from your config repo, then roll that release out to the running container. ### 1. Publish a release Choose one of these approaches: * **Dashboard**: Open the repo from **Containers** → **Repositories**, edit `tinfoil-config.yml`, open and merge the generated pull request, then follow the **Release** steps. See the [quickstart](/containers/quickstart#3-release-a-version) for the full walkthrough. * **Tinfoil CLI**: Open the config pull request and trigger the release through the installed GitHub App: ```bash theme={"dark"} tinfoil repo config get owner/repo --raw > tinfoil-config.yml tinfoil repo config pr owner/repo --file tinfoil-config.yml tinfoil repo pr status owner/repo 42 # After the pull request is merged tinfoil repo build info owner/repo tinfoil repo build run owner/repo --version v1.2.4 ``` * **GitHub directly**: Commit the config change, then run: ```bash theme={"dark"} gh workflow run tinfoil-release.yml -f version=v1.2.4 ``` You can also use **Actions** → **Tinfoil Release** → **Run workflow** in the GitHub UI. Wait for both release workflow phases to finish. `repo build run` prints the GitHub Actions URL where you can follow their status. If either fails, the tag is not deployable. ### 2. Roll out the release In the dashboard, click **Update** on the container and select the new tag. From the CLI, run: ```bash theme={"dark"} tinfoil container relaunch my-api --tag v1.2.4 ``` Publishing a release does not change a running container. The rollout starts only when you select the release in the dashboard or run `container relaunch`. ## Updating a deployment A deployment groups all container instances in your organization that use the same config repository. Update every eligible instance to one tag with: ```bash theme={"dark"} tinfoil deployment update owner/repo --tag v1.2.4 ``` To update only selected instances, repeat `--instance` with their container UUIDs: ```bash theme={"dark"} tinfoil deployment update owner/repo \ --tag v1.2.4 \ --instance 550e8400-e29b-41d4-a716-446655440000 ``` Deployment updates use the default staging setting. Override it for one rollout with `--staging true` or `--staging false`. Only running or failed instances are eligible. Instances in any other state, or already updating, are skipped. The CLI reports each result and exits non-zero if any selected instance is skipped or fails. ### Update statuses The container's deployment status describes the version currently serving traffic. During a blue-green update, the update candidate has a separate status: | Status | Meaning | | ----------- | ----------------------------------------------------------- | | **Pending** | New version is being deployed | | **Started** | New version's enclave is running, health checks in progress | | **Ready** | New version is healthy and ready to accept traffic | | **Failed** | New version failed to start | ## Multi-container configs If one `tinfoil-config.yml` defines multiple processes in its `containers` section, they run in the same enclave and update together. This is separate from a deployment containing multiple independently deployed container instances. ## Rolling back To revert to a previous version, click **Update**, pick an older Git tag, and confirm. It's the same blue-green flow — the current version keeps serving until the older version is ready. ## Canceling an in-progress update Click **Cancel Update** to stop the new version and keep the current one running. The new enclave is torn down and no traffic switches. ## GPU container updates Single-GPU containers use the blue-green flow like CPU containers. Multi-GPU containers (`gpus: > 1`) use all available GPUs on the host, so there are no free GPUs to run a second copy. Updates stop the current deployment before starting the new one, which means **there is downtime** — typically while the new enclave boots and loads your model. DNS records are kept in place during the transition so clients reconnect automatically once the new version is ready. If a multi-GPU update fails, the container is left in a failed state and you can retry or redeploy. ## Redeploying without a new tag To re-launch a container with modified secrets or config, click **Update** and select **Edit config**. The modal opens pre-populated with the container's current config. You can change settings before submitting. The redeployment uses the same blue-green strategy. This is also how you pick up [updated secret values](/containers/secrets-and-env-vars#updating) — the dashboard shows a stale secrets indicator when a redeploy is needed. ## Recovering from a failed update * **New version fails health checks**: click **Cancel Update**. Deploy a [debug mode](/containers/debug-mode) instance with the new tag to investigate, fix the issue, then retry the update. * **Update stuck in Pending or Started**: the new version follows the same lifecycle as a fresh deployment — see [instance troubleshooting](/containers/troubleshooting#instance-failures). ## Using the CLI ```bash theme={"dark"} # Trigger a blue-green update with a new tag or config tinfoil container relaunch my-api --tag v1.2.4 tinfoil container relaunch my-api --variable LOG_LEVEL=debug # Roll back through the same update flow tinfoil container relaunch my-api --tag v1.2.3 # Update every eligible instance that uses a repository tinfoil deployment update owner/repo --tag v1.2.4 # Inspect or control an in-progress update tinfoil container update status my-api tinfoil container update accept my-api # promote a "ready" candidate (staging) tinfoil container update cancel my-api # discard, keep current version ``` See the [CLI reference](/containers/cli#lifecycle) for all flags. # Creating an organization Source: https://docs.tinfoil.sh/create-organization Step-by-step guide for creating and managing a Tinfoil organization Organizations let your team share API keys, billing, and usage under a single account. ## Step 1: Open the Account Switcher From the dashboard, click the **"Personal account"** dropdown in the top right corner. Account switcher dropdown ## Step 2: Create Organization In the dropdown menu, click **"Create organization"**. Create organization option ## Step 3: Name Your Organization Enter your organization **Name** and **Slug**. You can also upload a logo. The slug appears in your dashboard URL and API requests, so keep it short and memorable (e.g., `acme`, `initech`, `wayne-labs`). Click **"Create organization"** when ready. The slug is unique to your organization, public-facing, and cannot be changed after creation. It should represent your organization (e.g., your company name or an abbreviation of it). Choose carefully. Organization name and slug form ## Step 4: Invite Members Enter the email addresses of team members you want to invite, separated by commas. Select a **Role** for the invitees and click **"Send invitations"**. You can also click **"Skip"** to do this later. Invite new members dialog ## Step 5: Manage Your Organization You're now in the organization dashboard. Use the account switcher to switch between your personal account and your organizations. Click **"Manage Members"** to add or remove team members at any time. Organization dashboard with member management # Getting a Tinfoil API key Source: https://docs.tinfoil.sh/get-api-key Step-by-step guide for generating a Tinfoil API key ### Step 1: Navigate to Homepage Start by visiting [https://tinfoil.sh/](https://tinfoil.sh/) and click the **"Get Started"** button in the top right corner. Tinfoil homepage ### Step 2: Log In or Sign Up Enter your credentials to log in to your account. If you don't have an account yet, click "Sign up" to create one or use one of the social connections. Login page ### Step 3: Access Your Dashboard Once logged in, click the **"Dashboard"** button in the top right corner. User dashboard ### Step 4: Activate Private Inference Find the **Private Inference** product card and click the **"Activate"** button to activate your API subscription. Access API dashboard ### Step 5: API Checkout Complete the checkout by entering your payment details. You're only charged based on usage. API checkout page ### Step 6: Create a New API Key After completing checkout, you'll be taken back to the dashboard. Click the **"API Keys"** tab in the top navigation bar to go to the API key management page. Use descriptive names like "Production", "Development", or "Mobile App" to easily identify your keys later. API key management page ### Step 7: Name and Create Your Key Type a name for your key and click the **"Create API Key"** button. Naming and creating your API key ### Step 8: Copy Your API Key Your new API key will appear in the list. Click the **copy** button to copy it, or the **eye** button to reveal the full key. Copy your API key ### Step 9: Manage Billing Navigate to the **Billing** tab to view your current usage, set a monthly spend limit, or manage your subscriptions. Billing and subscription management # Document processing Source: https://docs.tinfoil.sh/guides/document-processing Learn how to use Tinfoil for secure and private document processing. ## Document Processing API Tinfoil's document processing service extracts structured Markdown from uploaded documents — including PDFs, DOCX, PPTX, XLSX, HTML, CSV, and images. The entire service runs inside a [secure enclave](/containers/overview), and the VLM used for OCR and visual extraction also runs in its own secure enclave — so your documents are never exposed to any operator. Born-digital PDFs are parsed using [MuPDF](https://mupdf.com/) inside a sandboxed subprocess with no network access, environment variables, or filesystem; scanned pages and images are sent to the VLM for OCR. You can use document processing in two ways: * Call `/v1/convert/file` directly when you want extracted Markdown (or page images) back from the document service. * Send a base64-encoded file through the OpenAI-compatible `/v1/responses` or `/v1/chat/completions` APIs. Tinfoil privately converts the attachment and forwards either Markdown (for text-only models) or per-page Markdown plus page images (for vision-capable models) to the model. You can override the default with the optional [`tinfoil_mode`](#4-override-the-pdf-processing-mode) field. **Current scope:** OpenAI-compatible file input support currently accepts base64 `file_data` only. `file_id` and the `/v1/files` upload flow are not supported. ### 1. Convert A Document Directly The document processing endpoint accepts `multipart/form-data` requests at `/v1/convert/file`. Upload one or more files with field name `files`. You can control extraction behavior with the `mode` query parameter: | Mode | Description | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `text` (default) | Markdown from the text layer. VLM OCR only for scanned pages. | | `vision` | Text plus VLM OCR for scanned pages and VLM visual descriptions (tables, charts, diagrams, formulas) for born-digital pages. | | `images` | Per-page text plus page images as base64 PNG. No VLM. | | `raw` | Text layer only. No VLM, no image rendering. | | `vlm` | Full-page VLM OCR on every page. | ```javascript JavaScript theme={"dark"} import { SecureClient } from 'tinfoil' import fs from 'fs' const client = new SecureClient() const fileBuffer = fs.readFileSync('doc.pdf') const blob = new Blob([fileBuffer], { type: 'application/pdf' }) const formData = new FormData() formData.append('files', blob, 'doc.pdf') // Default mode — fast, no VLM for born-digital PDFs const response = await client.fetch('/v1/convert/file', { method: 'POST', body: formData, }) const result = await response.json() // result.document.md_content contains the converted Markdown console.log(result.document.md_content) ``` ```rust Rust theme={"dark"} // Requires reqwest with the "multipart" feature enabled. use tinfoil::Client; use tokio::fs; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let pdf_bytes = fs::read("doc.pdf").await?; let part = reqwest::multipart::Part::bytes(pdf_bytes) .file_name("doc.pdf") .mime_str("application/pdf")?; let form = reqwest::multipart::Form::new() .part("files", part) .text("model", "doc-upload") .text("to_formats", "md") .text("from_formats", "pdf") .text("pipeline", "standard"); let secure = client.secure_client(); let response = secure.http_client()? .post(format!("{}/v1/convert/file", secure.base_url())) .bearer_auth(secure.api_key()) .multipart(form) .send() .await? .error_for_status()? .json::() .await?; println!("{}", response["document"]["md_content"].as_str().unwrap_or("")); Ok(()) } ``` The response includes the extracted Markdown content. When uploading a single file, the result is in `document`; for multiple files, results are in a `documents` array: ```json theme={"dark"} { "document": { "md_content": "# Title\n\nExtracted text..." }, "status": "success", "processing_time": 1.23 } ``` In `images` mode, each page includes its extracted text, a base64-encoded PNG, and a scanned/born-digital flag: ```json theme={"dark"} { "document": { "md_content": "# Title\n\nExtracted text...", "pages": [ { "page": 1, "text": "# Title\n\nExtracted text...", "image": "iVBORw0KGgo...", "is_scanned": false }, { "page": 2, "text": "", "image": "iVBORw0KGgo...", "is_scanned": true }, { "page": 3, "text": "## Conclusion\n\n...", "image": "iVBORw0KGgo...", "is_scanned": false } ] }, "status": "success", "processing_time": 2.45 } ``` `text` mirrors the per-page slice of `md_content`; pure scans come back with an empty `text` field. When uploading multiple files, the response uses `documents` (an array) instead of `document`: ```json theme={"dark"} { "documents": [ { "md_content": "# First document..." }, { "md_content": "# Second document..." } ], "status": "success", "processing_time": 3.21 } ``` #### Pairing `images` Mode With A Vision Model For a vision-capable model (e.g. `qwen3-vl-30b`, `gemma4-31b`), interleave each page's text and image and wrap the raw `image` base64 as a data URI: ```javascript theme={"dark"} const convertResp = await client.fetch('/v1/convert/file?mode=images', { method: 'POST', body: formData, }) const { document } = await convertResp.json() const content = [{ type: 'text', text: '[Attached file: doc.pdf]' }] for (const p of document.pages) { const label = p.is_scanned ? `Page ${p.page} (scanned):` : `Page ${p.page}:` content.push({ type: 'text', text: p.text ? `${label}\n${p.text}` : label }) content.push({ type: 'image_url', image_url: { url: `data:image/png;base64,${p.image}` }, }) } content.push({ type: 'text', text: 'What is this PDF about?' }) const visionResp = await client.fetch('/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'qwen3-vl-30b', messages: [{ role: 'user', content }], }), }) ``` This recovers visual elements that text extraction discards — illustrations, diagrams, color-coding, page decorations, and other layout cues — while still giving the model accurate, parser-extracted text. When you instead attach a PDF as base64 `file_data` on `/v1/responses` or `/v1/chat/completions` with a vision-capable model, Tinfoil performs this same per-page interleave automatically. ### 2. Use File Inputs With The Responses API If you want OpenAI-compatible file attachments, send a base64-encoded file in an `input_file` content part on `/v1/responses`. ```javascript JavaScript theme={"dark"} import { SecureClient } from 'tinfoil' import fs from 'fs' const client = new SecureClient() const fileData = fs.readFileSync('doc.pdf').toString('base64') const response = await client.fetch('/v1/responses', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-oss-120b', input: [ { role: 'user', content: [ { type: 'input_file', filename: 'doc.pdf', file_data: `data:application/pdf;base64,${fileData}`, }, { type: 'input_text', text: 'Summarize this document in 3 bullet points.', }, ], }, ], }), }) const result = await response.json() console.log(result.output_text) ``` ```rust Rust theme={"dark"} use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use serde_json::json; use tinfoil::Client; use tokio::fs; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let file_data = B64.encode(fs::read("doc.pdf").await?); let secure = client.secure_client(); let response = secure.http_client()? .post(format!("{}/v1/responses", secure.base_url())) .bearer_auth(secure.api_key()) .json(&json!({ "model": "gpt-oss-120b", "input": [{ "role": "user", "content": [ { "type": "input_file", "filename": "doc.pdf", "file_data": format!("data:application/pdf;base64,{}", file_data), }, { "type": "input_text", "text": "Summarize this document in 3 bullet points." } ] }] })) .send() .await? .error_for_status()? .json::() .await?; println!("{}", response["output_text"].as_str().unwrap_or("")); Ok(()) } ``` For binary formats such as PDF, DOCX, PPTX, and images, Tinfoil processes the attachment through the private document-processing backend before forwarding it to the model. By default the router picks the best shape per attachment: | Routed model | Default PDF / image behavior | | -------------- | -------------------------------------------------- | | Vision-capable | Per-page interleaved Markdown **and** page images. | | Text-only | Markdown only, for speed. | You can check whether a model is vision-capable via the `multimodal` field on `GET /v1/models`. DOCX, PPTX, XLSX, HTML, CSV, and plain text attachments are always forwarded as extracted Markdown regardless of the routed model. You can override the default per attachment with [`tinfoil_mode`](#4-override-the-pdf-processing-mode). ### 3. Use File Inputs With Chat Completions The OpenAI-compatible Chat Completions shape uses `type: "file"` with a nested `file` object. ```javascript JavaScript theme={"dark"} import { SecureClient } from 'tinfoil' import fs from 'fs' const client = new SecureClient() const fileData = fs.readFileSync('doc.pdf').toString('base64') const response = await client.fetch('/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'gpt-oss-120b', messages: [ { role: 'user', content: [ { type: 'file', file: { filename: 'doc.pdf', file_data: `data:application/pdf;base64,${fileData}`, }, }, { type: 'text', text: 'Summarize this document in 3 bullet points.', }, ], }, ], }), }) const result = await response.json() console.log(result.choices[0].message.content) ``` ```rust Rust theme={"dark"} use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use serde_json::json; use tinfoil::Client; use tokio::fs; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let file_data = B64.encode(fs::read("doc.pdf").await?); let body = client.chat_relaxed().request() .model("gpt-oss-120b") .messages([json!({ "role": "user", "content": [ { "type": "file", "file": { "filename": "doc.pdf", "file_data": format!("data:application/pdf;base64,{}", file_data), } }, { "type": "text", "text": "Summarize this document in 3 bullet points." } ] })]); let response = client.chat_relaxed().create(body).await?; println!("{}", response.content().unwrap_or("")); Ok(()) } ``` ### 4. Override The PDF Processing Mode Set the optional Tinfoil-specific `tinfoil_mode` field directly on the file content part to override the auto-default — for example to force VLM full-page OCR on a low-quality scan: ```javascript theme={"dark"} const response = await client.fetch('/v1/responses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'gpt-oss-120b', input: [{ role: 'user', content: [ { type: 'input_file', filename: 'scan.pdf', file_data: `data:application/pdf;base64,${fileData}`, tinfoil_mode: 'vlm', }, { type: 'input_text', text: 'Extract every line as plain text.' }, ], }], }), }) ``` The router consumes the field and strips it before the request is forwarded, so the upstream model never sees it. | Value | Behavior | | ---------------- | --------------------------------------------------------------------------------------------------- | | `auto` (default) | `images` for vision-capable models, `text` for text-only models. | | `text` | Markdown from the text layer; VLM OCR only on scanned pages. | | `vision` | Markdown plus VLM visual descriptions for figures, charts, and tables. | | `images` | Per-page interleaved Markdown and images. Requires a vision-capable model; returns `400` otherwise. | | `raw` | Text layer only. No VLM, no image rendering. | | `vlm` | Full-page VLM OCR on every page. Highest quality, slowest. | `tinfoil_mode` only affects PDF and image attachments; for DOCX, PPTX, XLSX, HTML, CSV, and plain text the field has no effect. `tinfoil_mode` is a Tinfoil-specific extension and is not understood by OpenAI's API. If your code needs to target both Tinfoil and OpenAI from the same request body, omit the field. On Chat Completions the field nests inside the `file` object alongside `filename` and `file_data`: ```json theme={"dark"} { "type": "file", "file": { "filename": "doc.pdf", "file_data": "data:application/pdf;base64,...", "tinfoil_mode": "text" } } ``` ### Supported Formats | Format | Extraction | | --------------------------- | ---------------------------- | | PDF (born-digital) | MuPDF text layer to Markdown | | PDF (scanned) | VLM OCR | | DOCX, PPTX, HTML, XLSX, CSV | Server-side parsers | | Images | VLM OCR | | Markdown, text, JSON, XML | Passthrough | ### Errors And Limits Per request: up to 10 files, 50 MB each, `multipart/form-data` only. All non-2xx responses are `{"error": ""}`. `/health` reflects the state of the different pipeline elements: ```json theme={"dark"} { "status": "ok", "router": true, "sidecar": true, "vlm": true } { "status": "degraded", "router": true, "sidecar": true, "vlm": false } ``` ### Attestation The document upload API uses the same attestation mechanism as other Tinfoil services. Use `SecureClient` (as shown above) to verify attestation automatically. Experience document upload in our private chat interface with real-time privacy verification. View the open-source configuration for Tinfoil's confidential document processing service. # Image processing Source: https://docs.tinfoil.sh/guides/image-processing Learn how to use Tinfoil for image processing with multimodal models. ## Image Upload **Multimodal Models Only:** Image processing requires models with vision capabilities. Currently, **Qwen3-VL 30B** and **Gemma 4 31B** support image inputs. Other models (Llama, GPT-OSS) are text-only and cannot process images. See the [vision models](/models/vision) and [chat models](/models/chat) pages for complete model specifications and multimodal capabilities. ### How It Works Image processing works through the chat/completions endpoint using base64-encoded images. Images are sent as data URLs in the message content alongside your text prompt. ### Converting Images to Base64 There are several ways to convert your images to base64 format: ```bash Command Line theme={"dark"} # Convert image to base64 base64 -i image.jpg -o image_base64.txt # Or use it directly in your terminal base64 image.jpg | pbcopy # Copies to clipboard on macOS ``` ```python Python theme={"dark"} import base64 def image_to_base64(image_path): with open(image_path, "rb") as image_file: return base64.b64encode(image_file.read()).decode('utf-8') # Usage base64_string = image_to_base64("image.jpg") ``` ```javascript JavaScript theme={"dark"} import fs from 'fs'; function imageToBase64(imagePath) { const imageBuffer = fs.readFileSync(imagePath); return imageBuffer.toString('base64'); } // Usage const base64String = imageToBase64('image.jpg'); ``` ```go Go theme={"dark"} package main import ( "encoding/base64" "fmt" "io" "log" "os" ) func imageToBase64(imagePath string) (string, error) { file, err := os.Open(imagePath) if err != nil { return "", err } defer file.Close() data, err := io.ReadAll(file) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(data), nil } func main() { // Usage base64String, err := imageToBase64("image.jpg") if err != nil { log.Fatal(err) } fmt.Println("Base64 encoded image:", base64String[:50], "...") // Print first 50 chars } ``` ```rust Rust theme={"dark"} // The Tinfoil Rust SDK ships ImageUrl::from_path which reads the file, // detects the MIME type, and returns a ready-to-use ImageUrl with a // base64 data URL. Use it directly in chat messages — no manual encoding. use tinfoil::chat::ImageUrl; use tinfoil::multimodal::ImageUrlExt; let image = ImageUrl::from_path("image.jpg")?; println!("Base64 data URL: {}...", &image.url[..50.min(image.url.len())]); ``` ### API Usage ```python Python theme={"dark"} from tinfoil import TinfoilAI import base64 import mimetypes from pathlib import Path # Initialize client with Qwen3-VL vision model client = TinfoilAI( api_key="", ) # Read and encode image with proper MIME type detection image_path = "image.jpg" with open(image_path, "rb") as image_file: base64_image = base64.b64encode(image_file.read()).decode('utf-8') # Determine MIME type mime_type, _ = mimetypes.guess_type(image_path) if not mime_type or not mime_type.startswith('image/'): # Fallback based on file extension ext = Path(image_path).suffix.lower() mime_type_map = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp' } mime_type = mime_type_map.get(ext, 'image/jpeg') # Create completion with multimodal content response = client.chat.completions.create( model="qwen3-vl-30b", messages=[{ "role": "user", "content": [ {"type": "text", "text": "What's in this image?"}, { "type": "image_url", "image_url": { "url": f"data:{mime_type};base64,{base64_image}" } } ] }] ) print(response.choices[0].message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; import fs from 'fs'; import path from 'path'; // Initialize client with Qwen3-VL vision model const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); // Read and encode image const imagePath = 'image.jpg'; const imageBuffer = fs.readFileSync(imagePath); const base64Image = imageBuffer.toString('base64'); // Determine MIME type const ext = path.extname(imagePath).toLowerCase(); const mimeTypes: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp' }; const mimeType = mimeTypes[ext] || 'image/jpeg'; try { const response = await client.chat.completions.create({ model: 'qwen3-vl-30b', messages: [ { role: 'user', content: [ { type: 'text', text: "What's in this image?" }, { type: 'image_url', image_url: { url: `data:${mimeType};base64,${base64Image}` } } ] as any } ] }); console.log(response.choices[0]?.message?.content); } catch (error) { console.error('Image processing failed:', error); } ``` ```go Go theme={"dark"} package main import ( "context" "encoding/base64" "fmt" "log" "mime" "os" "path/filepath" "strings" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) func main() { // Initialize client with Qwen3-VL vision model client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { log.Fatal(err) } // Read and encode image imagePath := "image.jpg" imageData, err := os.ReadFile(imagePath) if err != nil { log.Fatal(err) } base64Image := base64.StdEncoding.EncodeToString(imageData) // Determine MIME type ext := filepath.Ext(imagePath) mimeType := mime.TypeByExtension(ext) // Robust fallback for MIME type if mimeType == "" || !strings.HasPrefix(mimeType, "image/") { // Fallback based on file extension mimeTypeMap := map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", } if fallbackType, ok := mimeTypeMap[strings.ToLower(ext)]; ok { mimeType = fallbackType } else { mimeType = "image/jpeg" // final fallback } } // Create completion with multimodal content imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Image) response, err := client.Chat.Completions.New(context.TODO(), openai.ChatCompletionNewParams{ Model: "qwen3-vl-30b", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{ openai.TextContentPart("What's in this image?"), openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ URL: imageURL, }), }), }, }) if err != nil { log.Fatal(err) } fmt.Println(response.Choices[0].Message.Content) } ``` ```rust Rust theme={"dark"} use tinfoil::chat::{ ChatCompletionRequestMessage, ChatCompletionRequestMessageContentPartImage, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent, ChatCompletionRequestUserMessageContentPart, CreateChatCompletionRequestArgs, ImageUrl, }; use tinfoil::multimodal::ImageUrlExt; use tinfoil::Client; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; // ImageUrl::from_path handles base64 encoding and MIME detection. let image = ImageUrl::from_path("image.jpg")?; let request = CreateChatCompletionRequestArgs::default() .model("qwen3-vl-30b") .messages(vec![ChatCompletionRequestMessage::User( ChatCompletionRequestUserMessage { content: ChatCompletionRequestUserMessageContent::Array(vec![ ChatCompletionRequestUserMessageContentPart::Text( "What's in this image?".into(), ), ChatCompletionRequestUserMessageContentPart::ImageUrl( ChatCompletionRequestMessageContentPartImage { image_url: image }, ), ]), name: None, }, )]) .build()?; let response = client.chat().create(request).await?; println!("{}", response.choices[0].message.content.as_deref().unwrap_or("")); Ok(()) } ``` ### Best Practices 1. **Image Size**: For optimal performance, resize large images before processing (recommended max: 4096x4096) 2. **Base64 Encoding**: Ensure proper base64 encoding and include the correct MIME type in the data URL 3. **Multiple Images**: You can include multiple images in a single chat completion by adding multiple image\_url objects to the content array 4. **Compression**: Consider compressing large images to reduce payload size and improve response times # MCP web-search server Source: https://docs.tinfoil.sh/guides/mcp-websearch Call Tinfoil's confidential websearch MCP server directly over attested HTTP. ## Overview The [`confidential-websearch`](https://github.com/tinfoilsh/confidential-websearch) MCP server runs inside a Tinfoil secure enclave and exposes two tools: `search` (web search via [Exa](https://exa.ai), a Zero Data Retention provider) and `fetch` (headless-browser page rendering via Cloudflare Browser Rendering). Both return results back into the enclave before being handed to the caller, so queries and page content are only ever decrypted inside attested code. Use this guide when you want to drive the web-search tool loop yourself — for example, from a custom agent runtime or any MCP-compatible client. If you just want a model to search the web as part of a chat completion, prefer the higher-level [Web search guide](/guides/web-search) which wraps this same server behind `web_search_options` and the `web_search` Responses tool. Optional safety filters run inside the same enclave: * **PII filter** blocks outgoing search queries that contain sensitive identifiers before they reach Exa. * **Prompt-injection filter** drops search results and fetched pages that contain instructions aimed at hijacking a downstream model. Both filters are opt-in per request. ## Endpoint and transport | Item | Value | | --------------- | ----------------------------------------------------------------------------------------- | | Production host | `websearch.tinfoil.sh` | | MCP endpoint | `POST https://websearch.tinfoil.sh/mcp` | | Health check | `GET https://websearch.tinfoil.sh/health` | | Transport | MCP Streamable HTTP | | Source repo | [`tinfoilsh/confidential-websearch`](https://github.com/tinfoilsh/confidential-websearch) | Any MCP-compliant client can introspect the tool surface by calling `tools/list` on the endpoint. ## Authentication Send your Tinfoil API key as a bearer token: ``` Authorization: Bearer $TINFOIL_API_KEY ``` This is an attested enclave. For connection-time trust guarantees, connect through a client that verifies the enclave's attestation and pins its TLS certificate or HPKE key — see [Calling from a Tinfoil SDK](#calling-from-a-tinfoil-sdk) below. ## Tools ### `search` Run a web search and return ranked results with titles, URLs, snippets, and publication dates. #### Arguments | Name | Type | Required | Default | Description | | ----------------------- | --------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `query` | string | yes | - | Natural language search query. Max \~400 characters. | | `max_results` | int | no | `8` | Number of results to return. The upstream provider applies its own ceiling. | | `content_mode` | string | no | `highlights` | `highlights` returns key excerpts relevant to the query. `text` returns the full page text as markdown. | | `max_content_chars` | int | no | `700` | Per-result character budget for the snippet or text returned in each hit. | | `user_location_country` | string | no | - | ISO 3166-1 alpha-2 country code (e.g. `US`, `GB`, `DE`) used to bias results toward that locale. | | `allowed_domains` | string\[] | no | - | Only return results whose host matches one of these domains. | | `excluded_domains` | string\[] | no | - | Drop results from these domains. | | `category` | string | no | - | Restrict to one of: `company`, `people`, `research paper`, `news`, `personal site`, `financial report`. `company` and `people` are incompatible with date filters and `excluded_domains`. | | `start_published_date` | string | no | - | ISO-8601 date. Only include results published at or after this instant. | | `end_published_date` | string | no | - | ISO-8601 date. Only include results published at or before this instant. | | `max_age_hours` | int | no | - | Cache freshness control. `0` forces a livecrawl on every result (freshest, slowest). `-1` disables livecrawl (cache-only, fastest). Omit for the upstream default. | #### Response ```json theme={"dark"} { "results": [ { "title": "string", "url": "string", "content": "string", "favicon": "string (optional)", "published_date": "string (optional, ISO-8601)" } ] } ``` ### `fetch` Fetch one or more web pages via Cloudflare Browser Rendering and return them as clean markdown. Use this after `search` when you need the full page beyond the returned snippet. #### Arguments | Name | Type | Required | Default | Description | | ----------------- | --------- | -------- | ------- | ---------------------------------------------------------------------------------------- | | `urls` | string\[] | yes | - | One or more HTTP/HTTPS URLs. Capped at 20 per request. | | `allowed_domains` | string\[] | no | - | If set, reject any URL whose host is not in this list before it is sent to the renderer. | The server rejects unsafe fetch targets before they reach Cloudflare (localhost, internal hostnames, private IP ranges, unsupported URL schemes). #### Response `results` preserves input order and includes both completed and failed fetches. `pages` is the convenience subset of `results` whose `status` is `completed`. ```json theme={"dark"} { "pages": [ { "url": "string", "content": "string (markdown)" } ], "results": [ { "url": "string", "status": "completed | failed", "content": "string (present when status=completed)", "error": "string (present when status=failed)" } ] } ``` ## Per-request safety headers The server's safety filters have env-configured defaults, but an integrator can override them on a single request by forwarding either header on the `POST /mcp` call. Missing, empty, or unparseable values fall back to the server default, so a malformed header cannot silently weaken filtering. | Header | Values | Effect | | -------------------------------- | --------------- | ----------------------------------------------------------------------------------------------- | | `X-Tinfoil-Tool-PII-Check` | `true`, `false` | Override the PII filter on outgoing search queries for this request only. | | `X-Tinfoil-Tool-Injection-Check` | `true`, `false` | Override the prompt-injection filter on search results and fetched pages for this request only. | See [PII protection](/guides/web-search#pii-protection) and [Prompt-injection protection](/guides/web-search#prompt-injection-protection) in the Web search guide for what each filter blocks. ## Calling from a Tinfoil SDK Because the websearch server is an attested enclave, you should verify its attestation before trusting traffic to it. Every Tinfoil SDK exposes a `SecureClient` that does this for you: it verifies the enclave's signed release against the `tinfoilsh/confidential-websearch` GitHub repo, pins the attested transport, and returns a verified HTTP client you can hand to any MCP client transport. The example below pairs `tinfoil-js` with the TypeScript MCP SDK to call `search`. The same pattern works from the [Python](/sdk/python-sdk), [Go](/sdk/go-sdk), and [Swift](/sdk/swift-sdk) SDKs — point their `SecureClient` at `websearch.tinfoil.sh` with `configRepo = tinfoilsh/confidential-websearch` and plug the verified client into the MCP SDK of your choice. ```typescript theme={"dark"} import { SecureClient } from "tinfoil"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const secure = new SecureClient({ enclaveURL: "https://websearch.tinfoil.sh", configRepo: "tinfoilsh/confidential-websearch", }); await secure.ready(); const transport = new StreamableHTTPClientTransport( new URL(secure.getBaseURL() + "/mcp"), { fetch: secure.fetch, requestInit: { headers: { Authorization: `Bearer ${process.env.TINFOIL_API_KEY}`, }, }, }, ); const client = new Client({ name: "websearch-example", version: "0.1.0" }); await client.connect(transport); const result = await client.callTool({ name: "search", arguments: { query: "confidential computing attestation 2026", max_results: 5, }, }); console.log(result); ``` If verification fails, `ready()` rejects before any request is sent. See the [JavaScript SDK guide](/sdk/javascript-sdk) for transport-mode options (EHBP vs. TLS pinning) and proxying. ## Limits and defaults | Item | Value | | ---------------------------------- | ----------------- | | `search.max_results` default | 8 | | `search.max_content_chars` default | 700 | | `fetch.urls` per-call cap | 20 | | Cloudflare per-URL timeout | 60s | | Rendered markdown truncation | 50,000 characters | ## See also * [Web search guide](/guides/web-search) — the higher-level wrapper that drives this server for you on `/v1/chat/completions` and `/v1/responses`. * [`tinfoilsh/confidential-websearch`](https://github.com/tinfoilsh/confidential-websearch) — source and release history for the MCP server itself. * [JavaScript SDK](/sdk/javascript-sdk) — attested `SecureClient` used in the example above. # Add customer-managed keys with passkeys Source: https://docs.tinfoil.sh/guides/passkey-kit Protect user encryption keys with passkeys so your server only ever stores encrypted key material. tinfoilsh/tinfoil-passkey-kit Passkey Kit is an open-source SDK for JavaScript and Swift that lets an application encrypt data with a key only the user can recover. The user's passkey — the same credential behind Face ID, Touch ID, or a browser passkey prompt — protects the encryption key. Your server stores only an encrypted copy of the key, so neither you nor anyone with access to your infrastructure can read the user's data. This pattern is often called customer-managed keys or user-held encryption. It is the foundation for end-to-end encrypted features such as private notes, messages, health records, or files, where the product promise is "we cannot read your data." The JavaScript and Swift packages share the same wire format: a key protected on the web unlocks in an iOS app and vice versa, as long as both clients use the same configuration and the passkey syncs between devices through a passkey provider such as iCloud Keychain. [Tinfoil Chat](https://chat.tinfoil.sh) uses Passkey Kit to protect its end-to-end chat encryption keys across its web and iOS clients. ## How it works Passkey Kit builds on one property of modern passkeys: the WebAuthn PRF (pseudo-random function) extension. When the user approves a passkey prompt, the authenticator can return a 32-byte secret that is stable for that passkey but never leaves the ceremony unapproved. That secret becomes the root of the key hierarchy: 1. **Passkey ceremony.** The user approves a passkey prompt (biometric or device PIN). The authenticator returns the PRF output, a deterministic 32-byte secret tied to that passkey. 2. **Key derivation.** Passkey Kit derives a key-encryption key (KEK) from the PRF output with HKDF-SHA-256. The KEK never leaves the client. 3. **Key wrapping.** The KEK encrypts your application's 32-byte content-encryption key (CEK) with AES-256-GCM. The result is a wrapped bundle of plain JSON-safe strings. 4. **Server storage.** Your server stores the wrapped bundle. It is ciphertext: without the passkey, it cannot be turned back into the CEK. 5. **Recovery.** On any device with the passkey, the user approves a prompt, the same PRF output comes back, the same KEK is derived, and the CEK is unwrapped locally. Your application uses the CEK to encrypt and decrypt its own data. Passkey Kit only manages how the CEK is protected and recovered; what you encrypt with it is up to you. Because the PRF output is deterministic, Passkey Kit can also cache it on the device so repeat unlocks skip the passkey prompt. Caching is optional and configurable. Passkey Kit does not replace your account system, authenticate API requests, encrypt your application records, or store wrapped keys for you. It handles the passkey ceremonies and the key protection; your app provides identity, storage, and the encryption of your actual data. ## What you will build This guide covers how to: 1. Install and configure Passkey Kit in JavaScript and Swift. 2. Configure a shared WebAuthn relying party. 3. Create a passkey and protect a fresh CEK with it. 4. Store only wrapped key bundles on your server. 5. Recover the CEK on the user's device. 6. Let users enroll additional passkeys for the same key. 7. Use the same wrapped-key format across web and iOS clients. ## Prerequisites * A domain you control, such as `example.com` * An authenticated backend that can store wrapped key bundles per user * A stable opaque user ID of at most 64 UTF-8 bytes * For web: a browser with WebAuthn PRF support (recent Chrome, Safari, and Edge on platforms with a screen lock) * For iOS: iOS 18 or later WebAuthn requires a secure context. Use HTTPS in production. Browsers allow `localhost` for local development, but native passkeys require a configured associated domain. ## Install the package Install the published npm package: ```bash theme={"dark"} npm install @tinfoilsh/passkey-kit ``` In Xcode, select **File > Add Package Dependencies** and enter: ```text theme={"dark"} https://github.com/tinfoilsh/tinfoil-passkey-kit ``` Select the `TinfoilPasskeyKit` product and add it to your app target. For a `Package.swift` consumer, add the package and product directly: ```swift theme={"dark"} dependencies: [ .package( url: "https://github.com/tinfoilsh/tinfoil-passkey-kit", from: "0.1.0" ) ], targets: [ .target( name: "YourApp", dependencies: [ .product( name: "TinfoilPasskeyKit", package: "tinfoil-passkey-kit" ) ] ) ] ``` ## Choose shared protocol settings Three values define your application's key-derivation domain. Every client must use identical bytes for all three, or keys wrapped on one client cannot be unwrapped on another: * **Relying-party ID (RP ID)**: the domain that owns the passkeys, usually your registrable domain. Passkeys created for one RP ID are invisible to other RP IDs. * **PRF salt input**: an application-chosen string mixed into the PRF evaluation. Different salts produce unrelated PRF outputs from the same passkey. * **HKDF info**: a purpose-binding string for the KEK derivation. Different info strings produce unrelated KEKs from the same PRF output. For a new application, choose domain-specific values and keep them stable. If you change either protocol string after enrollment, existing wrapped keys can no longer be recovered with the newly derived key. This guide uses: | Setting | Value | | ---------------- | --------------------------- | | Relying-party ID | `example.com` | | PRF salt input | `example-key-encryption-v1` | | HKDF info | `example-passkey-kek-v1` | ## Configure the relying party A web page can use `example.com` as its RP ID when its origin is `example.com` or a subdomain such as `app.example.com`. Add the Associated Domains capability to the app target, then include the relying-party domain: ```text theme={"dark"} webcredentials:example.com ``` Serve this file from `https://example.com/.well-known/apple-app-site-association`: ```json theme={"dark"} { "webcredentials": { "apps": ["APP_ID_PREFIX.com.example.app"] } } ``` Replace `APP_ID_PREFIX` with the prefix from the built app's `application-identifier` entitlement, and replace `com.example.app` with your bundle identifier. The prefix is often your Apple team ID, but it can differ for older or transferred apps. Serve the file as `application/json` over HTTPS without a redirect. ## Create the client Configure the same protocol strings in each client. ```typescript theme={"dark"} import { createPasskeyKit } from "@tinfoilsh/passkey-kit"; export const passkeyKit = createPasskeyKit({ rpId: "example.com", rpName: "Example App", prfSaltInput: "example-key-encryption-v1", hkdfInfo: "example-passkey-kek-v1", }); ``` The default storage adapter caches reusable raw PRF output as plaintext in `localStorage`. A same-origin script or XSS attacker that reads it can derive the KEK. Pass `storage: null` unless you accept that risk, or provide a `StorageAdapter` backed by storage appropriate for your threat model. ```swift theme={"dark"} import Foundation import TinfoilPasskeyKit @MainActor func makePasskeyKit() -> PasskeyKit { let store = KeychainPasskeyStateStore( service: "example.com", account: "com.example.passkey-prf", localCredentialIdKey: "com.example.local-passkey-id" ) return PasskeyKit( configuration: PasskeyKitConfiguration( rpId: "example.com", rpName: "Example App", prfSalt: Data("example-key-encryption-v1".utf8), hkdfInfo: Data("example-passkey-kek-v1".utf8), stateStore: store ) ) } ``` `KeychainPasskeyStateStore` protects cached PRF output with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Pass `stateStore: nil` if every unlock should require a new passkey ceremony. You can also check support before offering the feature. `isPrfSupported()` returns an optimistic capability check for the current browser or device: ```typescript theme={"dark"} if (await passkeyKit.isPrfSupported()) { showPasskeySetupOption(); } ``` ## Enroll and wrap a CEK Enrollment is the first-time setup for a user: generate a fresh CEK, create a PRF-capable passkey, and wrap the CEK under the passkey-derived KEK. `enroll` runs all three steps in one call. Use the same stable opaque user ID in every client. Use an email address only as the user-facing account name, not as the stable ID. The examples use application-provided `user`, `api`, `AppUser`, `useCek`, and `useCEK` placeholders. Connect them to your account, networking, and encryption layers. ```typescript theme={"dark"} import { generateCek } from "@tinfoilsh/passkey-kit"; import { passkeyKit } from "./passkey-kit"; const cek = generateCek(); const enrollment = await passkeyKit.enroll({ user: { id: user.id, name: user.email, displayName: user.displayName, }, cek, }); if (enrollment) { await api.saveWrappedCek(enrollment.wrappedCek); useCek(cek); } ``` `enroll` returns `null` when the user cancels the ceremony. ```swift theme={"dark"} import TinfoilPasskeyKit @MainActor func enroll(user: AppUser, kit: PasskeyKit) async throws { let cek = try PasskeyCrypto.generateCEK() let enrollment = try await kit.enroll( user: PasskeyUser( id: user.id, name: user.email, displayName: user.displayName ), cek: cek ) try await api.saveWrappedCEK(enrollment.wrappedCEK) useCEK(cek) } ``` Swift reports cancellation as `PasskeyKitError.userCancelled`. Persist only the wrapped bundle. Never send the raw CEK or PRF output to your server. ## Store the wrapped bundle Store one or more wrapped bundles for each account. Both SDKs serialize the same fields: ```json theme={"dark"} { "credentialId": "base64url-credential-id", "kekIvHex": "12-byte-iv-as-lowercase-hex", "wrappedKeyHex": "ciphertext-and-16-byte-gcm-tag-as-lowercase-hex" } ``` Your server should: * Authenticate every read and write request. * Associate each bundle with the authenticated account. * Preserve all three fields without transforming their encoding. * Allow multiple bundles so users can enroll more than one passkey. * Treat bundle deletion and replacement as security-sensitive operations. The wrapped bundle is ciphertext, but its credential ID is account metadata. Apply the same access controls you use for other private account data. The bundle alone cannot recover the CEK, so your backend can store it without gaining access to the customer's raw key. ## Unlock the CEK Unlocking is the returning-user flow: load the account's wrapped bundles, then let the matching passkey unwrap the CEK. The user approves one passkey prompt; whichever credential they authenticate with selects the matching bundle. Try the local cache first if your application enables it. A cached unlock reuses the stored PRF output and skips the passkey prompt entirely. ```typescript theme={"dark"} import type { WrappedCek } from "@tinfoilsh/passkey-kit"; import { passkeyKit } from "./passkey-kit"; const bundles: WrappedCek[] = await api.listWrappedCeks(); const cached = await passkeyKit.unlockWithCachedPrf(bundles); const unlocked = cached ?? (await passkeyKit.unlock(bundles)); if (unlocked) { useCek(unlocked.cek); } ``` `unlock` returns `null` when the user cancels or no usable credential is available. ```swift theme={"dark"} import TinfoilPasskeyKit @MainActor func unlock(kit: PasskeyKit) async throws { let bundles: [WrappedCEK] = try await api.listWrappedCEKs() if let cached = kit.unlockWithCachedPRF(bundles) { useCEK(cached.cek) return } let unlocked = try await kit.unlock(bundles) useCEK(unlocked.cek) } ``` Use `.immediatelyAvailable` when you want to check local credentials without presenting the full cross-device passkey interface: ```swift theme={"dark"} let unlocked = try await kit.unlock( bundles, mode: .immediatelyAvailable ) ``` ## Add another passkey Each wrapped bundle ties the CEK to one passkey. To let a user unlock from more devices or survive losing a passkey, enroll additional passkeys that each wrap the same CEK. Every passkey for an account must wrap the same existing CEK. Do not call `generateCek` or `generateCEK` when adding another passkey, or the account will have bundles that recover different keys. First unlock the current CEK, then create the additional passkey and wrap that same CEK: ```typescript theme={"dark"} async function addPasskey(existingCek: Uint8Array) { const prfResult = await passkeyKit.createPasskey({ id: user.id, name: user.email, displayName: user.displayName, }); if (prfResult) { const wrappedCek = await passkeyKit.wrapWithPrfResult( prfResult, existingCek, ); await api.saveWrappedCek(wrappedCek); } } ``` ```swift theme={"dark"} import Foundation import TinfoilPasskeyKit @MainActor func addPasskey(existingCEK: Data, kit: PasskeyKit) async throws { let prfResult = try await kit.createPasskey( for: PasskeyUser( id: user.id, name: user.email, displayName: user.displayName ) ) let wrappedCEK = try kit.wrapWithPRFResult( prfResult, cek: existingCEK ) try await api.saveWrappedCEK(wrappedCEK) } ``` ## Handle passkey failures Classify errors by type or enum case. Do not branch on localized messages. ```typescript theme={"dark"} import { PasskeyTimeoutError, PrfNotSupportedError, } from "@tinfoilsh/passkey-kit"; try { const unlocked = await passkeyKit.unlock(bundles); if (unlocked) useCek(unlocked.cek); } catch (error) { if (error instanceof PrfNotSupportedError) { showUnsupportedAuthenticatorMessage(); } else if (error instanceof PasskeyTimeoutError) { showProviderTimeoutMessage(); } else { throw error; } } ``` ```swift theme={"dark"} do { let unlocked = try await kit.unlock(bundles) useCEK(unlocked.cek) } catch PasskeyKitError.userCancelled { return } catch PasskeyKitError.prfNotSupported { showUnsupportedAuthenticatorMessage() } catch { throw error } ``` ## Clear local state Clear cached PRF output when the user signs out or removes local access. ```typescript JavaScript theme={"dark"} passkeyKit.clearLocalState(); ``` ```swift iOS theme={"dark"} kit.clearLocalState() ``` Clearing local state does not delete the passkey from the platform credential manager or remove wrapped bundles from your server. ## Test cross-platform recovery Use a staging account and complete these checks before production: 1. Enroll on the web and store the wrapped bundle. 2. Load the same bundle on iOS and unlock it with the synced passkey. 3. Enroll on iOS and unlock the resulting bundle on the web. 4. Confirm both clients recover identical 32-byte CEK values. 5. Confirm cancellation does not create or delete a server bundle. 6. Confirm a tampered IV or ciphertext fails authentication. 7. Confirm signing out clears the local PRF cache. If cross-platform unlock fails, first compare the RP ID, PRF salt bytes, HKDF info bytes, credential ID, and bundle encodings on both clients. ## Security checklist * Keep the PRF output and unwrapped CEK on the client. Send only ciphertext and wrapped bundles to your server. * Use authenticated, authorized endpoints for wrapped bundles. * Use HTTPS and a stable RP ID. * Never change protocol strings for existing data. * Prefer platform-protected storage over plaintext browser storage when your threat model requires at-rest protection, or disable the PRF cache. * Support multiple passkeys and a deliberate recovery flow. A user with a single passkey and no other recovery path loses their data if the passkey is lost. * Treat passkey removal and wrapped-bundle deletion as sensitive operations. * Remember the boundary: this design keeps your server out of the key path, but the client code that handles the unwrapped CEK is still trusted. Protect your software supply chain and release process. ## Next steps Review the JavaScript and Swift implementations, tests, and protocol constants. View published versions and npm installation details. Read the specification behind the passkey-derived secret used for key protection. See customer-managed keys in production across web and iOS clients. # Processing audio Source: https://docs.tinfoil.sh/guides/processing-audio Speech-to-text, realtime streaming transcription, and text-to-speech. Tinfoils technical security guarantees make transcribing or voicing sensitive content (medical notes, legal drafts, internal communications) safe in a way no conventional cloud audio API can match. File transcription and text-to-speech are OpenAI-compatible, so existing clients work by changing only the base URL and key. Realtime transcription is only partially OpenAI-compatible (details below). See [Audio models](/models/audio) for the full list of models. ## Realtime transcription [Voxtral Mini Realtime](/models/audio) (`voxtral-mini-4b-realtime`) streams speech-to-text over a WebSocket: you send PCM16 audio chunks as the user speaks, and partial transcripts stream back word-by-word. This is only available through the `tinfoil-js` WebSocket right now. [Contact us](mailto:contact@tinfoil.sh) if you need it in another SDK. ``` wss://inference.tinfoil.sh/v1/realtime ``` Authenticate with your API key as a `Bearer` token in the `Authorization` header. The [`tinfoil-js`](https://github.com/tinfoilsh/tinfoil-js) SDK handles this for you and pins the TLS connection to the attested enclave key: ```typescript theme={"dark"} import { TinfoilAI } from "tinfoil"; const client = new TinfoilAI({ apiKey: "" }); const rt = await client.realtime({ model: "voxtral-mini-4b-realtime" }); rt.on("session.created", (event) => console.log(event)); rt.send({ type: "input_audio_buffer.append", audio: base64AudioChunk }); ``` This is Node.js only: browsers can't expose TLS certificate details, so the connection can't be pinned to the attested key, and verified realtime isn't available in the browser yet. If this is a problem, [contact us](mailto:contact@tinfoil.sh). ### OpenAI Realtime-compatible mode Connect with `?intent=transcription` and the endpoint speaks the OpenAI Realtime transcription dialect. Existing OpenAI Realtime clients should work by changing only the URL and key: ``` wss://inference.tinfoil.sh/v1/realtime?intent=transcription ``` The session flow: 1. The server sends `session.created`. 2. Optionally send `session.update` declaring your input format (defaults to PCM16 mono at 24kHz; declare `{"type": "audio/pcm", "rate": 16000}` if you capture at 16kHz). The server replies `session.updated`. 3. Stream audio with `input_audio_buffer.append` (base64 PCM16). Partial transcripts arrive as `conversation.item.input_audio_transcription.delta` events. 4. Send `input_audio_buffer.commit` to end the utterance. The server replies with `input_audio_buffer.committed` and a `conversation.item.input_audio_transcription.completed` event carrying the final transcript. You can then stream the next utterance on the same connection. ```typescript theme={"dark"} import { TinfoilAI } from "tinfoil"; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY! }); const rt = await client.realtime({ model: "voxtral-mini-4b-realtime" }); // Partial transcripts stream in as deltas; the final transcript arrives on completion. rt.on("conversation.item.input_audio_transcription.delta", (event) => { process.stdout.write(event.delta); }); rt.on("conversation.item.input_audio_transcription.completed", (event) => { console.log("\nFinal:", event.transcript); }); // Stream PCM16 audio as base64 chunks, then commit to end the utterance. for (const chunk of pcm16Chunks) { rt.send({ type: "input_audio_buffer.append", audio: chunk.toString("base64") }); } rt.send({ type: "input_audio_buffer.commit" }); ``` **Turn Detection**: This only works in push to talk dictation mode. The openAI spec uses `server_vad` turn detection: the server detects silence, and emits a `speech_started`/`stopped` event. Our mode does not do that. See more in the [realtime model repo](https://github.com/tinfoilsh/confidential-realtime-models) ### Native mode Connecting with `?model=voxtral-mini-4b-realtime` instead speaks the leaner vLLM dialect: flat `transcription.delta` events, a `session.update` carrying only `{"model": ...}`, client commits with a `final` flag, and `transcription.done` (with usage) after the final commit. Audio must be PCM16 mono at 16kHz. One transcription session per connection. ## File transcription For recorded audio on disk, transcribe the whole file in one request over the OpenAI-compatible `/v1/audio/transcriptions` endpoint. The `model` is required; use [`voxtral-small-24b`](/models/audio). ```python Python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI(api_key="") with open("meeting.mp3", "rb") as audio: result = client.audio.transcriptions.create( model="voxtral-small-24b", file=audio, ) print(result.text) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from "tinfoil"; import fs from "node:fs"; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const result = await client.audio.transcriptions.create({ model: "voxtral-small-24b", file: fs.createReadStream("meeting.mp3"), }); console.log(result.text); ``` ## Text-to-speech Synthesize speech over the OpenAI-compatible `/v1/audio/speech` endpoint, which returns WAV audio. Use [`qwen3-tts`](/models/audio) for low-latency speech or [`voxtral-tts`](/models/audio) for the larger multilingual model. Both require a `voice`. ```python Python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI(api_key="") response = client.audio.speech.create( model="qwen3-tts", voice="serena", input="Your audio never leaves the enclave.", ) with open("speech.wav", "wb") as f: f.write(response.read()) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from "tinfoil"; import fs from "node:fs"; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const response = await client.audio.speech.create({ model: "qwen3-tts", voice: "serena", input: "Your audio never leaves the enclave.", }); fs.writeFileSync("speech.wav", Buffer.from(await response.arrayBuffer())); ``` Voices are the upstream models' default preset voices. [`qwen3-tts`](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice) has 9: `aiden`, `dylan`, `eric`, `ono_anna`, `ryan`, `serena`, `sohee`, `uncle_fu`, `vivian`. [`voxtral-tts`](https://huggingface.co/mistralai/Voxtral-4B-TTS-2603) has 20: `neutral_female`, `neutral_male`, `casual_female`, `casual_male`, `cheerful_female`, `ar_male`, `de_female`, `de_male`, `es_female`, `es_male`, `fr_female`, `fr_male`, `hi_female`, `hi_male`, `it_female`, `it_male`, `nl_female`, `nl_male`, `pt_female`, `pt_male`. # Proxy encrypted requests through your backend Source: https://docs.tinfoil.sh/guides/proxy-server Keep your API key server-side while preserving end-to-end encryption between clients and the inference enclave. When building production applications with Tinfoil, you typically need to keep your `TINFOIL_API_KEY` on your backend — exposing it to client browsers would let anyone use your API quota. At the same time, you want prompts and completions encrypted end-to-end to the attested enclave, not just to your server. A proxy server running on your backend solves this. It sits between your client and the Tinfoil enclave, adding your API key, authenticating users, tracking usage, or applying rate limiting — while the [Encrypted HTTP Body Protocol (EHBP)](/resources/ehbp) ensures request and response bodies stay encrypted end-to-end. EHBP encrypts at the application layer using [HPKE](https://datatracker.ietf.org/doc/html/rfc9180), completely separate from TLS, so your proxy can read and modify HTTP headers without ever seeing the plaintext data. Every Tinfoil SDK also automatically verifies that the HPKE key comes from an attested secure enclave. ## Client Setup The credential passed to the client is a token for your proxy, not your backend's `TINFOIL_API_KEY`. Your proxy should authenticate the token, remove it, and add `TINFOIL_API_KEY` before forwarding the request. JavaScript, Python, Go, and Swift route both inference and attestation requests through your proxy when you set its URL as both the API base URL and attestation bundle URL. The SDK still verifies the attestation bundle client-side regardless of where it was fetched from. When the SDK sends requests through a proxy (i.e., `baseURL` differs from the enclave URL), it includes the enclave URL in the `X-Tinfoil-Enclave-Url` header. Your proxy should use this header to determine where to forward requests, ensuring the encrypted payload reaches the same enclave that the client verified. ```bash theme={"dark"} npm install tinfoil ``` ```typescript theme={"dark"} import { TinfoilAI } from "tinfoil"; const client = new TinfoilAI({ bearerToken: "", baseURL: "https://your-proxy-server.com/v1/", attestationBundleURL: "https://your-proxy-server.com", }); const response = await client.chat.completions.create({ model: "", messages: [{ role: "user", content: "Hello!" }], }); ``` ```bash theme={"dark"} pip install tinfoil ``` ```python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI( api_key="", base_url="https://your-proxy-server.com/v1/", attestation_bundle_url="https://your-proxy-server.com", ) response = client.chat.completions.create( model="", messages=[{"role": "user", "content": "Hello!"}], ) ``` ```bash theme={"dark"} go get github.com/tinfoilsh/tinfoil-go ``` ```go theme={"dark"} import ( "log" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) client, err := tinfoil.NewClientWithOptions( tinfoil.WithBaseURL("https://your-proxy-server.com/v1/"), tinfoil.WithAttestationBundleURL("https://your-proxy-server.com"), tinfoil.WithOpenAIOptions(option.WithAPIKey("")), ) if err != nil { log.Fatal(err) } ``` ```swift theme={"dark"} import TinfoilAI let client = try await TinfoilAI.create( apiKey: "", baseURL: "https://your-proxy-server.com", attestationBundleURL: "https://your-proxy-server.com" ) ``` ```bash theme={"dark"} cargo add tinfoil --git https://github.com/tinfoilsh/tinfoil-rs --branch feat/ehbp-proxy ``` ```rust theme={"dark"} let client = tinfoil::Client::new_with_proxy( "inference.tinfoil.sh", "tinfoilsh/confidential-model-router", "", "https://your-proxy-server.com", ) .await?; ``` Rust verifies the enclave directly, then sends encrypted inference requests through the proxy. The client therefore needs network access to both the enclave and your proxy; it does not use the proxy's `/attestation` endpoint. Rust proxy support is currently available from the `feat/ehbp-proxy` branch and will move to `main` in the next SDK release. `baseURL` or `base_url` must point at your proxy, not a Tinfoil enclave. Include `/v1/` for JavaScript, Python, and Go. Swift and Rust append API paths themselves. ## Proxy Server Setup Your proxy's job is to preserve the [EHBP protocol headers](/resources/ehbp#protocol-headers) that coordinate encryption between client and enclave, add your authentication credentials, and forward the encrypted payload. No special cryptographic libraries are needed — the proxy never participates in encryption or decryption. While we provide an example implementation in Go below, the proxy pattern works with any language that can handle HTTP requests. The [example repository](https://github.com/tinfoilsh/encrypted-request-proxy-example) provides a complete reference implementation with a Go proxy and a TypeScript client. ### Required Endpoints Your proxy needs to implement these routes: | Path | Method | Description | | -------------- | ------ | ------------------------------------------------------------------------------------------- | | `/attestation` | GET | Proxy to `https://atc.tinfoil.sh/attestation` for JavaScript, Python, Go, and Swift clients | | `/v1/*` | POST | Forward API requests to the enclave URL from the `X-Tinfoil-Enclave-Url` header | The example below forwards every `/v1/` path so chat completions, responses, embeddings, audio, and other supported APIs use the same encrypted route. ### Required Headers to Preserve The EHBP protocol uses specific headers to coordinate encryption keys between the client and enclave. Your proxy must forward these headers unchanged in both directions. For requests, you need to preserve `Ehbp-Encapsulated-Key` (the HPKE encapsulated key, hex-encoded, 64 characters). For responses, you need to preserve `Ehbp-Response-Nonce` (the 32-byte nonce used in response key derivation, hex-encoded, 64 characters). Understanding the [body framing format](/resources/ehbp#body-framing) isn't necessary for proxy implementation, but it helps explain why these headers are critical—they contain the cryptographic material needed for key derivation and decryption. ### CORS Configuration If your proxy will be called from browser-based applications, you'll need to configure CORS headers to allow the browser to send and read the encryption headers. The key requirement is exposing the EHBP headers in both directions: ``` Access-Control-Allow-Origin: * Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: Accept, Authorization, Content-Type, Ehbp-Encapsulated-Key, X-Tinfoil-Enclave-Url Access-Control-Expose-Headers: Ehbp-Response-Nonce ``` ### Implementation Example ```go theme={"dark"} package main import ( "io" "log" "net/http" "net/url" "os" "strings" ) var ( ehbpRequestHeaders = []string{"Ehbp-Encapsulated-Key"} ehbpResponseHeaders = []string{"Ehbp-Response-Nonce"} ) func main() { http.HandleFunc("/v1/", proxyHandler) http.HandleFunc("/attestation", attestationHandler) log.Println("Proxy listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } // attestationHandler proxies attestation bundle requests to the Tinfoil ATC func attestationHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } resp, err := http.Get("https://atc.tinfoil.sh/attestation") if err != nil { http.Error(w, "Failed to fetch attestation bundle", http.StatusBadGateway) return } defer resp.Body.Close() if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } // proxyHandler forwards encrypted API requests to the enclave func proxyHandler(w http.ResponseWriter, r *http.Request) { // Set CORS headers w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, Ehbp-Encapsulated-Key, X-Tinfoil-Enclave-Url") w.Header().Set("Access-Control-Expose-Headers", "Ehbp-Response-Nonce") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } // Get upstream URL from the X-Tinfoil-Enclave-Url header upstreamBase := r.Header.Get("X-Tinfoil-Enclave-Url") if upstreamBase == "" { http.Error(w, "X-Tinfoil-Enclave-Url header required", http.StatusBadRequest) return } if !isAllowedEnclave(upstreamBase) { http.Error(w, "Unapproved enclave URL", http.StatusBadRequest) return } upstreamURL := upstreamBase + r.URL.Path // Create upstream request req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, upstreamURL, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if contentType := r.Header.Get("Content-Type"); contentType != "" { req.Header.Set("Content-Type", contentType) } if accept := r.Header.Get("Accept"); accept != "" { req.Header.Set("Accept", accept) } // Add your API key apiKey := os.Getenv("TINFOIL_API_KEY") if apiKey == "" { http.Error(w, "TINFOIL_API_KEY not set", http.StatusInternalServerError) return } req.Header.Set("Authorization", "Bearer "+apiKey) // Copy encryption headers copyHeaders(req.Header, r.Header, ehbpRequestHeaders...) // Forward request resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp.Body.Close() // Copy encryption headers from response copyHeaders(w.Header(), resp.Header, ehbpResponseHeaders...) if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } // Handle chunked transfer encoding for streaming responses if te := resp.Header.Get("Transfer-Encoding"); te != "" { w.Header().Set("Transfer-Encoding", te) w.Header().Del("Content-Length") } w.WriteHeader(resp.StatusCode) // Stream response with flushing for SSE/streaming support if flusher, ok := w.(http.Flusher); ok { buf := make([]byte, 1024) for { n, err := resp.Body.Read(buf) if n > 0 { w.Write(buf[:n]) flusher.Flush() } if err != nil { break } } return } io.Copy(w, resp.Body) } func isAllowedEnclave(rawURL string) bool { upstream, err := url.Parse(rawURL) if err != nil || upstream.Scheme != "https" || upstream.User != nil { return false } host := upstream.Hostname() return host == "tinfoil.sh" || strings.HasSuffix(host, ".tinfoil.sh") } func copyHeaders(dst, src http.Header, keys ...string) { for _, key := range keys { if value := src.Get(key); value != "" { dst.Set(key, value) } } } ``` ## Usage Metrics for Billing Since EHBP encrypts request and response bodies, your proxy cannot read token counts from the JSON. Tinfoil provides usage metrics via HTTP headers so you can track usage and bill your users. To enable this, add the `X-Tinfoil-Request-Usage-Metrics: true` header when forwarding requests to the enclave. Tinfoil returns token counts in the `X-Tinfoil-Usage-Metrics` response header for non-streaming requests, or as an HTTP trailer after the body completes for streaming requests. The format is: ``` prompt=,completion=,total= ``` For example: ```go theme={"dark"} // When building the upstream request: req.Header.Set("X-Tinfoil-Request-Usage-Metrics", "true") // After receiving the response: // Non-streaming: read from response header if usage := resp.Header.Get("X-Tinfoil-Usage-Metrics"); usage != "" { log.Printf("Usage: %s", usage) // "prompt=67,completion=42,total=109" } // Streaming: read from trailer after body is consumed io.Copy(w, resp.Body) if usage := resp.Trailer.Get("X-Tinfoil-Usage-Metrics"); usage != "" { log.Printf("Usage: %s", usage) } ``` See the [example repository](https://github.com/tinfoilsh/encrypted-request-proxy-example#usage-metrics-for-billing) for a complete implementation. ## Custom Headers Beyond the required EHBP headers, you can use custom HTTP headers to build your own application-level protocols between the client and proxy. Since EHBP only encrypts bodies, headers remain visible to the proxy — which means you can authenticate users, track requests, implement rate limiting, or pass feature flags without any of that metadata reaching the enclave. ### Request Headers The JavaScript SDK's lower-level `SecureClient` lets you send arbitrary headers and inspect the raw response: ```typescript theme={"dark"} import { SecureClient } from "tinfoil"; const secureClient = new SecureClient({ baseURL: "https://your-proxy-server.com/", attestationBundleURL: "https://your-proxy-server.com", }); await secureClient.ready(); const response = await secureClient.fetch("/v1/chat/completions", { method: "POST", headers: { Authorization: "Bearer ", "Content-Type": "application/json", "X-User-ID": "user-123", "X-Request-ID": crypto.randomUUID(), "Your-Custom-Request-Header": "custom-value", }, body: JSON.stringify({ model: "", messages: [{ role: "user", content: "Hello!" }], }), }); ``` Your proxy can then read and strip these headers for logging, routing decisions, or authentication checks before forwarding the encrypted request to the enclave: ```go theme={"dark"} // In your proxy server if customHeader := r.Header.Get("Your-Custom-Request-Header"); customHeader != "" { log.Printf("Custom request header received: %s", customHeader) // These headers are not forwarded to the enclave } ``` Just remember to add any custom headers you use to your CORS `Access-Control-Allow-Headers` configuration so browsers can send them. ### Response Headers Your proxy can add custom headers to responses that the client can read. This is useful for communicating rate limit information, cost tracking, or request IDs for debugging: ```go theme={"dark"} // In your proxy server w.Header().Set("Your-Custom-Response-Header", "response-value") w.Header().Set("X-Rate-Limit-Remaining", "100") ``` The client can then access these headers: ```typescript theme={"dark"} // Optional: Read custom headers from the response const customHeader = response.headers.get("Your-Custom-Response-Header"); const remaining = response.headers.get("X-Rate-Limit-Remaining"); ``` As with request headers, you'll need to expose custom response headers in your CORS `Access-Control-Expose-Headers` configuration for browsers to access them. ## Security Considerations While EHBP encrypts the request and response bodies, the HTTP headers remain visible throughout the proxy chain. This design is intentional—it's what allows proxies to route and manage requests—but it has important security implications. Never expose your `TINFOIL_API_KEY` to client applications. The entire purpose of the proxy architecture is to keep this key on your backend. * **Use HTTPS in production.** While request and response bodies are encrypted at the application layer, HTTP headers (including custom headers like user IDs) are only protected by transport-layer encryption. * **Allowlist enclave destinations.** Treat `X-Tinfoil-Enclave-Url` as untrusted input and only forward to approved HTTPS Tinfoil enclave origins. This prevents clients from using your proxy to reach arbitrary hosts. * **Validate and sanitize custom headers** from clients. Treat them as untrusted input — check formats, guard against injection attacks, and authenticate before trusting client-provided values. * **Always preserve and forward the EHBP headers** (`Ehbp-Encapsulated-Key` for requests, `Ehbp-Response-Nonce` for responses). Dropping or modifying them will cause decryption to fail. ## How It Works The data flow shows how encryption is maintained throughout the request lifecycle: ```mermaid theme={"dark"} sequenceDiagram participant Client participant Proxy as Your Proxy Server participant ATC as Tinfoil ATC participant Enclave as Tinfoil Enclave Client->>Proxy: GET /attestation Proxy->>ATC: GET /attestation ATC-->>Proxy: Attestation bundle Proxy-->>Client: Attestation bundle Note over Client: Verifies enclave attestation
Extracts encryption keys Note over Client: Encrypts body with
enclave's public key Client->>Proxy: Encrypted request
+ X-Tinfoil-Enclave-Url
+ EHBP headers Note over Proxy: Reads headers only
Cannot decrypt body Proxy->>Enclave: Encrypted request
+ TINFOIL_API_KEY
+ EHBP headers Note over Enclave: Decrypts request body
Processes inference
Encrypts response Enclave-->>Proxy: Encrypted response
+ EHBP headers Note over Proxy: Reads headers only
Cannot decrypt body Proxy-->>Client: Encrypted response
+ EHBP headers Note over Client: Decrypts response body ``` This diagram shows the proxied attestation flow used by JavaScript, Python, Go, and Swift. Rust performs attestation directly against the enclave before routing encrypted inference requests through your proxy. ## Next Steps For a complete working example, check out the [encrypted-request-proxy-example](https://github.com/tinfoilsh/encrypted-request-proxy-example) repository. It includes a Go proxy implementation with streaming support, a TypeScript browser client demonstrating the `SecureClient`, and custom header handling examples. Go proxy server with TypeScript browser client Deep dive into the EHBP specification # Reasoning effort Source: https://docs.tinfoil.sh/guides/reasoning Control how much a reasoning model thinks before answering using the reasoning_effort parameter. ## Reasoning effort Reasoning models can spend extra tokens thinking through a problem before they answer. The OpenAI-compatible `reasoning_effort` parameter controls how much of that thinking the model does. Higher effort generally improves quality on hard tasks at the cost of more latency and output tokens. Pass `reasoning_effort` as a string on the chat completions request. Use a [reasoning-capable model](#supported-values-per-model) and a value it supports. ```python Python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI(api_key="") response = client.chat.completions.create( model="", reasoning_effort="medium", messages=[ {"role": "user", "content": "What is 17 * 23? Think step by step."} ], ) print(response.choices[0].message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const response = await client.chat.completions.create({ model: '', reasoning_effort: 'medium', messages: [ { role: 'user', content: 'What is 17 * 23? Think step by step.' } ] }); console.log(response.choices[0]?.message?.content); ``` ```go Go theme={"dark"} package main import ( "context" "fmt" "log" "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) func main() { client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { log.Fatal(err) } response, err := client.Chat.Completions.New(context.TODO(), openai.ChatCompletionNewParams{ Model: "", ReasoningEffort: "medium", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage("What is 17 * 23? Think step by step."), }, }) if err != nil { log.Fatal(err) } fmt.Println(response.Choices[0].Message.Content) } ``` ```rust Rust theme={"dark"} use tinfoil::chat::{ ChatCompletionRequestMessage, ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent, CreateChatCompletionRequestArgs, ReasoningEffort, }; use tinfoil::Client; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let request = CreateChatCompletionRequestArgs::default() .model("") .reasoning_effort(ReasoningEffort::Medium) .messages(vec![ChatCompletionRequestMessage::User( ChatCompletionRequestUserMessage { content: ChatCompletionRequestUserMessageContent::Text( "What is 17 * 23? Think step by step.".to_string(), ), name: None, }, )]) .build()?; let response = client.chat().create(request).await?; println!("{}", response.choices[0].message.content.as_deref().unwrap_or("")); Ok(()) } ``` ```swift Swift theme={"dark"} import TinfoilAI import OpenAI let client = try await TinfoilAI.create( apiKey: ProcessInfo.processInfo.environment["TINFOIL_API_KEY"] ?? "" ) let chatQuery = ChatQuery( messages: [ .user(.init(content: .string("What is 17 * 23? Think step by step."))) ], model: "", reasoningEffort: .medium ) let response = try await client.chats(query: chatQuery) print(response.choices.first?.message.content ?? "No response") ``` ```bash CLI theme={"dark"} tinfoil http post https://inference.tinfoil.sh/v1/chat/completions \ -e inference.tinfoil.sh \ -r tinfoilsh/confidential-model-router \ -H "Authorization: Bearer $TINFOIL_API_KEY" \ -H "Content-Type: application/json" \ -b '{"model": "", "reasoning_effort": "medium", "messages": [{"role": "user", "content": "What is 17 * 23? Think step by step."}]}' ``` ```bash cURL theme={"dark"} curl -X POST https://inference.tinfoil.sh/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "", "reasoning_effort": "medium", "messages": [{"role": "user", "content": "What is 17 * 23? Think step by step."}] }' ``` Swift's `ReasoningEffort` enum covers `none`, `minimal`, `low`, `medium`, and `high`; pass other values with `.customValue("xhigh")`. Rust's `ReasoningEffort` enum covers `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. ## Supported values per model The accepted values differ by model. Sending an unsupported value returns a `400` error. | Model | Type | Supported `reasoning_effort` values | | ------------------------ | ------------- | ---------------------------------------------------------- | | `glm-5-2` | Chat | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | | `gemma4-31b` | Chat / Vision | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | | `qwen3-vl-30b` | Vision | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | | `gpt-oss-120b` | Chat | `low`, `medium`, `high` | | `gpt-oss-safeguard-120b` | Safety | `low`, `medium`, `high` | On the standard scale, `none` disables reasoning and effort increases up to `max`. The `gpt-oss` models use OpenAI's Harmony response format, which defines only `low`, `medium`, and `high`; sending `none`, `minimal`, `xhigh`, or `max` to these models returns a `400` error. `llama3-3-70b` is not a reasoning model. It accepts the parameter without error but does not produce a reasoning trace. ## Reading the reasoning trace The model's thinking is returned in the `reasoning` field of the response message, separate from the final answer in `content`. Higher effort produces a longer trace. ```python Python theme={"dark"} response = client.chat.completions.create( model="", reasoning_effort="high", messages=[{"role": "user", "content": "Why is the sky blue?"}], ) message = response.choices[0].message print("Reasoning:", message.reasoning) print("Answer:", message.content) ``` ```bash cURL theme={"dark"} curl -s -X POST https://inference.tinfoil.sh/v1/chat/completions \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "", "reasoning_effort": "high", "messages": [{"role": "user", "content": "Why is the sky blue?"}] }' | jq '.choices[0].message | {reasoning, content}' ``` Available models and their supported values can change. Query the [models endpoint](/sdk/direct-api-access) to see which models report `"reasoning": true`. # Structured outputs Source: https://docs.tinfoil.sh/guides/structured-outputs Learn how to use structured outputs with JSON schema validation for reliable data extraction and API integration. ## Structured Outputs Structured outputs ensure that model responses match specific formats like JSON schemas, regex patterns, or predefined choices. Tinfoil uses vLLM's guided decoding to constrain outputs by filtering next-token predictions, guaranteeing valid formats without post-processing. Tinfoil supports structured outputs through vLLM. Use `response_format` with `json_schema` type for JSON outputs, or `structured_outputs` for `choice` and `regex` constraints. In Python, pass it via `extra_body`. In JavaScript, pass it directly on the request body (the OpenAI Node SDK does not support `extra_body`). ### Benefits * **Format Enforcement**: Token-level filtering ensures outputs match your exact format * **Type Safety**: Works with Pydantic (Python), Zod (TypeScript), and native types in Go * **No Post-Processing**: Outputs are guaranteed valid * **Deterministic**: Next-token prediction is constrained to produce only valid tokens * **Multiple Backends**: Supports xgrammar and guidance backends For complete documentation, see the [vLLM Structured Outputs Guide](https://docs.vllm.ai/en/latest/features/structured_outputs/) and the [vLLM blog post on structured decoding](https://blog.vllm.ai/2025/01/14/struct-decode-intro.html). ### Quick Start Here are basic examples for each structured output type: #### Choice Restrict output to a predefined list: ```python Python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI(api_key="") response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "Classify this sentiment: vLLM is wonderful!"} ], extra_body={"structured_outputs": {"choice": ["positive", "negative"]}} ) print(response.choices[0].message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const response = await client.chat.completions.create({ model: '', messages: [ { role: 'user', content: 'Classify this sentiment: vLLM is wonderful!' } ], structured_outputs: { choice: ['positive', 'negative'] } } as any); console.log(response.choices[0].message.content); ``` ```go Go theme={"dark"} package main import ( "context" "fmt" "log" "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) func main() { client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { log.Fatal(err) } response, err := client.Chat.Completions.New( context.TODO(), openai.ChatCompletionNewParams{ Model: "", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage("Classify this sentiment: vLLM is wonderful!"), }, }, option.WithJSONSet("structured_outputs", map[string]interface{}{ "choice": []string{"positive", "negative"}, }), ) if err != nil { log.Fatal(err) } fmt.Println(response.Choices[0].Message.Content) } ``` ```rust Rust theme={"dark"} use serde_json::json; use tinfoil::Client; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let body = client.chat_relaxed().request() .model("") .messages([json!( {"role": "user", "content": "Classify this sentiment: vLLM is wonderful!"} )]) .structured_outputs_choice(["positive", "negative"]); let response = client.chat_relaxed().create(body).await?; println!("{}", response.content().unwrap_or("")); Ok(()) } ``` #### Regex Enforce regex patterns for formatted outputs: ```python Python theme={"dark"} from tinfoil import TinfoilAI client = TinfoilAI(api_key="") response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "Generate an example email address for Alan Turing, who works in Enigma. End in .com."} ], extra_body={"structured_outputs": {"regex": r"\w+@\w+\.com"}} ) print(response.choices[0].message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const response = await client.chat.completions.create({ model: '', messages: [ { role: 'user', content: 'Generate an example email address for Alan Turing, who works in Enigma. End in .com.' } ], structured_outputs: { regex: '\\w+@\\w+\\.com' } } as any); console.log(response.choices[0].message.content); ``` ```go Go theme={"dark"} package main import ( "context" "fmt" "log" "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) func main() { client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { log.Fatal(err) } response, err := client.Chat.Completions.New( context.TODO(), openai.ChatCompletionNewParams{ Model: "", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage("Generate an example email address for Alan Turing, who works in Enigma. End in .com."), }, }, option.WithJSONSet("structured_outputs", map[string]interface{}{ "regex": `\w+@\w+\.com`, }), ) if err != nil { log.Fatal(err) } fmt.Println(response.Choices[0].Message.Content) } ``` ```rust Rust theme={"dark"} use serde_json::json; use tinfoil::Client; #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let body = client.chat_relaxed().request() .model("") .messages([json!( {"role": "user", "content": "Generate an example email address for Alan Turing, who works in Enigma. End in .com."} )]) .structured_outputs_regex(r"\w+@\w+\.com"); let response = client.chat_relaxed().create(body).await?; println!("{}", response.content().unwrap_or("")); Ok(()) } ``` #### JSON Use `response_format` with `json_schema` type for reliable JSON generation: ```python Python theme={"dark"} from pydantic import BaseModel from enum import Enum from tinfoil import TinfoilAI class CarType(str, Enum): sedan = "sedan" suv = "SUV" truck = "Truck" coupe = "Coupe" class CarDescription(BaseModel): brand: str model: str car_type: CarType client = TinfoilAI(api_key="") json_schema = CarDescription.model_json_schema() response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "Output a JSON object with the brand, model, and car_type of the most iconic car from the 90's."} ], response_format={ "type": "json_schema", "json_schema": { "name": "car-description", "schema": json_schema } } ) print(response.choices[0].message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); const jsonSchema = { type: 'object', properties: { brand: { type: 'string' }, model: { type: 'string' }, car_type: { type: 'string', enum: ['sedan', 'SUV', 'Truck', 'Coupe'] } }, required: ['brand', 'model', 'car_type'] }; const response = await client.chat.completions.create({ model: '', messages: [ { role: 'user', content: "Output a JSON object with the brand, model, and car_type of the most iconic car from the 90's." } ], response_format: { type: 'json_schema', json_schema: { name: 'car-description', schema: jsonSchema } } }); console.log(response.choices[0].message.content); ``` ```go Go theme={"dark"} package main import ( "context" "encoding/json" "fmt" "log" "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/tinfoilsh/tinfoil-go" ) type CarType string const ( Sedan CarType = "sedan" SUV CarType = "SUV" Truck CarType = "Truck" Coupe CarType = "Coupe" ) type CarDescription struct { Brand string `json:"brand"` Model string `json:"model"` CarType CarType `json:"car_type"` } func main() { client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { log.Fatal(err) } jsonSchema := map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "brand": map[string]interface{}{"type": "string"}, "model": map[string]interface{}{"type": "string"}, "car_type": map[string]interface{}{"type": "string", "enum": []string{"sedan", "SUV", "Truck", "Coupe"}}, }, "required": []string{"brand", "model", "car_type"}, } response, err := client.Chat.Completions.New( context.TODO(), openai.ChatCompletionNewParams{ Model: "", Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage("Output a JSON object with the brand, model, and car_type of the most iconic car from the 90's."), }, }, option.WithJSONSet("response_format", map[string]interface{}{ "type": "json_schema", "json_schema": map[string]interface{}{ "name": "car-description", "schema": jsonSchema, }, }), ) if err != nil { log.Fatal(err) } var car CarDescription json.Unmarshal([]byte(response.Choices[0].Message.Content), &car) fmt.Printf("Brand: %s, Model: %s, Type: %s\n", car.Brand, car.Model, car.CarType) } ``` ```rust Rust theme={"dark"} use serde::Deserialize; use serde_json::json; use tinfoil::Client; #[derive(Debug, Deserialize)] struct CarDescription { brand: String, model: String, car_type: String, } #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let json_schema = json!({ "type": "object", "properties": { "brand": {"type": "string"}, "model": {"type": "string"}, "car_type": {"type": "string", "enum": ["sedan", "SUV", "Truck", "Coupe"]} }, "required": ["brand", "model", "car_type"] }); let body = client.chat_relaxed().request() .model("") .messages([json!( {"role": "user", "content": "Output a JSON object with the brand, model, and car_type of the most iconic car from the 90's."} )]) .response_format_json_schema("car-description", json_schema); let response = client.chat_relaxed().create(body).await?; let content = response.content().unwrap_or(""); let car: CarDescription = serde_json::from_str(content)?; println!("Brand: {}, Model: {}, Type: {}", car.brand, car.model, car.car_type); Ok(()) } ``` **Prompt explicitly for JSON.** While `structured_outputs` enforces valid JSON structure, the model produces more reliable results when your prompt explicitly requests JSON output and describes the expected fields. For example, use "Output a JSON object with..." rather than just "Generate..." ### Advanced Features #### Whitespace Pattern Override Customize whitespace handling in JSON decoding by combining `response_format` with `extra_body`: ```python theme={"dark"} response = client.chat.completions.create( model="", messages=[...], response_format={ "type": "json_schema", "json_schema": { "name": "my-schema", "schema": json_schema } }, extra_body={ "structured_outputs": { "whitespace_pattern": r"[ \t\n]*" } } ) ``` ### Complex Nested Schemas Build complex nested structures with Pydantic: ```python Python theme={"dark"} from pydantic import BaseModel from tinfoil import TinfoilAI class Address(BaseModel): street: str city: str state: str zip_code: str class Employee(BaseModel): name: str age: int email: str | None addresses: list[Address] class Company(BaseModel): name: str founded_year: int employees: list[Employee] headquarters: Address client = TinfoilAI(api_key="") response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "Output a JSON object for a company profile. The company is named TechCorp and has 2 employees. Include the company name, founded_year, employees (each with name, age, email, and addresses), and headquarters address."} ], response_format={ "type": "json_schema", "json_schema": { "name": "company", "schema": Company.model_json_schema() } } ) import json company_data = json.loads(response.choices[0].message.content) print(f"Company: {company_data['name']}, Employees: {len(company_data['employees'])}") ``` ### Best Practices **Markdown-Wrapped Responses:** Some models may wrap JSON responses in markdown code blocks (` ```json ... ``` `). Strip the formatting before parsing the JSON. **Use Low Temperature for Deterministic Outputs** ```python theme={"dark"} response = client.chat.completions.create( model="", temperature=0.1, messages=[...], response_format={ "type": "json_schema", "json_schema": { "name": "my-schema", "schema": schema } } ) ``` **Validate Responses** ```python theme={"dark"} from pydantic import ValidationError try: parsed = MySchema.model_validate_json(response.choices[0].message.content) except ValidationError as e: print(f"Validation failed: {e}") ``` **Enable Streaming for Large Responses** ```python theme={"dark"} response = client.chat.completions.create( model="", messages=[...], response_format={ "type": "json_schema", "json_schema": { "name": "my-schema", "schema": schema } }, stream=True ) for chunk in response: if chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True) ``` ### Additional Resources * [vLLM Structured Outputs Guide](https://docs.vllm.ai/en/latest/features/structured_outputs/) - Official documentation * [BoundaryML](https://boundaryml.com/) - Reliable structured outputs from any model * [Pydantic Documentation](https://docs.pydantic.dev/) - Python schema validation * [Zod Documentation](https://zod.dev/) - TypeScript schema validation # Tool calling Source: https://docs.tinfoil.sh/guides/tool-calling Learn how to use function calling capabilities with Tinfoil's AI models for dynamic integrations. ## Function Calling Function calling (also known as tool calling) lets AI models invoke external tools and APIs — fetching real-time data, performing calculations, or integrating with your existing systems. **Model Performance:** Most chat models support function calling. **GLM-5.2** is recommended for agentic workflows and complex tool calling scenarios. ### Basic Example Here's a simple example of how to implement function calling with a weather API: ```python Python theme={"dark"} from tinfoil import TinfoilAI import json # Initialize the client client = TinfoilAI( api_key="" ) # Define the tool/function tools = [ { "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a specific location", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA" } }, "required": ["location"] } } } ] # Mock weather function (replace with real API call) def get_weather(location): return f"The weather in {location} is sunny, 22°C" # Make the initial request response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "What's the weather like in New York?"} ], tools=tools, tool_choice="auto" ) # Check if the model wants to call a function message = response.choices[0].message if message.tool_calls: # Process each tool call for tool_call in message.tool_calls: if tool_call.function.name == "get_weather": # Parse function arguments args = json.loads(tool_call.function.arguments) location = args["location"] # Call the function weather_result = get_weather(location) # Send the function result back to the model messages = [ {"role": "user", "content": "What's the weather like in New York?"}, message, # Assistant's message with tool call { "role": "tool", "content": weather_result, "tool_call_id": tool_call.id } ] # Get the final response final_response = client.chat.completions.create( model="", messages=messages, tools=tools, tool_choice="auto" ) print(final_response.choices[0].message.content) else: print(message.content) ``` ```typescript JavaScript theme={"dark"} import { TinfoilAI } from 'tinfoil'; // Initialize the client const client = new TinfoilAI({ apiKey: process.env.TINFOIL_API_KEY }); // Define the tool const tools = [ { type: 'function' as const, function: { name: 'get_weather', description: 'Get current weather for a specific location', parameters: { type: 'object', properties: { location: { type: 'string', description: 'The city and state, e.g. San Francisco, CA' } }, required: ['location'] } } } ]; // Mock weather function function getWeather(location: string): string { return `The weather in ${location} is sunny, 22°C`; } async function main() { // Make initial request const response = await client.chat.completions.create({ model: '', messages: [ { role: 'user', content: "What's the weather like in New York?" } ], tools }); const message = response.choices[0].message; if (message.tool_calls && message.tool_calls.length > 0) { // Process tool calls const toolResults = []; for (const toolCall of message.tool_calls) { if (toolCall.function.name === 'get_weather') { const args = JSON.parse(toolCall.function.arguments); const weatherResult = getWeather(args.location); toolResults.push({ role: 'tool' as const, content: weatherResult, tool_call_id: toolCall.id }); } } // Get final response const finalResponse = await client.chat.completions.create({ model: '', messages: [ { role: 'user', content: "What's the weather like in New York?" }, message, ...toolResults ], tools }); console.log(finalResponse.choices[0].message.content); } else { console.log(message.content); } } main().catch(console.error); ``` ```go Go theme={"dark"} package main import ( "context" "encoding/json" "fmt" "os" "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/shared" "github.com/tinfoilsh/tinfoil-go" ) func main() { // Create a secure Tinfoil client client, err := tinfoil.NewClient( option.WithAPIKey(os.Getenv("TINFOIL_API_KEY")), ) if err != nil { panic(err.Error()) } ctx := context.Background() question := "What is the weather in New York City?" params := openai.ChatCompletionNewParams{ Messages: []openai.ChatCompletionMessageParamUnion{ openai.UserMessage(question), }, Tools: []openai.ChatCompletionToolUnionParam{ openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{ Name: "get_weather", Description: openai.String("Get weather at the given location"), Parameters: shared.FunctionParameters{ "type": "object", "properties": map[string]interface{}{ "location": map[string]string{ "type": "string", }, }, "required": []string{"location"}, }, }), }, Model: "", } // Make initial chat completion request completion, err := client.Chat.Completions.New(ctx, params) if err != nil { panic(err) } toolCalls := completion.Choices[0].Message.ToolCalls // Return early if there are no tool calls if len(toolCalls) == 0 { fmt.Printf("No function call") return } // If there was a function call, continue the conversation params.Messages = append(params.Messages, completion.Choices[0].Message.ToParam()) for _, toolCall := range toolCalls { if toolCall.Function.Name == "get_weather" { // Extract the location from the function call arguments var args map[string]interface{} err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args) if err != nil { panic(err) } location := args["location"].(string) // Simulate getting weather data weatherData := getWeather(location) params.Messages = append(params.Messages, openai.ToolMessage(weatherData, toolCall.ID)) } } completion, err = client.Chat.Completions.New(ctx, params) if err != nil { panic(err) } fmt.Println(completion.Choices[0].Message.Content) } // Mock function to simulate weather data retrieval func getWeather(location string) string { // In a real implementation, this function would call a weather API return "Sunny, 25°C" } ``` ```swift Swift theme={"dark"} import Foundation import TinfoilAI import OpenAI // This example uses top-level await syntax // Wrap in an async function or use in an async context as needed let client = try await TinfoilAI.create( apiKey: ProcessInfo.processInfo.environment["TINFOIL_API_KEY"] ?? "" ) let weatherSchema = JSONSchema( .type(.object), .properties([ "location": JSONSchema( .type(.string), .description("The city and state, e.g. San Francisco, CA") ) ]), .required(["location"]) ) let tools = [ ChatQuery.ChatCompletionToolParam( function: .init( name: "get_weather", description: "Get current weather for a specific location", parameters: weatherSchema ) ) ] func getWeather(location: String) -> String { return "The weather in \(location) is sunny, 22°C" } let chatQuery = ChatQuery( messages: [ .user(.init(content: .string("What's the weather like in New York?"))) ], model: "", tools: tools ) let response = try await client.chats(query: chatQuery) if let toolCalls = response.choices.first?.message.toolCalls, !toolCalls.isEmpty { if let firstToolCall = toolCalls.first, let argsData = firstToolCall.function.arguments.data(using: String.Encoding.utf8), let args = try? JSONSerialization.jsonObject(with: argsData) as? [String: Any], let location = args["location"] as? String { let weatherResult = getWeather(location: location) let followUpQuery = ChatQuery( messages: [ .user(.init(content: .string("What's the weather like in New York?"))), .assistant(.init( content: nil, toolCalls: response.choices.first!.message.toolCalls?.map { ChatQuery.ChatCompletionMessageParam.AssistantMessageParam.ToolCallParam( id: $0.id, function: ChatQuery.ChatCompletionMessageParam.AssistantMessageParam.ToolCallParam.FunctionCall( arguments: $0.function.arguments, name: $0.function.name ) ) } )), .tool(.init(content: .textContent(weatherResult), toolCallId: firstToolCall.id)) ], model: "", tools: tools ) let finalResponse = try await client.chats(query: followUpQuery) print(finalResponse.choices.first?.message.content ?? "") } } ``` ```rust Rust theme={"dark"} use serde_json::{json, Value}; use tinfoil::Client; fn get_weather(location: &str) -> String { format!("The weather in {} is sunny, 22°C", location) } #[tokio::main] async fn main() -> Result<(), Box> { let client = Client::new_default().await?; let tools = json!([{ "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a specific location", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "The city and state, e.g. San Francisco, CA" } }, "required": ["location"] } } }]); let mut messages: Vec = vec![ json!({"role": "user", "content": "What's the weather like in New York?"}), ]; let body = client.chat_relaxed().request() .model("") .messages(messages.clone()) .set("tools", tools.clone()); let response = client.chat_relaxed().create(body).await?; let tool_calls = response.typed_tool_calls(); if tool_calls.is_empty() { println!("{}", response.content().unwrap_or("")); return Ok(()); } // Append the assistant turn (raw, includes tool_calls verbatim). if let Some(assistant) = response.raw().pointer("/choices/0/message") { messages.push(assistant.clone()); } // Run each tool and append its result. for call in &tool_calls { if call.function_name.as_deref() == Some("get_weather") { let args: Value = serde_json::from_str(&call.arguments_raw)?; let location = args["location"].as_str().unwrap_or(""); let result = get_weather(location); messages.push(json!({ "role": "tool", "content": result, "tool_call_id": call.id.as_deref().unwrap_or(""), })); } } let body = client.chat_relaxed().request() .model("") .messages(messages) .set("tools", tools); let final_response = client.chat_relaxed().create(body).await?; println!("{}", final_response.content().unwrap_or("")); Ok(()) } ``` ### Multiple Tools Example You can define multiple tools for more complex workflows: ```python theme={"dark"} tools = [ { "type": "function", "function": { "name": "get_weather", "description": "Get current weather for a location", "parameters": { "type": "object", "properties": { "location": {"type": "string"} }, "required": ["location"] } } }, { "type": "function", "function": { "name": "calculate", "description": "Perform mathematical calculations", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "Mathematical expression to evaluate" } }, "required": ["expression"] } } } ] def calculate(expression): # Safe evaluation of mathematical expressions try: result = eval(expression) return str(result) except: return "Error: Invalid mathematical expression" # The model can now choose between weather and calculation functions response = client.chat.completions.create( model="", messages=[ {"role": "user", "content": "What's 15 * 23 + 45?"} ], tools=tools, tool_choice="auto" ) ``` ### Best Practices 1. **Choose the Right Model**: Among the models offered on Tinfoil API, GLM-5.2 is recommended for function calling and agentic workflows 2. **Clear Descriptions**: Write detailed function descriptions to help the model understand when to use each tool 3. **Parameter Validation**: Always validate function parameters before execution 4. **Error Handling**: Implement proper error handling for function calls 5. **Security**: Never execute untrusted code - validate all function arguments 6. **Testing**: Test your functions independently before integrating with the AI model View all available models and their capabilities. Complete Python SDK documentation with more examples. # Verification center UI Source: https://docs.tinfoil.sh/guides/verification-center Add Tinfoil's verification center to your application to display real-time enclave verification status to users. ## Introduction The Verification Center is an embeddable iframe that displays the status of Tinfoil's enclave verification process. It shows users the cryptographic proof that their data is being processed in a verified secure enclave. You can see it live at [chat.tinfoil.sh](https://chat.tinfoil.sh). This guide covers how to embed the Verification Center in your web application and feed it verification data from the Tinfoil JavaScript SDK. ## Verification States The Verification Center displays different states based on the verification results: When all verification steps pass, users see confirmation that their data is protected:
Verification Center showing successful verification
If the HPKE public key doesn't match the expected value from the enclave attestation, encryption cannot be trusted:
Verification Center showing HPKE key mismatch error
If the code fingerprint doesn't match the enclave fingerprint, the enclave may not be running the expected code:
Verification Center showing fingerprint mismatch error
## Prerequisites Install the Tinfoil JavaScript SDK: ```bash theme={"dark"} npm install tinfoil ``` ## Basic Integration The integration involves two parts: embedding the iframe and sending it verification data from the SDK. ### 1. Add the Iframe ```html theme={"dark"} ``` ### 2. Send Verification Data Use the Tinfoil SDK to get the verification document and send it to the iframe via `postMessage`. You can use either `TinfoilAI` (OpenAI-compatible) or `SecureClient` (low-level): ```typescript theme={"dark"} import { TinfoilAI } from "tinfoil"; const client = new TinfoilAI({ apiKey: "" }); await client.ready(); const verificationDoc = await client.getVerificationDocument(); const iframe = document.getElementById("tinfoil-verification") as HTMLIFrameElement; // Wait for the iframe to signal it's ready window.addEventListener("message", (event) => { if (event.origin !== "https://verification-center.tinfoil.sh") return; if (event.data.type === "TINFOIL_VERIFICATION_CENTER_READY") { iframe.contentWindow?.postMessage( { type: "TINFOIL_VERIFICATION_DOCUMENT", document: verificationDoc, }, "https://verification-center.tinfoil.sh" ); } }); ``` ```typescript theme={"dark"} import { SecureClient } from "tinfoil"; const client = new SecureClient({ enclaveURL: "https://", configRepo: "", }); await client.ready(); const verificationDoc = client.getVerificationDocument(); const iframe = document.getElementById("tinfoil-verification") as HTMLIFrameElement; // Wait for the iframe to signal it's ready window.addEventListener("message", (event) => { if (event.origin !== "https://verification-center.tinfoil.sh") return; if (event.data.type === "TINFOIL_VERIFICATION_CENTER_READY") { iframe.contentWindow?.postMessage( { type: "TINFOIL_VERIFICATION_DOCUMENT", document: verificationDoc, }, "https://verification-center.tinfoil.sh" ); } }); ``` ## URL Parameters The Verification Center accepts these query parameters: | Parameter | Type | Default | Description | | ------------ | ------- | ------- | ----------------------- | | `darkMode` | boolean | `false` | Enable dark theme | | `showHeader` | boolean | `true` | Show or hide the header | Example with dark mode enabled: ```html theme={"dark"} ``` ## PostMessage API The Verification Center communicates with its parent window using the `postMessage` API. ### Messages from the Iframe Listen for these messages from the Verification Center: ```typescript theme={"dark"} window.addEventListener("message", (event) => { // Only handle messages from the Verification Center if (event.origin !== "https://verification-center.tinfoil.sh") return; switch (event.data.type) { case "TINFOIL_VERIFICATION_CENTER_READY": // Iframe is ready to receive verification data break; case "TINFOIL_REQUEST_VERIFICATION_DOCUMENT": // Iframe is requesting a fresh verification document break; } }); ``` ### Messages to the Iframe Send verification data to the iframe: ```typescript theme={"dark"} iframe.contentWindow?.postMessage( { type: "TINFOIL_VERIFICATION_DOCUMENT", document: verificationDoc, }, "https://verification-center.tinfoil.sh" ); ``` ## Complete Example Here's a complete integration with a sidebar layout: ```html theme={"dark"} ``` ```html theme={"dark"} ``` ## Understanding the Verification Document The verification document contains the results of the three-step verification process: ```typescript theme={"dark"} interface VerificationDocument { // Repository and enclave information configRepo: string; // GitHub repo (e.g., "tinfoilsh/confidential-gpt-oss-120b") enclaveHost: string; // Enclave hostname releaseDigest: string; // SHA256 digest of the release // Cryptographic measurements codeMeasurement: object; // Measurement from Sigstore verification enclaveMeasurement: object; // Measurement from enclave attestation codeFingerprint: string; // SHA-256 fingerprint of code enclaveFingerprint: string; // SHA-256 fingerprint of enclave // Verification status securityVerified: boolean; // True if all checks passed steps: { fetchDigest: StepState; verifyCode: StepState; verifyEnclave: StepState; compareMeasurements: StepState; verifyCertificate: StepState; }; } interface StepState { status: "pending" | "success" | "failed"; error?: string; } ``` The `securityVerified` field indicates whether all verification steps passed. Individual step statuses are available in the `steps` object for granular status display. The `getVerificationDocument()` method is available on both `TinfoilAI` and `SecureClient`. On `TinfoilAI` it returns a promise; on `SecureClient` it returns synchronously after `ready()` has resolved. ## React Integration For React applications, create a component that manages the iframe lifecycle: ```tsx theme={"dark"} import { useEffect, useRef, useState } from "react"; import { TinfoilAI } from "tinfoil"; interface VerificationCenterProps { apiKey: string; darkMode?: boolean; showHeader?: boolean; } export function VerificationCenter({ apiKey, darkMode = false, showHeader = true, }: VerificationCenterProps) { const iframeRef = useRef(null); const [verificationDoc, setVerificationDoc] = useState(null); const [iframeReady, setIframeReady] = useState(false); const iframeUrl = `https://verification-center.tinfoil.sh?darkMode=${darkMode}&showHeader=${showHeader}`; const VERIFICATION_CENTER_ORIGIN = "https://verification-center.tinfoil.sh"; useEffect(() => { const client = new TinfoilAI({ apiKey }); async function initialize() { await client.ready(); const doc = await client.getVerificationDocument(); setVerificationDoc(doc); } initialize(); }, [apiKey]); useEffect(() => { if (iframeReady && verificationDoc && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "TINFOIL_VERIFICATION_DOCUMENT", document: verificationDoc, }, VERIFICATION_CENTER_ORIGIN ); } }, [iframeReady, verificationDoc]); useEffect(() => { function handleMessage(event: MessageEvent) { if (event.origin !== VERIFICATION_CENTER_ORIGIN) return; if (event.data.type === "TINFOIL_VERIFICATION_CENTER_READY") { setIframeReady(true); } if (event.data.type === "TINFOIL_REQUEST_VERIFICATION_DOCUMENT") { if (verificationDoc && iframeRef.current?.contentWindow) { iframeRef.current.contentWindow.postMessage( { type: "TINFOIL_VERIFICATION_DOCUMENT", document: verificationDoc, }, VERIFICATION_CENTER_ORIGIN ); } } } window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [verificationDoc]); return (