# sideagent Cloud

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

Source: https://sideagent.dev/docs/cloud

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

## Set up an agent

1. [Sign in](https://sideagent.dev/app) 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):

```tsx
<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):

```tsx
<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](https://sideagent.dev/), 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](https://developers.cloudflare.com/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.

```ts
// 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;
  };
}
```

```tsx
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.
