Documentation

Bring your own key: LLM apps and Supabase

A host0 app can call an LLM or your own Supabase project from the browser today, with no server code. It can't keep a secret, so this page draws the line: what's safe, what isn't, and a chat app you can publish as it is.

The safety line

Every file in an app goes to the browser of whoever opens it. A private app only narrows who that is: anyone you invite can still read its JavaScript. So a key in your code is a published key.

KeyWhere it livesToday
A visitor's own OpenAI, Anthropic or OpenRouter keyThe visitor pastes it; it stays in their browserWorks. Their key, their bill
Supabase publishable key (sb_publishable_…) or legacy anon keyIn your bundle, in the openWorks. Row Level Security is what protects the data
Your own OpenAI, Anthropic or OpenRouter keyNever in the bundleNeeds a server-side proxy, which host0 doesn't have yet
Supabase secret key (sb_secret_…) or legacy service_role keyNever in the bundleIt ignores Row Level Security. Keep it on a server

host0 refuses a deploy with files that look like credentials (.env, .pem, .key, id_rsa), but it doesn't read your JavaScript. A key pasted into app.js goes live with no warning, and anyone can use it until you revoke it.

If everyone should use your key: host0 can't do that yet. A proxy that keeps the key on the platform is planned, not shipped. Until then, put the key in a backend you run elsewhere (a Supabase Edge Function or a Cloudflare Worker) and call that from the app. It needs its own sign-in check and rate limit, or strangers spend your money through it.

Visitors bring their own key

The page asks the visitor for their key and calls the provider straight from their browser. host0 never sees the key or the conversation: the request goes from the visitor's browser to the provider, not through host0.

All three providers accept these calls from another origin. We checked the preflight response of each endpoint from a *.host0.app origin on 5 November 2026.

ProviderEndpointHeaders
AnthropicPOST https://api.anthropic.com/v1/messagesx-api-key, anthropic-version, anthropic-dangerous-direct-browser-access: true
OpenAIPOST https://api.openai.com/v1/responsesauthorization: Bearer …
OpenRouterPOST https://openrouter.ai/api/v1/chat/completionsauthorization: Bearer …

Be straight with visitors about the risk. A key typed into a page can be read by every script on that page, which is why OpenAI's key safety guide says not to deploy a key in client-side code, and why Anthropic's header has "dangerous" in its name. That advice is about shipping a key, not about a visitor choosing to paste theirs, but the exposure is the same. So:

  • Ask for a dedicated key with a spending limit, and say so on the page. OpenRouter is the gentlest choice: credit limits per key, and an OAuth flow that gives the visitor a key in one click instead of a paste.
  • Keep the key in memory, or in sessionStorage if the visitor asks. Avoid localStorage: it outlives the tab, and when a slug is renamed or deleted the address is released after 30 days, so its next owner gets the same origin.
  • Never put the key anywhere else: not in a URL (browser history, logs and referrers keep URLs), not in shared records, not in h0.json.
  • Load no third-party scripts on that page, and lock it down with a Content-Security-Policy, as the example below does. host0's badge and analytics scripts are same-origin and don't read form fields.
  • Treat model output as untrusted. Render it with textContent, never innerHTML.

A worked example: an LLM chat app

One file, no build step, no h0.json. Save it as chat/index.html, then publish the folder.

HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- The key can only travel to these three hosts. -->
<meta http-equiv="content-security-policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.anthropic.com https://api.openai.com https://openrouter.ai">
<title>Chat with your own key</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem; color-scheme: light dark; }
  form { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
  input, select, button { font: inherit; padding: .4rem .6rem; }
  #key, #prompt { flex: 1 1 14rem; }
  #log p { white-space: pre-wrap; margin: .75rem 0; }
  #log .user { font-weight: 600; }
  #error { color: #c00; }
  small { opacity: .7; }
</style>
</head>
<body>
<h1>Chat with your own key</h1>
<p><small>Your key stays in this browser and goes only to the provider you pick. It is not sent to this site. Use a dedicated key with a spending limit.</small></p>

<form id="settings">
  <select id="provider" aria-label="Provider">
    <option value="anthropic">Anthropic</option>
    <option value="openai">OpenAI</option>
    <option value="openrouter">OpenRouter</option>
  </select>
  <input id="model" aria-label="Model">
  <input id="key" type="password" autocomplete="off" spellcheck="false" placeholder="Your API key" aria-label="API key">
  <label><input id="remember" type="checkbox"> Remember in this tab</label>
</form>

<div id="log"></div>
<p id="error" role="alert"></p>

<form id="chat">
  <input id="prompt" placeholder="Ask something" aria-label="Message" required>
  <button id="send">Send</button>
</form>

<script>
const providers = {
  anthropic: {
    url: "https://api.anthropic.com/v1/messages",
    model: "claude-opus-5-5",
    headers: (key) => ({
      "x-api-key": key,
      "anthropic-version": "2026-10-21",
      // Anthropic's opt-in for calls from a browser, where the page can read the key.
      "anthropic-dangerous-direct-browser-access": "true",
    }),
    body: (model, messages) => ({ model, messages, max_tokens: 16000, stream: true }),
    text: (e) => (e.delta?.type === "text_delta" ? e.delta.text : ""),
  },
  openai: {
    url: "https://api.openai.com/v1/responses",
    model: "gpt-6.1-sol",
    headers: (key) => ({ authorization: `Bearer ${key}` }),
    body: (model, messages) => ({ model, input: messages, stream: true }),
    text: (e) => (e.type === "response.output_text.delta" ? e.delta : ""),
  },
  openrouter: {
    url: "https://openrouter.ai/api/v1/chat/completions",
    model: "openrouter/auto",
    headers: (key) => ({ authorization: `Bearer ${key}` }),
    body: (model, messages) => ({ model, messages, stream: true }),
    text: (e) => e.choices?.[0]?.delta?.content ?? "",
  },
};

const $ = (id) => document.getElementById(id);
const messages = []; // the conversation lives in memory: gone when the tab closes

$("key").value = sessionStorage.getItem("key") ?? "";
$("remember").checked = Boolean($("key").value);
$("provider").value = sessionStorage.getItem("provider") ?? "anthropic";
$("model").value = providers[$("provider").value].model;

$("provider").onchange = () => { $("model").value = providers[$("provider").value].model; };

function remember() {
  if ($("remember").checked) {
    sessionStorage.setItem("key", $("key").value);
    sessionStorage.setItem("provider", $("provider").value);
  } else {
    sessionStorage.clear();
  }
}

function addLine(text, className) {
  const p = document.createElement("p");
  p.className = className;
  p.textContent = text; // textContent, never innerHTML: model output is untrusted
  $("log").append(p);
  return p;
}

function failure(event) {
  if (event.delta?.stop_reason === "refusal") return "The model declined this request.";
  return event.error?.message ?? event.response?.error?.message ??
    (event.type === "error" ? event.message : null);
}

async function ask(provider, key, model) {
  const res = await fetch(provider.url, {
    method: "POST",
    headers: { "content-type": "application/json", ...provider.headers(key) },
    body: JSON.stringify(provider.body(model, messages)),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.error?.message ?? `The provider answered ${res.status}.`);
  }
  // Each provider streams server-sent events: lines of `data: {json}`.
  const line = addLine("", "assistant");
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let reply = "";
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) return reply;
    buffer += value;
    const lines = buffer.split("\n");
    buffer = lines.pop();
    for (const raw of lines) {
      if (!raw.startsWith("data:")) continue;
      const data = raw.slice(5).trim();
      if (data === "[DONE]") continue;
      const event = JSON.parse(data);
      const problem = failure(event);
      if (problem) throw new Error(problem);
      reply += provider.text(event);
      line.textContent = reply;
    }
  }
}

