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:
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."}]
}'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.
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.
curl https://undergrove.vercel.app/v1/models \
-H "Authorization: Bearer $UNDERGROVE_API_KEY"Endpoints
| Endpoint | Purpose |
|---|---|
| GET /v1/models | Models 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/completions | Chat, streamed or as a single response, with images, function calling and structured outputs where the model supports them. |
| POST /v1/embeddings | Vectors 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/transcriptions | Transcripts 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/speech | WAV 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/status | Service 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.
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
| Field | Type | Default | Meaning |
|---|---|---|---|
model | string | Required | An id from /v1/models. |
messages | array | Required | Up 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_calls | array, 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_format | object | — | Structured outputs: {"type": "json_object"}, or json_schema with a name and schema. |
stream | boolean | false | Send true to stream the answer as server-sent events. |
max_tokens | integer | The model’s limit | Also accepted as max_completion_tokens. Capped at the model’s output limit. |
temperature, top_p | number | Set by the model | Sampling controls: 0–2 and 0–1. |
reasoning_effort | string | Set by the model | How 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_options | object | — | 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. |
stop | string | string[] | — | A string or up to four strings. |
presence_penalty, frequency_penalty, seed | number, integer | — | Passed through when the model route supports them. |
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.
- Your codeTinfoil’s SDK verifies the enclave, then encrypts each request to it.Can read the request
- Sealed to the enclaveUndergrove relayChecks the key and allowance, passes the ciphertext on. Can’t read it.Sees ciphertext only
- Still sealedModel enclaveThe only place the request is decrypted and the model runs.Can read the request
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:
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:
{
"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:
{
"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:
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:
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"}}.
| Status | Codes |
|---|---|
| 400 | invalid_request, invalid_json, unsupported_parameter, unsupported_content, context_length_exceeded, model_rejected_request, unsupported_api_version, async_needs_webhook, async_stream, invalid_webhook_url |
| 401 | authentication_required, invalid_api_key, invalid_session, sandbox_key (a ugk_sandbox_ key, which only the local stand-in answers) |
| 403 | alpha_access_required, model_not_allowed_for_key (the key is limited to other models) |
| 404 | model_not_found, webhook_not_found, thread_not_found, assistant_not_found, prompt_not_found, version_not_found |
| 409 | idempotency_in_progress (the first request with this Idempotency-Key is still running), version_conflict (a sealed item changed since it was read), id_taken |
| 413 | request_too_large (chat and transcription over 4.4 MB, embeddings over 1 MB, speech over 100 KB) |
| 422 | idempotency_key_reused (the same Idempotency-Key with a different request) |
| 429 | rate_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. |
| 502 | model_unavailable, stream_interrupted |
| 503 | capacity_exhausted (with Retry-After), inference_not_configured, realtime_not_available (an audio route isn’t attested) |
| 504 | model_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.
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
- Your serverSends the request with your key.Can read the request
- HTTPSUndergrove gatewayChecks the key and limits, reads the request in memory, keeps no copy.Can read the request
- Encrypted to the verified enclaveModel enclaveTinfoil’s hardware enclave runs the model on the published route.Can read the request
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.
# 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/attestationVerifiable 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.
| Header | Value |
|---|---|
x-undergrove-attestation | verified-by-gateway |
x-undergrove-enclave-host | The attested enclave: Tinfoil’s model router |
x-undergrove-enclave-measurement | The code measurement, matched to a signed release |
x-undergrove-enclave-release | The release tag (and -release-digest, its published digest) |
x-undergrove-enclave-key | The enclave’s public key the request was encrypted to |
x-undergrove-attestation-sha256 | The SHA-256 of the attestation report |
x-undergrove-attestation-url | Where to fetch the bundle: /v1/attestation?sha256=… |
Read them from any client. With the OpenAI Python SDK, ask for the raw response:
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
// 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.
- 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.
- Copy the code it gives you and paste it before
</body>on each page. - Try it first on the demo page, a made-up shop.
<script
src="https://undergrove.vercel.app/embed.js"
data-key="ugp_…"
data-model="<model-id>"
data-instructions="We’re a pottery studio. Help with classes, opening times and shipping."
async
></script>| Attribute | Type | Default | Meaning |
|---|---|---|---|
data-key | string | Required | Your publishable key, ugp_… |
data-model | string | Required | One of the models the key lists. |
data-title | string | “Ask a question” | The panel’s title. |
data-greeting | string | — | The first message visitors see. |
data-instructions | string | — | What your website is about, what to help with, and the tone. Sent with every question; visitors can read it in your page. |
data-label | string | “Ask” | The button’s text. |
data-position | string | right | right or left. |
data-theme | string | auto | auto (the visitor’s device), light or dark. |
data-open | boolean | false | true 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):
<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.
| Package | For | What it does |
|---|---|---|
@undergrove/sdk | Node | An OpenAI-style client with streaming, tools, embeddings, transcription and speech. |
undergrove | Python | The Python client, with LangChain (ChatUndergrove) and LlamaIndex (UndergroveLLM) as extras. |
Go | Go | A client on Tinfoil’s Go verifier, with streaming and tool calls. |
Undergrove | Swift | A Swift package for macOS 14 and iOS 17, on Tinfoil’s Swift verifier. |
@undergrove/ai-sdk-provider | Vercel AI SDK | generateText, streamText and tools, with what was verified in each answer’s provider metadata. |
@undergrove/langchain | LangChain.js | A chat model and embeddings. |
@undergrove/react | React | Chat components and hooks, and a server route that keeps the API key off the browser. |
create-undergrove-app | Starter | npm create undergrove-app: a private chat app with a two-command Vercel deploy. |
@undergrove/agents | Agents | A tool-calling loop that records which verified enclave answered each step, in a trail anyone can check for edits, with enclave pins. |
undergrove | Rust | A 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:undergrove | Java and Kotlin | A 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] | Jupyter | The %%undergrove cell magic, with notebook variables and DataFrames as context, and an attestation widget. |
@undergrove/office | Excel and Word | =UNDERGROVE.ASK() in any cell, and summarise, rewrite or ask about the selection in Word. |
@undergrove/office | Outlook | A summary of the open message, and a draft reply you check before sending. |
undergrove-obsidian | Obsidian | Ask about the open note, summarise, rewrite and continue writing, with the verified enclave under each answer. Desktop only. |
Undergrove for Alfred | Alfred | Type ug and a question: the answer in Large Type and on the clipboard, encrypted through the undergrove CLI. |
n8n-nodes-undergrove | n8n | A community node: ask a model, create embeddings and list models, with what was verified on each item. |
Undergrove for Zapier | Zapier | Ask a Model and Create Embeddings actions, each request encrypted to the enclave from the Zap’s process. |
Undergrove for Make | Make | A custom app on the developer API: the gateway verifies the enclave and reports what it verified in each module’s output. |
Undergrove Enclave Pin | GitHub Actions | A 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:
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:
- 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: 9134838da8aa4e1792566b2988820340356517c4db0c94261eb8154843e0357554ba72fcf185f950d9301fbede574c90What’s next
| Available | Workspace 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. |
|---|---|
| Next | First model route, private-alpha sign-in, shared usage limits. |
| Built, not yet switched on | Self-serve keys, paid plans, and prepaid credit for use beyond the daily allowance. |
| Planned | Team access. |
| Requires verification | Independent security assessment of the workspace, relay and gateway. |