sideagentDocs

Cloud

sideagent Cloud

The hosted version: configure an agent in a dashboard and embed it with a publishable key.

On this page

sideagent Cloud runs the agent for you. You do not host a server or write createAgent code.

Set up an agent

  1. Sign in with your email. You get a magic link.
  2. Create an agent. Each agent has its own configuration, history, limits and audit log.
  3. Add instructions, knowledge sources (URLs or pasted text) and, optionally, your OpenAPI spec and the operations to allow.
  4. Pick an LLM: the built-in model, or any OpenAI-compatible host with your own key. Keys are encrypted at rest and shown only as their last four characters.
  5. Pick how users are identified: token verification (JWKS or a userinfo URL), or anonymous for public, knowledge-only chat.
  6. Add the origins that may embed the chat, then turn the agent on.

Keys

Each agent has a publishable key, safe in the browser.

The publishable key names the agent and the sideagent Cloud host. Pass it to the chat (sideagent 0.4.0 or later):

<AgentChat publishableKey="pk_live_…" />

The chat sends requests to the agent the key names and carries the key in the x-sideagent-key header. Every request to the agent endpoint needs it: without the key the answer is 401, and with a key for another agent or host it is 404, as for a missing agent. The key proves nothing on its own.

Signed-in users

With token verification the chat sends your user's login token, and the agent calls your API with that same token. You write no server code. Pass a function that returns the current token (sideagent 0.5.0 or later):

<AgentChat
  publishableKey="pk_live_…"
  getHeaders={async () => ({ Authorization: `Bearer ${await getToken()}` })}
  storageKey={user.id}
/>

getToken is your login SDK's call, for example getAccessTokenSilently() in Auth0 or getToken() in Clerk. The chat calls it before every request, so it never sends an expired token. storageKey keeps each user's last conversation apart when several people share a browser.

The agent checks every request:

  • JWKS: the token is a JWT signed with a key from your JWKS URL, with an asymmetric algorithm you allowed (RS, PS, ES or EdDSA; never none or HS256). iss must equal the issuer, aud must contain the audience, and exp must be present and in the future (60 seconds of clock skew). The user is the claim you chose, sub by default. The JWKS is cached for ten minutes; a token with a new key ID fetches it again, at most once a minute.
  • Userinfo URL: the agent calls your userinfo URL with the token. A 2xx JSON answer names the user in the field you chose; 401 or 403 refuses the token. The answer is cached for the time you set, 0 to 300 seconds, so a revoked token can keep working that long.

A missing, malformed or refused token gets 401 {"error":"unauthorized"}, whatever the cause. The cause is in the agent's audit log: no_token, bad_token, token_rejected, jwks_unavailable or userinfo_unavailable. When the JWKS or the userinfo URL cannot be reached, every request is refused.

Your API receives Authorization: Bearer <token> and no other header from the browser. Cookies are never forwarded, so an API that accepts only a session cookie cannot be called as the user.