$("chat").onsubmit = async (e) => {
  e.preventDefault();
  const key = $("key").value.trim();
  $("error").textContent = "";
  if (!key) { $("error").textContent = "Paste your API key first."; return; }
  remember();
  const text = $("prompt").value;
  $("prompt").value = "";
  messages.push({ role: "user", content: text });
  addLine(text, "user");
  $("send").disabled = true;
  try {
    const reply = await ask(providers[$("provider").value], key, $("model").value.trim());
    if (reply) messages.push({ role: "assistant", content: reply });
  } catch (err) {
    messages.pop(); // so the same question can be asked again
    $("error").textContent = err.message;
  } finally {
    $("send").disabled = false;
  }
};
</script>
</body>
</html>

Publish it with the skill, or ask your agent to:

Terminal
./scripts/publish.sh ./chat --name "Chat" --visibility link

What we verified, and what we didn't. On 5 November 2026 we published this exact file to a local host0 stack and opened it in a browser. It loaded with the badge and analytics scripts and no Content-Security-Policy errors. With a fake key, each provider's real endpoint passed the browser's cross-origin check, answered 401, and the page showed the error. With fetch stubbed, a canned stream in each provider's format rendered as plain text, and a refusal and a mid-stream error showed as messages. We never used a real key, so no model has answered through this page. The defaults for model and anthropic-version come from each provider's docs that day. They age, so the model box is editable.

Your own Supabase project

An app can use Supabase straight from the browser. The publishable key is made to ship in client code, and Row Level Security decides what it can reach: Supabase's guide says so plainly, and says to enable RLS on every table before you deploy.

Pick this over host0's own shared data when you need SQL, joins, a second app on the same data, or your own sign-in. For a list or a tracker that a team shares, /.h0/data is less work: no project, no policies, and sign-in is already there.

First, in your Supabase project's SQL editor, a table only its owner can touch:

Code
create table public.notes (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  body text not null,
  created_at timestamptz not null default now()
);

alter table public.notes enable row level security;
revoke all on table public.notes from anon, authenticated;
grant select, insert, delete on table public.notes to authenticated;

create policy "Read own notes" on public.notes for select to authenticated
  using ((select auth.uid()) = user_id);
create policy "Add own notes" on public.notes for insert to authenticated
  with check ((select auth.uid()) = user_id);
create policy "Delete own notes" on public.notes for delete to authenticated
  using ((select auth.uid()) = user_id);

Then, in the app, with your project's URL and publishable key:

HTML
<script type="module">
  import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

  // Both are public by design. RLS protects the data, not this key.
  const supabase = createClient(
    "https://YOUR-PROJECT.supabase.co",
    "sb_publishable_YOUR_KEY",
  );

  const { data, error } = await supabase
    .from("notes")
    .select("id, body, created_at")
    .order("created_at", { ascending: false });
</script>

Three things catch people out:

  • No RLS means public. A table without RLS can be read and changed by anyone holding the publishable key, which is everyone who opens the app. Test your policies signed out and as a second user.
  • Sign-in is separate from host0's. Supabase Auth doesn't know who /.h0/me is, and the reverse. If you use email links or Google, add your app's URL (https://k3x9q2m7ta.host0.app/) to the redirect URLs under Authentication → URL Configuration, and change it when you rename the app.
  • Supabase's own limits and bill are yours. host0 doesn't see or manage that project: back it up and watch its usage in Supabase.

We ran the SQL above on local Postgres on 5 November 2026 and confirmed that a signed-in user reads only their own rows and a signed-out visitor is refused. We didn't run the browser snippet against a live Supabase project.