# sideagent Cloud

> Versi ter-hosting: konfigurasikan agent di dasbor dan embed dengan publishable key.

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

sideagent Cloud menjalankan agent untuk Anda. Anda tidak perlu meng-hosting server atau menulis kode server.

## Menyiapkan agent

1. [Masuk](https://sideagent.dev/app) dengan email Anda. Anda akan menerima tautan masuk (magic link).
2. Buat agent. Setiap agent memiliki konfigurasi, riwayat, batasan, dan audit log sendiri.
3. Tambahkan instruksi, knowledge source (URL atau teks yang ditempel).
4. Pilih LLM: model built-in, atau host apa pun yang kompatibel dengan OpenAI dengan API key Anda sendiri. Key dienkripsi saat disimpan dan hanya ditampilkan empat karakter terakhirnya.
5. Pilih cara pengguna dikenali: token verification (JWKS atau userinfo URL), atau anonim untuk chat publik yang hanya menjawab dari knowledge.
6. Tambahkan origin yang boleh meng-embed chat, lalu aktifkan agent.

## Key

Setiap agent memiliki satu publishable key yang aman di browser.

Publishable key menyebutkan agent dan host sideagent Cloud. Berikan key ini ke chat (sideagent 0.4.0 atau lebih baru):

```tsx
<AgentChat publishableKey="pk_live_…" />
```

Chat mengirim request ke agent yang disebut oleh key dan menyertakan key di header `x-sideagent-key`. Setiap request ke endpoint agent membutuhkannya: tanpa key jawabannya `401`, dan dengan key untuk agent atau host lain jawabannya `404`, sama seperti agent yang tidak ada. Key ini sendiri tidak membuktikan apa pun.

## Pengguna yang masuk

Dengan token verification, chat mengirim token login pengguna Anda, dan agent memakainya untuk mengetahui siapa yang bertanya, sehingga setiap pengguna punya riwayatnya sendiri. Anda tidak menulis kode server. Berikan fungsi yang mengembalikan token saat ini (sideagent 0.5.0 atau lebih baru):

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

`getToken` adalah fungsi SDK login Anda, misalnya `getAccessTokenSilently()` pada Auth0 atau `getToken()` pada Clerk. Chat memanggilnya sebelum setiap request, sehingga token yang kedaluwarsa tidak pernah dikirim. `storageKey` memisahkan percakapan terakhir setiap pengguna ketika beberapa orang memakai browser yang sama.

Agent memeriksa setiap request:

- **JWKS**: token adalah JWT yang ditandatangani dengan key dari URL JWKS Anda, dengan algoritma asimetris yang Anda izinkan (RS, PS, ES, atau EdDSA; tidak pernah `none` atau HS256). `iss` harus sama dengan issuer, `aud` harus memuat audience, dan `exp` harus ada dan belum lewat (toleransi selisih jam 60 detik). Pengguna adalah claim yang Anda pilih, secara default `sub`. JWKS di-cache selama sepuluh menit; token dengan key ID baru mengambilnya lagi, paling sering sekali per menit.
- **Userinfo URL**: agent memanggil URL userinfo Anda dengan token tersebut. Jawaban JSON `2xx` menyebutkan pengguna pada field yang Anda pilih; `401` atau `403` menolak token. Jawaban di-cache selama waktu yang Anda atur, 0 sampai 300 detik, sehingga token yang di-revoke dapat tetap berlaku selama itu.

Token yang tidak ada, rusak, atau ditolak mendapat `401 {"error":"unauthorized"}`, apa pun penyebabnya. Penyebabnya ada di audit log agent: `no_token`, `bad_token`, `token_rejected`, `jwks_unavailable`, atau `userinfo_unavailable`. Ketika JWKS atau URL userinfo tidak dapat dijangkau, setiap request ditolak.

Token hanya dipakai untuk mengenali pengguna. Agent tidak memanggil API Anda.

Thread milik pengguna yang memulainya. User ID adalah issuer (atau origin URL userinfo) ditambah claim, sehingga dua issuer dengan `sub` yang sama tetap terpisah.

## Sumber jawaban agent

Agent hanya menjawab dari knowledge-nya dan menolak pertanyaan di luar topik. Dokumentasi ini adalah knowledge bagi asisten di [beranda](https://sideagent.dev/id), yang merupakan agent sideagent Cloud.

## Agent publik dan batas penyalahgunaan

Agent anonim terbuka untuk siapa pun yang dapat memuat halaman Anda, sehingga sideagent Cloud membatasinya:

- **Origin dan key.** Permintaan harus datang dari origin yang Anda izinkan dan membawa publishable key. Keduanya publik, jadi hanya mencegah kesalahan, bukan skrip yang mengirim header yang sama.
- **Batas laju.** Sepuluh permintaan per menit untuk setiap alamat pengunjung dan agent.
- **Batas harian.** Pengunjung anonim berbagi batas pesan harian untuk setiap agent. Jika batas itu tercapai, atau batas bulanan akun Anda untuk model built-in tercapai, agent menjawab `429 {"error":"limit_reached"}`.
- **Chat disembunyikan.** Agent melaporkan saat pesannya habis. Dengan sideagent 0.8.0 atau lebih baru, chat tidak ditampilkan sama sekali, termasuk tombol peluncurnya, sampai halaman dimuat ulang. Di dalam `AgentProvider`, `useAgent().available` bernilai `false`, sehingga Anda dapat menyembunyikan tombol "tanya" Anda sendiri.
- **Panjang jawaban.** Jawaban dari model built-in dipotong pada jumlah token yang tetap.

### Tantangan bot

sideagent Cloud juga dapat mewajibkan tantangan [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) dari pengunjung anonim. Pada umumnya pengunjung tidak perlu melakukan apa pun: tantangan berjalan di latar belakang, dan kotak baru muncul bila Cloudflare memerlukan interaksi pengunjung. Setelah selesai, token yang berlaku 30 menit dikirim di header `x-sideagent-challenge` pada setiap permintaan. Permintaan tanpa token yang valid mendapat `403 {"error":"challenge_required"}`.

Chat tidak melakukan ini sendiri. Salin helper di bawah ke aplikasi Anda dan berikan hasilnya sebagai `getHeaders` (sideagent 0.5.0 atau lebih baru). Helper ini tidak melakukan apa pun jika agent tidak meminta tantangan, sehingga aman ditambahkan sebelum tantangan diaktifkan. Biayanya satu permintaan `agent.info` tambahan setiap halaman dimuat.

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

Jika situs Anda memakai Content-Security-Policy, izinkan `https://challenges.cloudflare.com` di `script-src` dan `frame-src`. Agent yang memverifikasi token pengguna tidak pernah meminta tantangan; hanya pengunjung anonim yang ditantang. Jika Anda sudah memakai `getHeaders` untuk token, gabungkan kedua hasilnya.

## Status

sideagent Cloud sedang dalam pengembangan. Dasbor, konfigurasi agent, dan indexing knowledge sudah tersedia. Endpoint agent melayani agent anonim, dan agent dengan token verification yang mendapat riwayat untuk pengguna yang masuk.