Threads and approvals belong to the user who started them. The user ID is the issuer (or the userinfo URL's origin) plus the claim, so two issuers with the same sub stay apart.

Knowledge-only agents

Knowledge-only agents work without a spec. They answer only from their knowledge and refuse unrelated questions. This documentation is the knowledge for the assistant on the home page, which is a sideagent Cloud agent.

Public agents and abuse limits

An anonymous agent is open to anyone who can load your page, so sideagent Cloud limits it:

  • Origin and key. Requests must come from an origin you allowed and carry the publishable key. Both are public, so they stop mistakes, not a script that sets the same headers.
  • Rate limit. Ten requests a minute for each visitor address and agent.
  • Daily cap. Anonymous visitors share a daily message cap for each agent. When it is reached, or when your account's monthly cap for the built-in model is reached, the agent answers 429 {"error":"limit_reached"}.
  • Hidden chat. The agent reports when it is out of messages. With sideagent 0.8.0 or later the chat then renders nothing, launcher included, until the next page load. Under AgentProvider, useAgent().available is false, so you can hide your own "ask" button.
  • Output length. A reply from the built-in model is cut at a fixed number of tokens.

Bot challenge

sideagent Cloud can also require a Cloudflare Turnstile challenge from anonymous visitors. The visitor does nothing in most cases: the challenge runs in the background, and a box appears only when Cloudflare needs the visitor to interact. Once solved, a token valid for 30 minutes goes in the x-sideagent-challenge header of every request. A request without a valid token gets 403 {"error":"challenge_required"}.

The chat does not do this on its own. Copy the helper below into your app and pass its result as getHeaders (sideagent 0.5.0 or later). It does nothing when the agent asks for no challenge, so adding it before the challenge is switched on is safe. It costs one extra agent.info request per page load.

// challenge-headers.ts
import { parsePublishableKey, publishableEndpoint, PUBLISHABLE_KEY_HEADER } from "sideagent/keys";

const CHALLENGE_HEADER = "x-sideagent-challenge";
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

type Turnstile = {
  render(el: HTMLElement, o: { sitekey: string; appearance: "interaction-only"; callback: (t: string) => void; "error-callback": () => void; "timeout-callback": () => void }): string;
  remove(id: string): void;
};

let loading: Promise<Turnstile> | undefined;
const loadTurnstile = () =>
  (loading ??= new Promise<Turnstile>((resolve, reject) => {
    const el = document.createElement("script");
    el.src = SCRIPT;
    el.async = true;
    el.onload = () => ((window as any).turnstile ? resolve((window as any).turnstile) : reject(new Error("turnstile missing")));
    el.onerror = () => reject(new Error("turnstile blocked"));
    document.head.appendChild(el);
  }).catch((e) => ((loading = undefined), Promise.reject(e))));

async function solve(siteKey: string): Promise<string> {
  const turnstile = await loadTurnstile();
  const box = document.createElement("div");
  box.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:2147483647";
  document.body.appendChild(box);
  let id: string | undefined;
  try {
    return await new Promise<string>((resolve, reject) => {
      id = turnstile.render(box, {
        sitekey: siteKey,
        appearance: "interaction-only",
        callback: resolve,
        "error-callback": () => reject(new Error("challenge failed")),
        "timeout-callback": () => reject(new Error("challenge timed out")),
      });
    });
  } finally {
    if (id !== undefined) turnstile.remove(id);
    box.remove();
  }
}

export function challengeHeaders(publishableKey: string): () => Promise<Record<string, string>> {
  const parts = parsePublishableKey(publishableKey);
  if (!parts) throw new Error("invalid publishable key");
  const endpoint = publishableEndpoint(parts);
  const post = (body: unknown) =>
    fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", [PUBLISHABLE_KEY_HEADER]: publishableKey }, body: JSON.stringify(body) });

  let siteKey: Promise<string | null> | undefined;
  let token: { value: string; expires: number } | null = null;
  let running: Promise<void> | undefined;

  const wanted = () =>
    (siteKey ??= post({ action: "agent.info" })
      .then(async (r) => (r.ok ? (((await r.json()) as { challenge?: { siteKey?: string } }).challenge?.siteKey ?? null) : null))
      .catch(() => null));

  const refresh = (key: string) =>
    (running ??= (async () => {
      const r = await post({ action: "challenge.verify", token: await solve(key) });
      if (!r.ok) return;
      const j = (await r.json()) as { challengeToken?: string; expiresIn?: number };
      if (typeof j.challengeToken === "string") token = { value: j.challengeToken, expires: Date.now() + Number(j.expiresIn) * 1000 };
    })()
      .catch(() => {})
      .finally(() => (running = undefined)));

  return async () => {
    const key = await wanted();
    if (!key) {
      siteKey = undefined; // ask again next time, in case the lookup failed
      return {};
    }
    if (!token || token.expires - 60_000 < Date.now()) await refresh(key);
    const headers: Record<string, string> = {};
    if (token && token.expires > Date.now()) headers[CHALLENGE_HEADER] = token.value;
    return headers;
  };
}
const getHeaders = useMemo(() => challengeHeaders("pk_live_…"), []);

<AgentChat publishableKey="pk_live_…" getHeaders={getHeaders} />

If your site sets a Content-Security-Policy, allow https://challenges.cloudflare.com in script-src and frame-src. An agent that verifies user tokens never asks for a challenge; only anonymous visitors are challenged. If you already pass getHeaders for a token, merge the two results.

Status

sideagent Cloud is in development. The dashboard, agent configuration and knowledge indexing are available. The agent endpoint serves anonymous, knowledge-only agents, and agents with token verification: API calls and approvals with the built-in model, history for signed-in users. Not yet available: public operations for anonymous users, and API calls with your own LLM key.