DEVELOPER DOCUMENTATION / OPENING SOON

Build with clarity.

An OpenAI-compatible API with published model routes and plain data handling.

Where we are

The API is built and tested. Until the first model route opens, chat requests return 503 inference_not_configured. The live state is always at https://undergrove.vercel.app/api/status.

It follows the OpenAI API format, so existing SDKs work by changing the base URL: chat with images, function calling and structured outputs, plus embeddings, transcription and speech. To keep requests unreadable even to our gateway, use encrypted mode, as the workspace does.

Quickstart

Base URL: https://undergrove.vercel.app/v1. List models with GET /v1/models, then stream a reply:

request.shShell
curl https://undergrove.vercel.app/v1/chat/completions \
  -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "<model-id>",
    "stream": true,
    "messages": [{"role": "user", "content": "Help me explore an idea."}]
  }'
Example responseAn example; values shortened

HTTP/2 200

9 headers
content-type
text/event-stream; charset=utf-8
cache-control
no-store
x-undergrove-route
tinfoil
x-undergrove-attestation
verified-by-gateway
x-undergrove-enclave-host
inference.tinfoil.sh
x-undergrove-enclave-measurement
c0ffee12…9a7d
x-undergrove-enclave-key
4f2b81e0…d3c6
x-undergrove-attestation-sha256
8e41c7aa…01fb
x-undergrove-attestation-url
/v1/attestation?sha256=8e41c7aa…01fb

    The answer so far

    Switch from OpenAI in one line

    Undergrove speaks the OpenAI API, so the official SDKs work as they are. Give the client Undergrove’s base URL and an Undergrove key, in the one line that creates it, then name one of the open models offered here.

    import os
    from openai import OpenAI
     
    client = OpenAI(base_url="https://undergrove.vercel.app/v1", api_key=os.environ["UNDERGROVE_API_KEY"])
     
    response = client.chat.completions.create(
        model="<model-id>",
        messages=[{"role": "user", "content": "Summarise this contract in three points."}],
    )
    print(response.choices[0].message.content)
    

    2 lines change: the client’s base URL and key, and the model.

    Or change no code at all: both SDKs read OPENAI_BASE_URL and OPENAI_API_KEY when the client is created without them, so setting those to https://undergrove.vercel.app/v1 and your key switches an app as it is. Streaming, images, function calling and structured outputs work as described below, for the models that support them.

    Developer tools

    Clients that verify the enclave and encrypt on your own machine. Each one checks the Tinfoil enclave’s attestation first, then encrypts every request to that enclave, so Undergrove’s relay passes it on without being able to read it. They live in the repository’s packages/ folder, are tested against a local stand-in, and aren’t published to any registry yet.

    Try the CLI

    This terminal replays the real undergrove command. Its output was recorded against the local stand-in the packages are tested with: verify is a real check of a recorded Tinfoil attestation, models lists the current catalog, and the answers to ask are scripted. There’s no model behind this page, and nothing you type leaves it.

    The terminal loads as you reach it.

    Sample apps

    Three small web apps in packages/examples, each a page and a Node server that holds the key. Without a key they run against the local stand-in with scripted answers, and nothing leaves your computer; with UNDERGROVE_API_KEY set, the same code verifies the enclave and encrypts each request to it.

    A chat that answers from a help-centre file, streamed to the page as server-sent events.

    npm run support-botin packages/examples; the source is in packages/examples/support-bot

    const SYSTEM = `You are the support assistant for Acme Bikes.
    Answer only from the help centre below. Quote prices and times exactly.
    When it doesn't cover a question, say so and offer the contact options.
    Keep answers short: two to four sentences, or a short list.
    
    ${knowledge}`;
    
    // Streams one answer. `history` is the conversation so far, as
    // [{ role: "user" | "assistant", content }].
    export async function answer(undergrove, history, send) {
      const stream = await undergrove.chat.completions.create({
        model,
        stream: true,
        max_tokens: 600,
        messages: [{ role: "system", content: SYSTEM }, ...history.slice(-12)],
      });
      for await (const chunk of stream) {
        const text = chunk.choices[0]?.delta?.content;
        if (text) send({ text });
      }
    }

    In your editor and CI

    • Node SDK@undergrove/sdk: OpenAI-style chat, streaming, embeddings, transcription and speech, encrypted by default.packages/sdk-js
    • Python SDKundergrove, on Tinfoil’s Python SDK, with the same encrypted default.packages/sdk-python
    • CLIundergrove ask, models and verify, with the key in the system keychain. For editors: --events streams JSON lines, acp runs it as an agent and mcp as tools.packages/cli
    • VS CodeA chat panel, and Explain selection, Fix this (with Apply fix) and Write tests on the code in front of you.packages/vscode
    • JetBrains IDEsA tool window chat and the same three actions in IntelliJ IDEA, PyCharm and the rest, with the key in the IDE’s password safe.packages/jetbrains
    • ZedUndergrove as an agent in the Agent Panel, with /explain, /fix, /tests and /review, or as tools for Zed’s own agent. Both run the CLI, which encrypts on your machine.packages/zed
    • Neovim:UndergroveAsk, and :UndergroveExplain on a visual selection, with the answer streaming into a floating window.packages/neovim
    • RaycastAsk Undergrove, and Ask About Selected Text in any app.packages/raycast
    • GitHub ActionUndergrove Review: one review per pull request, with line comments and suggestions, and the attestation in its footer.packages/github-action

    Playground

    Send a chat request from this page with your own key, and see the answer, its usage and the raw response, with the same request as curl. It goes from your browser to this site’s /v1, like any other API call, and spends your allowance or credit as usual.

    Choose Encrypted to send it the way the SDKs’ encrypted mode does: Tinfoil’s SDK verifies the enclave in this page, then seals the request to it. The playground shows the bytes that actually left the page, what came back, and the receipt for the enclave it was sealed to.

    Kept in this page’s memory only and sent only to https://undergrove.vercel.app/v1. Reloading or leaving the page clears it. A key with a daily cap limits what a mistake can cost.

    Path

    Encrypted mode needs a model that runs in an enclave; this one doesn’t.

    The API isn’t answering yet: until the first model route opens, requests return 503 inference_not_configured. You can still build a request and copy it as curl.

    Messages

    Ctrl or ⌘ and Enter in a message sends it too.

    The same request with curl

    curl --no-buffer https://undergrove.vercel.app/v1/chat/completions \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "model": "<model-id>",
        "messages": [
          {
            "role": "user",
            "content": "Help me explore an idea."
          }
        ],
        "stream": true
      }'

    Authentication

    Send an Undergrove API key as a Bearer token. Keys start with ugk_ and are issued by invitation during the alpha. Where self-serve keys are switched on, alpha members create up to five in the workspace settings, and each spends its account’s allowance and credit. We store only a one-way hash of each key, so a lost key can’t be recovered, only replaced.

    Each self-serve key can have its own limits, set when it’s created or later in the settings: a daily spending cap, after which its requests are refused until midnight UTC, and a list of the models it may use. A capped key still counts against its account’s allowance. Keys made before limits existed have none and keep working as before.

    models.shShell
    curl https://undergrove.vercel.app/v1/models \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY"

    Endpoints

    EndpointPurpose
    GET /v1/modelsModels available now, each with a type (chat, embedding, transcription or speech), its license, context size, prices, reasoning levels, support for images, tools and web search, pace (quick or considered), and where it runs.
    POST /v1/chat/completionsChat, streamed or as a single response, with images, function calling and structured outputs where the model supports them.
    POST /v1/embeddingsVectors for search and similarity from Nomic Embed Text v1.5 (768 dimensions). Up to 256 inputs. $0.002 per 48 inputs or 67,200 characters, whichever is more.
    POST /v1/audio/transcriptionsTranscripts from Whisper Large v3 Turbo. Multipart upload of up to 4.4 MB, with optional language and prompt. $0.01 per started megabyte.
    POST /v1/audio/speechWAV audio from Qwen3-TTS in nine voices: aiden, dylan, eric, ono_anna, ryan, serena, sohee, uncle_fu and vivian. Up to 4,096 characters. $0.005 per started 600 characters.
    GET /api/statusService state, usage limits, and the full model register.

    Responses use the standard chat.completion shape. Streams send chat.completion.chunk events and end with data: [DONE]. Reasoning models stream their reasoning separately in delta.reasoning_content, and the last chunk reports token usage. If a stream is interrupted, the final event is an error object instead of [DONE], so a partial answer is never mistaken for a complete one.

    embeddings.shShell
    curl https://undergrove.vercel.app/v1/embeddings \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "model": "undergrove/nomic-embed-text",
        "input": ["Where is the signed contract?", "Payment terms"]
      }'

    Parameters

    FieldTypeDefaultMeaning
    modelstringRequiredAn id from /v1/models.
    messagesarrayRequiredUp to 200 system, user, assistant and tool messages. For models with vision, user messages can include up to 16 image_url parts as base64 data URLs (PNG, JPEG, GIF or WebP); links aren’t fetched. Each image counts as 3,000 input tokens unless the model reports otherwise.
    tools, tool_choice, parallel_tool_callsarray, string | object, boolean—Function calling for models with tools in /v1/models: up to 128 functions. Streamed calls arrive as tool_calls deltas, and the answer ends with finish_reason tool_calls. Send results back as tool messages with their tool_call_id.
    response_formatobject—Structured outputs: {"type": "json_object"}, or json_schema with a name and schema.
    streambooleanfalseSend true to stream the answer as server-sent events.
    max_tokensintegerThe model’s limitAlso accepted as max_completion_tokens. Capped at the model’s output limit.
    temperature, top_pnumberSet by the modelSampling controls: 0–2 and 0–1.
    reasoning_effortstringSet by the modelHow long a reasoning model thinks before answering. Accepted values differ by model and are listed in /v1/models; the workspace’s Fast and Thorough use the listed fast and thorough values.
    web_search_optionsobject—Send {} to let the model search the web. Queries are screened for personal details and results for prompt injection. Sources arrive as url_citation annotations. Each search request costs $0.05 of your allowance.
    stopstring | string[]—A string or up to four strings.
    presence_penalty, frequency_penalty, seednumber, integer—Passed through when the model route supports them.
    tools.shShell
    curl https://undergrove.vercel.app/v1/chat/completions \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "model": "<model-id>",
        "max_tokens": 500,
        "messages": [{"role": "user", "content": "Is it raining in Lisbon?"}],
        "tools": [{
          "type": "function",
          "function": {
            "name": "get_weather",
            "description": "Current weather for a city",
            "parameters": {
              "type": "object",
              "properties": {"city": {"type": "string"}},
              "required": ["city"]
            }
          }
        }]
      }'

    Encrypted mode

    The standard API is read by our gateway in memory to check each request. To keep requests unreadable to Undergrove as well, use Tinfoil’s SDK with your key. It verifies the enclave’s hardware attestation, then encrypts each request so only that enclave can read it. Our relay checks your key and allowance and passes the ciphertext on.

    1. Your codeTinfoil’s SDK verifies the enclave, then encrypts each request to it.Can read the request
    2. Sealed to the enclaveUndergrove relayChecks the key and allowance, passes the ciphertext on. Can’t read it.Sees ciphertext only
    3. Still sealedModel enclaveThe only place the request is decrypted and the model runs.Can read the request
    Encrypted mode: your code verifies the enclave itself, and the request stays sealed until it’s inside.
    encrypted.mjsJavaScript
    import { SecureClient } from "tinfoil"; // npm install tinfoil
    
    const RELAY = "https://undergrove.vercel.app/api/enclave";
    const client = new SecureClient({
      baseURL: `${RELAY}/v1/`,
      attestationBundleURL: RELAY,
    });
    await client.ready(); // verifies the enclave; throws if it can't
    
    const response = await client.fetch("chat/completions", {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.UNDERGROVE_API_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        model: "gemma4-31b", // enclave_model in /v1/models
        max_tokens: 1000,
        messages: [{ role: "user", content: "Summarise this contract." }],
      }),
    });
    console.log((await response.json()).choices[0].message.content);

    The same client reaches embeddings, audio/transcriptions and audio/speech. Because the relay can’t read these requests, models are named by their enclave_model id from /v1/models, answers come back in Tinfoil’s format, and usage is estimated from the size of the encrypted data. Allowances and credit apply as usual.

    MCP server

    Undergrove is also a Model Context Protocol server, at https://undergrove.vercel.app/mcp. Each live model is a tool, such as ask_<model>, that MCP clients like Claude Code, Cursor and Claude Desktop can call with your API key. A tool takes a prompt, and optionally system, max_tokens and, for models that search, web_search, and returns the model’s answer as text.

    A tool call is an API request like any other: the gateway reads it in memory, sends it on the model’s published route, keeps no copy, and counts it against your key’s limits, allowance and credit. The server uses streamable HTTP, answers in JSON and keeps no sessions. It speaks protocol versions 2025-11-25, 2025-06-18 and 2025-03-26.

    Claude Code

    Run once in a terminal:

    terminalShell
    claude mcp add --transport http undergrove https://undergrove.vercel.app/mcp \
      --header "Authorization: Bearer ugk_your_key"

    Cursor

    In ~/.cursor/mcp.json, or .cursor/mcp.json in a project:

    mcp.jsonJSON
    {
      "mcpServers": {
        "undergrove": {
          "url": "https://undergrove.vercel.app/mcp",
          "headers": {
            "Authorization": "Bearer ugk_your_key"
          }
        }
      }
    }

    Claude Desktop

    In claude_desktop_config.json (Settings › Developer › Edit Config). Claude Desktop reaches remote servers through mcp-remote, which needs Node.js:

    claude_desktop_config.jsonJSON
    {
      "mcpServers": {
        "undergrove": {
          "command": "npx",
          "args": [
            "-y",
            "mcp-remote",
            "https://undergrove.vercel.app/mcp",
            "--header",
            "Authorization:${AUTH_HEADER}"
          ],
          "env": {
            "AUTH_HEADER": "Bearer ugk_your_key"
          }
        }
      }
    }

    Any client

    Or call it directly. It speaks JSON-RPC over streamable HTTP:

    tools-list.shShell
    curl https://undergrove.vercel.app/mcp \
      -H "Authorization: Bearer ugk_your_key" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'

    Then call a model’s tool:

    tools-call.shShell
    curl https://undergrove.vercel.app/mcp \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
          "name": "ask_<model>",
          "arguments": {"prompt": "Name three uses for a sealed envelope."}
        }
      }'

    The other way round, the workspace can use tools from MCP servers you connect in Settings. Those calls go through Undergrove’s server to the server you connected, in plaintext, so use servers you trust with what the tools are given.

    Errors and limits

    Errors use the OpenAI format: {"error": {"message", "type", "code"}}.

    StatusCodes
    400invalid_request, invalid_json, unsupported_parameter, unsupported_content, context_length_exceeded, model_rejected_request, unsupported_api_version, async_needs_webhook, async_stream, invalid_webhook_url
    401authentication_required, invalid_api_key, invalid_session, sandbox_key (a ugk_sandbox_ key, which only the local stand-in answers)
    403alpha_access_required, model_not_allowed_for_key (the key is limited to other models)
    404model_not_found, webhook_not_found, thread_not_found, assistant_not_found, prompt_not_found, version_not_found
    409idempotency_in_progress (the first request with this Idempotency-Key is still running), version_conflict (a sealed item changed since it was read), id_taken
    413request_too_large (chat and transcription over 4.4 MB, embeddings over 1 MB, speech over 100 KB)
    422idempotency_key_reused (the same Idempotency-Key with a different request)
    429rate_limited, budget_exhausted, capacity_exhausted, key_budget_exceeded (the key reached its own daily cap; the message names the reset time). Wait for the Retry-After header, add credit in the workspace settings, or raise the key’s cap there.
    502model_unavailable, stream_interrupted
    503capacity_exhausted (with Retry-After), inference_not_configured, realtime_not_available (an audio route isn’t attested)
    504model_timeout

    During the alpha, each key or account has a per-minute, daily request and daily spending allowance. Current values are published at /api/status.

    429.jsonJSON
    HTTP/2 429
    retry-after: 41
    
    {
      "error": {
        "message": "You’re sending messages quickly. Please wait a moment.",
        "type": "rate_limit_error",
        "code": "rate_limited"
      }
    }

    Know where your data goes

    1. Your serverSends the request with your key.Can read the request
    2. HTTPSUndergrove gatewayChecks the key and limits, reads the request in memory, keeps no copy.Can read the request
    3. Encrypted to the verified enclaveModel enclaveTinfoil’s hardware enclave runs the model on the published route.Can read the request
    The standard API: the gateway reads each request in memory to check it, then sends it encrypted to the enclave it verified.

    The gateway checks your key, applies limits, and forwards only the fields above to the model route. It keeps no copy of prompts or answers and does not log them. Its logs record route names, status codes, and timings only. Rate-limit counters use a one-way hash of your key or account and expire within two days.

    Each model route’s operator, location, and retention are published in the model register at /api/status and on the privacy page. Requests never fall back to a different route. Vercel, our host, keeps standard request metadata such as IP addresses.

    On confidential-computing routes, the API gateway verifies the enclave’s hardware attestation before connecting and encrypts each request so only that enclave can read it. The gateway itself still reads API requests in memory to check them.

    Workspace data

    Sync, shared links and shared projects store only ciphertext the browser made. Each diagram shows who holds the key, step by step.

    What verification proves

    Before sending anything, the workspace runs Tinfoil’s open-source verifier in your browser. It checks the enclave’s hardware attestation report against AMD’s or Intel’s signing keys, checks that the code measured in the enclave matches a release Tinfoil published and signed on GitHub, and checks that the encryption key it uses belongs to that enclave. Select Verified in the workspace to see each check, the release and the measurement.

    It proves that your messages can only be decrypted by that hardware running that published code. It does not prove the published code is free of flaws, protect against weaknesses in the processors themselves, or cover what you do with answers after they reach your browser. The workspace code is served by Undergrove, so you are trusting us to serve the verifier as published; Verify this page compares the code your browser loaded with the build’s manifest, and npm run verify:site compares the site with your own build of the same commit. An independent assessment is planned.

    Each answer from an enclave has a Receipt button. It shows the enclave, release and measurements verified before that request was sent and the key it was encrypted to, and downloads them as JSON. Until the tab closes, the download includes the attestation report, so you can run the verifier on it again later; after that, it has the report’s hash and where to fetch the enclave’s current one. The receipt is your browser’s record, not a signed statement, and the enclave it names is Tinfoil’s model router; the router passes requests on to the model’s own enclave. The receipt names that enclave too, and your browser tries to verify it the same way. Most model enclaves run on Intel TDX, which the browser verifier can’t check yet; for those the receipt says so, and the check rests on the router, whose published code verifies each model enclave before using it.

    attestation.shShell
    # The attestation bundle the gateway verified, what it checked
    # in it, and the steps to check it again yourself
    curl https://undergrove.vercel.app/v1/attestation

    Verifiable responses

    For models that run in an enclave, every API response says which enclave the gateway verified before sending your request, and GET /v1/attestation returns the attestation bundle behind it, so you can run Tinfoil’s open-source verifier on the same evidence.

    HeaderValue
    x-undergrove-attestationverified-by-gateway
    x-undergrove-enclave-hostThe attested enclave: Tinfoil’s model router
    x-undergrove-enclave-measurementThe code measurement, matched to a signed release
    x-undergrove-enclave-releaseThe release tag (and -release-digest, its published digest)
    x-undergrove-enclave-keyThe enclave’s public key the request was encrypted to
    x-undergrove-attestation-sha256The SHA-256 of the attestation report
    x-undergrove-attestation-urlWhere to fetch the bundle: /v1/attestation?sha256=…

    Read them from any client. With the OpenAI Python SDK, ask for the raw response:

    headers.shShell
    curl -si https://undergrove.vercel.app/v1/chat/completions \
      -H "Authorization: Bearer $UNDERGROVE_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"model": "<model-id>", "messages": [{"role": "user", "content": "Say hello."}]}' \
      | grep -i '^x-undergrove-'

    To check a response, this Node script sends a request, fetches the bundle its headers name, verifies it with @tinfoilsh/verifier, and checks that the key, measurement, enclave and report hash all match. The playground above does the same in your browser with Check it in this browser.

    verify-response.mjs
    verify-response.mjsJavaScript
    // Checks the attestation evidence on an Undergrove API response yourself.
    //
    //   npm install @tinfoilsh/verifier
    //   UNDERGROVE_API_KEY=ugk_… node verify-response.mjs
    import { createHash } from "node:crypto";
    import { Verifier } from "@tinfoilsh/verifier";
    
    const base = process.env.UNDERGROVE_BASE_URL || "https://undergrove.vercel.app/v1";
    const model = process.env.UNDERGROVE_MODEL || "undergrove/gpt-oss-120b";
    
    // 1. Each response from an attested route names the enclave the gateway
    //    verified and encrypted the request to.
    const response = await fetch(`${base}/chat/completions`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.UNDERGROVE_API_KEY}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        model,
        max_tokens: 50,
        messages: [{ role: "user", content: "Say hello." }],
      }),
    });
    if (!response.ok) throw new Error(`The API answered ${response.status}.`);
    const header = (name) => response.headers.get(`x-undergrove-${name}`);
    const claim = {
      host: header("enclave-host"),
      key: header("enclave-key"),
      measurement: header("enclave-measurement"),
      report: header("attestation-sha256"),
    };
    if (!claim.key) throw new Error("This response carries no attestation evidence.");
    
    // 2. Fetch the attestation bundle it names. Without a report hash, the
    //    route's current bundle (its key must still match).
    const query = claim.report ? `?sha256=${claim.report}` : "";
    const { bundle } = await (await fetch(`${base}/attestation${query}`)).json();
    
    // 3. Verify the bundle with Tinfoil's open-source verifier: the chip
    //    maker's signature on the report, the release's signed build record
    //    from GitHub, and the enclave's certificate. It throws if any fails.
    const verifier = new Verifier({ configRepo: "tinfoilsh/confidential-model-router" });
    const attested = await verifier.verifyBundle(bundle);
    const verified = verifier.getVerificationDocument();
    
    // 4. The verified bundle must attest exactly what the response claimed.
    const { format, body } = bundle.enclaveAttestationReport;
    const hash = createHash("sha256").update(format + body).digest("hex");
    const checks = [
      ["report hash", !claim.report || hash === claim.report],
      ["encryption key", attested.hpkePublicKey === claim.key],
      ["code measurement", verified.codeFingerprint === claim.measurement],
      ["enclave host", verified.enclaveHost === claim.host],
    ];
    for (const [name, ok] of checks) if (!ok) throw new Error(`The ${name} doesn’t match the response.`);
    
    console.log(
      `Verified: ${verified.enclaveHost} runs ${verified.configRepo} ${verified.releaseTag || ""},` +
        ` and its attested key is the one this response names (${claim.key.slice(0, 16)}…).`,
    );
    

    What a passing check shows

    That an enclave with that key and that measured code existed: genuine AMD or Intel confidential-computing hardware, running the release Tinfoil published and signed on GitHub. And that the gateway says it verified that enclave and encrypted your request to its key before forwarding it.

    What it doesn’t

    That the gateway really did encrypt your request to that key. The headers are the gateway’s report, not a statement signed by the enclave, and the gateway reads API requests in memory to check them. To rely on your own verification instead, use encrypted mode, where your client checks the enclave and encrypts each request itself. The enclave named is Tinfoil’s model router, which passes requests on to the model’s own enclave.

    Website assistant

    Put an assistant on your own website with one script tag. It adds a button that opens a chat panel, answered by the open model you choose, within a daily spending cap you set. The panel is a frame served by Undergrove, so your page can’t read what visitors type.

    1. In the workspace, open Settings and create a publishable key under Website assistant: its name, model, the websites that may use it, and a daily cap.
    2. Copy the code it gives you and paste it before </body> on each page.
    3. Try it first on the demo page, a made-up shop.
    index.htmlHTML
    <script
      src="https://undergrove.vercel.app/embed.js"
      data-key="ugp_…"
      data-model="&lt;model-id&gt;"
      data-instructions="We’re a pottery studio. Help with classes, opening times and shipping."
      async
    ></script>
    AttributeTypeDefaultMeaning
    data-keystringRequiredYour publishable key, ugp_…
    data-modelstringRequiredOne of the models the key lists.
    data-titlestring“Ask a question”The panel’s title.
    data-greetingstring—The first message visitors see.
    data-instructionsstring—What your website is about, what to help with, and the tone. Sent with every question; visitors can read it in your page.
    data-labelstring“Ask”The button’s text.
    data-positionstringrightright or left.
    data-themestringautoauto (the visitor’s device), light or dark.
    data-openbooleanfalsetrue opens the panel when the page loads.

    Why a publishable key can sit in a public page

    • It works for chat only, and only from Undergrove’s own assistant frame. The API refuses it everywhere else.
    • It works only while the frame is embedded by one of the websites you list. The browser reports which page embeds the frame, and the server checks it.
    • It always has a daily spending cap and a list of models. A program outside a browser can claim to be any website, so treat the key as public and set the cap to what you’re happy to spend in a day.
    • Each visitor has a per-minute and daily question limit, published at /api/status. Visitors are counted under a keyed one-way hash of their IP address, never the address, and the counts expire within two days.

    Answers spend your account’s allowance and credit, like any key. For models that run in an enclave, the frame encrypts each message in the visitor’s browser to the verified enclave, as the workspace does; other models are reached through the gateway, which reads requests in memory and keeps no copy. Your website sees neither.

    “Verified by Undergrove” badge

    A website with an assistant can show a small badge that links to Undergrove’s page for its key. That page reads the key’s websites and models from Undergrove’s server, verifies the enclave behind them in the visitor’s browser, and says what the badge does and doesn’t claim. Put the tag where the badge should appear.

    Or, with no script, an image and a link (badge-dark.svg for dark pages):

    badge.htmlHTML
    <script src="https://undergrove.vercel.app/badge.js" data-key="ugp_…" async></script>
    • The badge is a link. It makes no requests from your page and checks nothing itself; the page it opens does the checking.
    • It names your publishable key, which is already in your page for the assistant. The badge page shows the key’s websites and models, and never the account, the cap or what’s been spent.
    • The link asks the browser to send your website’s origin, so the page can tell visitors whether they came from one of the key’s websites. A badge copied to another website says so there.
    • It vouches for the assistant only, not for the rest of your website.

    Integrations

    Most of Undergrove’s own clients check the enclave’s attestation with Tinfoil’s verifier, then encrypt every request to that enclave, so the relay passes it on without being able to read it. Each takes your API key and uses Undergrove model ids. They aren’t on package registries yet; each builds from packages/ in the site’s repository.

    The Rust, Java and Kotlin clients and the Make app use the developer API instead, as there’s no Tinfoil SDK for them to build on: the gateway reads each request in memory, verifies the enclave itself and reports what it verified in the answer’s headers.

    PackageForWhat it does
    @undergrove/sdkNodeAn OpenAI-style client with streaming, tools, embeddings, transcription and speech.
    undergrovePythonThe Python client, with LangChain (ChatUndergrove) and LlamaIndex (UndergroveLLM) as extras.
    GoGoA client on Tinfoil’s Go verifier, with streaming and tool calls.
    UndergroveSwiftA Swift package for macOS 14 and iOS 17, on Tinfoil’s Swift verifier.
    @undergrove/ai-sdk-providerVercel AI SDKgenerateText, streamText and tools, with what was verified in each answer’s provider metadata.
    @undergrove/langchainLangChain.jsA chat model and embeddings.
    @undergrove/reactReactChat components and hooks, and a server route that keeps the API key off the browser.
    create-undergrove-appStarternpm create undergrove-app: a private chat app with a two-command Vercel deploy.
    @undergrove/agentsAgentsA tool-calling loop that records which verified enclave answered each step, in a trail anyone can check for edits, with enclave pins.
    undergroveRustA client for the developer API, with streaming and tool calls, and the gateway’s attestation evidence for each answer. It doesn’t encrypt requests to the enclave itself.
    dev.undergrove:undergroveJava and KotlinA client for the developer API, with streaming, tools and embeddings, and coroutines and a Flow for Kotlin. Not encrypted mode: the gateway reads requests in memory.
    undergrove[jupyter]JupyterThe %%undergrove cell magic, with notebook variables and DataFrames as context, and an attestation widget.
    @undergrove/officeExcel and Word=UNDERGROVE.ASK() in any cell, and summarise, rewrite or ask about the selection in Word.
    @undergrove/officeOutlookA summary of the open message, and a draft reply you check before sending.
    undergrove-obsidianObsidianAsk about the open note, summarise, rewrite and continue writing, with the verified enclave under each answer. Desktop only.
    Undergrove for AlfredAlfredType ug and a question: the answer in Large Type and on the clipboard, encrypted through the undergrove CLI.
    n8n-nodes-undergroven8nA community node: ask a model, create embeddings and list models, with what was verified on each item.
    Undergrove for ZapierZapierAsk a Model and Create Embeddings actions, each request encrypted to the enclave from the Zap’s process.
    Undergrove for MakeMakeA custom app on the developer API: the gateway verifies the enclave and reports what it verified in each module’s output.
    Undergrove Enclave PinGitHub ActionsA step that verifies the enclave on the runner with Tinfoil’s verifier and fails the job when it isn’t the one you pinned.

    With the Vercel AI SDK:

    summarise.mjsJavaScript
    import { streamText } from "ai";
    import { undergrove } from "@undergrove/ai-sdk-provider";
    
    const result = streamText({
      model: undergrove("undergrove/gemma-4-31b"),
      prompt: "Summarise this contract.",
    });
    for await (const text of result.textStream) process.stdout.write(text);

    With the React components and the starter, messages travel from the page to your own server, which seals them; whoever runs that server can read them, as with any server-side integration.

    Testing in CI

    The stand-in the packages are tested against answers sandbox keys: any key that starts with ugk_sandbox_. Your tests can use one without a secret or charges, and Undergrove itself refuses them with sandbox_key. Its answers are scripted, not a model’s. In GitHub Actions, one step starts it and sets UNDERGROVE_BASE_URL and UNDERGROVE_API_KEY:

    .github/workflows/test.ymlYAML
    - uses: ekempinski/undergrove/packages/github-action/sandbox@main
    - run: npm test   # UNDERGROVE_API_KEY=ugk_sandbox_ci
    
    # And before a deploy: fail if the enclave isn’t the one you pinned.
    - uses: ekempinski/undergrove/packages/github-action/pin@main
      with:
        measurement: 9134838da8aa4e1792566b2988820340356517c4db0c94261eb8154843e0357554ba72fcf185f950d9301fbede574c90

    What’s next

    AvailableWorkspace with encryption from your browser to the enclave, streaming gateway, OpenAI-compatible API with images, function calling, structured outputs, embeddings, transcription and speech, encrypted API mode, web search, documents, folders and codebases, images, audio transcripts, read-aloud, search across long documents, voice input, model register, saved conversations and export on your device.
    NextFirst model route, private-alpha sign-in, shared usage limits.
    Built, not yet switched onSelf-serve keys, paid plans, and prepaid credit for use beyond the daily allowance.
    PlannedTeam access.
    Requires verificationIndependent security assessment of the workspace, relay and gateway.