# Yak Documentation — Full Text
> Source: https://docs.yak.io — see https://docs.yak.io/llms.txt for a linked index.
# Introduction
URL: https://docs.yak.io/docs
Yak is an embeddable AI assistant that integrates directly into your web application. It connects to your data layer, understands your application's routes, and can take actions on behalf of your users — all through a simple chat interface.
All `@yak-io/*` packages are currently in **beta**. APIs may change before the stable v1 release, which is coming soon.
## Available SDKs
| Package | Description | Use When |
| --- | --- | --- |
| [`@yak-io/nextjs`](/docs/sdks/nextjs) | First-class Next.js support with automatic route scanning | Building with Next.js App Router |
| [`@yak-io/react`](/docs/sdks/react) | React components and hooks | Any React application |
| [`@yak-io/vue`](/docs/sdks/vue) | Vue 3 composables | Any Vue application |
| [`@yak-io/svelte`](/docs/sdks/svelte) | Svelte stores and provider | Any Svelte application |
| [`@yak-io/nuxt`](/docs/sdks/nuxt) | Nuxt 3 plugin and composables | Any Nuxt 3 application |
| [`@yak-io/angular`](/docs/sdks/angular) | Angular service and provider | Any Angular application |
| [`@yak-io/javascript`](/docs/sdks/javascript) | Core SDK and server handlers | Non-React frameworks or custom runtimes |
| [`@yak-io/trpc`](/docs/tool-adapters/trpc) | tRPC procedure adapter | Exposing tRPC procedures as AI tools |
| [`@yak-io/graphql`](/docs/tool-adapters/graphql) | GraphQL schema adapter | Exposing a GraphQL API as AI tools |
| [`@yak-io/rest`](/docs/tool-adapters/rest) | REST / OpenAPI adapter | Exposing a REST API as AI tools |
| [`@yak-io/prismic`](/docs/sdks/prismic) | Prismic content adapter | Making Prismic content queryable by the assistant |
`@yak-io/nextjs` includes `@yak-io/react` and `@yak-io/javascript` as dependencies — you only need to install one package.
## Integration Overview
Adding Yak to your application takes three steps:
1. **Install the SDK** for your framework
2. **Set up a server handler** that defines which routes and tools the AI can access
3. **Embed the widget** in your layout
```tsx
// 1. Install
// pnpm add @yak-io/nextjs
// 2. Server handler — app/api/yak/[[...yak]]/route.ts
// 3. Widget — app/layout.tsx
{children}
```
## What Can Yak Do?
- **Navigate users** — Understands your app's pages and can guide users to the right place
- **Execute actions** — Calls your APIs through [tool adapters](/docs/tool-adapters) to fetch data or perform operations
- **Contextual assistance** — Reads the current page context to provide relevant help
- **Talk, not just type** — Enable [voice mode](/docs/customization/voice) so users can speak to the assistant; tool calls flow through the same handlers
- **Remember signed-in users** — Pass a signed [customer identity](/docs/customization/end-user-identity) and Yak persists conversations per end-user so they can resume past chats
- **Customizable appearance** — Match your brand with [theming and styling](/docs/customization/styling)
- **Programmatic control** — [Open the widget, send prompts](/docs/customization/programmatic-control), and react to tool calls from your code
## Next Steps
Pick your framework to get started:
- [Next.js](/docs/sdks/nextjs) — Recommended for Next.js applications
- [React](/docs/sdks/react) — For any React application
- [Vue](/docs/sdks/vue) — For Vue 3 applications
- [Svelte](/docs/sdks/svelte) — For Svelte applications
- [Nuxt](/docs/sdks/nuxt) — For Nuxt 3 applications
- [Angular](/docs/sdks/angular) — For Angular applications
- [JavaScript](/docs/sdks/javascript) — For non-React frameworks or custom setups
Or explore specific topics:
- [Tool Adapters](/docs/tool-adapters) — Connect your APIs and data sources
- [End-user identity](/docs/customization/end-user-identity) — Persist conversations per signed-in user
- [Styling](/docs/customization/styling) — Customize the widget appearance
- [Security](/docs/reference/security) — Security model and best practices
---
# Troubleshooting
URL: https://docs.yak.io/docs/troubleshooting
## Widget Issues
### Widget doesn't appear
1. **Check your app ID** – Ensure `NEXT_PUBLIC_YAK_APP_ID` or equivalent is set correctly (find your app ID)
2. **Verify YakWidget is inside YakProvider** – The widget must be a child of the provider
3. **Check your domain is allowed** – If the widget renders but shows "This chatbot is not authorized to run on this domain", add the origin to your allowlist (Allowed Origins)
4. **On Vue, Svelte, Angular, or Nuxt: pass `trigger: true`** – These SDKs don't render a launcher by default, so the widget mounts with no button to open it
5. **Check for JavaScript errors** – Open browser console for any errors
```tsx
// ✓ Correct
// ✗ Wrong - widget outside provider
{children}
```
### Widget loads but doesn't respond
1. **Check API endpoints** – Verify your GET and POST handlers return correct responses
2. **Check network tab** – Look for failed requests to `/api/yak` or your config/tools endpoints
3. **Verify CORS** – If using separate origins, ensure CORS is configured
### Styles are broken
1. **Check CSS loading** – Ensure Tailwind/CSS is loaded in your layout
2. **Check z-index conflicts** – The widget uses high z-index values; ensure nothing is covering it
3. **Theme configuration** – Verify your theme prop is correctly structured
## API Handler Issues
### 404 on config/tools endpoints
1. **Check route path** – Ensure the route file matches your endpoint
- Next.js: `app/api/yak/[[...yak]]/route.ts`
- Remix: `app/routes/api.yak.ts`
2. **Check export names** – Handlers must export `GET` and `POST`
3. **Verify the path in client** – Match `getConfig` and `onToolCall` endpoints
### GET returns empty routes
1. **Check route sources** – Verify your route array or sources are populated
2. **For Next.js auto-scan** – Ensure `appDir` path is correct
3. **Check route filter** – Your include/exclude patterns may filter everything
### POST returns tool errors
1. **Check tool name** – Tool names are case-sensitive
2. **Verify tool is in manifest** – Confirm the tool appears in GET response
3. **Check input validation** – Ensure args match the tool's input schema
4. **Review executor logs** – Add logging to your `executeTool` function
## tRPC Adapter Issues
### Procedures not appearing
1. **Check allowedProcedures** – If using `allowedProcedures`, procedure must be in the whitelist
2. **Check disallowedProcedures** – If using `disallowedProcedures`, ensure procedure is not blocked
3. **Verify procedure path** – Use the full path (e.g., `orders.list`, not just `list`)
4. **Check router exports** – Ensure procedures are exported from your router
### Context errors
1. **Verify createContext** – Ensure it returns the expected shape
2. **Check authentication** – If procedures require auth, verify it's passed correctly
3. **Review tRPC errors** – Check server logs for validation or context errors
```ts
// Ensure your context factory handles missing request
createContext: async (opts) => {
// opts.req may be undefined in some contexts
return createContext(opts?.req);
}
```
## Programmatic API Issues
### openWithPrompt doesn't work
1. **Verify provider** – `useYak` must be called inside `YakProvider`
2. **Check timing** – Prompts are queued if called before iframe is ready
3. **Verify widget state** – Check `isOpen` to see current state
### Navigation doesn't work
1. **Provide onRedirect** – Without it, navigation falls back to `window.location.href`
2. **Use correct router** – Pass your router's navigate function
3. **Check path format** – Paths should start with `/`
```tsx
// React Router
navigate(path)} />
// Next.js
const router = useRouter();
router.push(path)} />
```
## Performance Issues
### Widget is slow to load
1. **Use lazy loading** – Consider `client:idle` in Astro or dynamic imports
2. **Check config endpoint** – Ensure GET handler responds quickly
3. **Minimize route/tool count** – Large manifests slow down processing
### Tool calls are slow
1. **Add caching** – Cache expensive operations where appropriate
2. **Batch requests** – Combine multiple database queries
3. **Check N+1 patterns** – Avoid fetching related data in loops
## Module Format Issues (ESM & CommonJS)
Every `@yak-io/*` package ships **both** ESM and CommonJS builds. Modern bundlers
(Vite, webpack, Next.js, Rollup, esbuild) and native ESM in Node pick the ESM build
automatically; CommonJS `require()` and most Jest setups pick the CommonJS build via
the package's `require` export condition (and its `main` field, for older tooling).
You don't need any special configuration.
### `SyntaxError: Unexpected token 'export'` in Jest
This means the test runner loaded the ESM build and tried to parse it as CommonJS.
With current Yak SDKs Jest 28+ resolves the CommonJS build on its own, so this should
no longer happen. If you still hit it on an older Jest or a custom resolver that forces
the `import` condition, allow Yak's packages through Jest's transform:
```js
// jest.config.js
module.exports = {
transformIgnorePatterns: ["node_modules/(?!(@yak-io)/)"],
};
```
### `ERR_REQUIRE_ESM` when calling `require("@yak-io/...")`
Current SDKs ship CommonJS, so a plain `require()` works. If you see this error you're
almost certainly on an old, ESM-only version — upgrade to the latest `@yak-io/*` release.
## Common Error Messages
### "Unknown tool: xxx"
The tool name in the POST request doesn't match any defined tool.
- Check tool names in your manifest
- Verify the correct adapter is handling the call
### "Invalid user hash"
The signed [end-user identity](/docs/customization/end-user-identity) didn't verify. Unlike session errors, the widget does **not** recover from this on its own — the user stays broken until the hash is right.
- **Most common cause: you rotated `apiSecret` but haven't redeployed.** Every hash signed with the old secret is now invalid. Redeploy your server with the new secret.
- Confirm you're signing the **user id only** — `HMAC-SHA256(apiSecret, userId)`, hex-encoded.
- Confirm the `id` you sign on the server is byte-for-byte the `id` you pass to the provider.
### "Origin not allowed for this application"
The page's origin isn't in the app's allowlist. Add it under Allowed Origins. In the widget this surfaces as "This chatbot is not authorized to run on this domain."
### Session token errors
`"Invalid session token"`, `"Session token expired"`, and `"Session token revoked"` are **recoverable** — the SDK silently mints a fresh token and retries, so users shouldn't see them. Seeing them repeatedly in logs is normal after a [sign-out-all-sessions](/docs/reference/security#session-revocation).
`"Session identity mismatch"` means a token bound to one end-user was presented for another. The SDK rotates the token automatically when the `user` prop changes; if you see this, check you aren't sharing one token across users (for example by caching it server-side).
### "Failed to fetch config"
The GET endpoint is unreachable.
- Check the endpoint URL in `getConfig`
- Verify the server is running
- Check for CORS issues
Still stuck? Check the browser console and server logs for more detailed error messages.
---
# Attachments
URL: https://docs.yak.io/docs/customization/attachments
Attachments let end users add files — images, PDFs, and text documents — to their chat messages, so the assistant can see and reason over what they share. A user might drop in a screenshot of an error, a PDF invoice, or a photo of their living room, then ask a question about it.
There's nothing to integrate on the host page: the widget renders the attach button and a drag-and-drop zone, uploads files to Yak's private CDN, and passes them to the assistant alongside the message. You control whether it's available **per application** from the dashboard.
## How it works
1. The user adds files via the **paperclip button** in the input bar or by **dragging and dropping** them onto the chat panel.
2. The widget uploads each file to Yak's private CDN through a short-lived signed URL, showing an upload progress indicator in a thumbnail strip.
3. On send, the ready files are attached to the message as file parts, and the assistant receives them along with the user's text.
Images and PDFs can be previewed in a side panel; other files show a file thumbnail.
## Enabling it
In the dashboard, open your application's Behavior settings and toggle **Enable attachments**. Attachments are **on by default**, including for applications created before this setting existed.
When the toggle is **off**:
- The widget hides the attach button and the drag-and-drop zone, so users have no way to add files.
- The upload endpoint (`/api/attachments/presign`) returns a `403`, so uploads are rejected server-side even if a client tries to bypass the UI.
Attachments apply to **chat mode** — disabling chat also disables uploads.
The toggle is a server-side gate the operator owns. It is **not** overridable from the host page's widget config, so a per-page integration can't re-enable uploads you've turned off.
## Limits
A few limits keep uploads bounded, enforced server-side regardless of the toggle:
- **File types** — images (PNG, JPEG, WebP, GIF), PDFs, and plain-text/Markdown files.
- **File size** — up to 25 MB per file.
- **Retention** — uploaded files are stored on Yak's private CDN, served only through signed CloudFront access, and automatically deleted after 7 days.
**Privacy.** Files a user uploads are stored on Yak's private CDN and passed to the model to answer the user's request. Don't enable attachments on applications where end users might share data you don't want sent to the model or retained for the upload window.
## Related
- [Image generation](/docs/customization/image-generation) builds on attachments — users upload a photo and the assistant composites your products into the scene.
---
# Conversation insights
URL: https://docs.yak.io/docs/customization/conversation-insights
Conversation insights give you an owner-facing view of how people use your assistant: the recurring **themes** they ask about, their **intents**, the **sentiment** of conversations and how they **end** (resolved, escalated, abandoned…). It's aggregate analysis for the business — you never see raw transcripts.
You enable it **per application** from the dashboard. It analyses conversations that are already stored, so it builds on [conversation storage](/docs/customization/conversation-storage). [Voice](/docs/customization/voice) conversations are analysed alongside text chat: each conversation is tagged with the **mode** it happened on — a **Chat** or **Voice** badge — and the dashboard breaks the period down by mode, so you can see the chat-vs-voice split at a glance.
## How it works
1. A conversation happens and is stored as normal.
2. About 15 minutes after the conversation starts, a background pass distills it once into a structured insight — a neutral summary plus short topic and intent labels, sentiment and outcome. This is analysis only: no card numbers, government ids, health data or credentials are ever recorded.
3. Across an application, near-duplicate topic labels are consolidated into canonical **themes** — so "order eta", "where's my order" and "delivery time" collapse into a single theme with a combined count.
Insights are derived data with their own retention and are independent of the underlying conversation: they outlive the raw transcript and can be kept even on shorter storage windows.
## Effort stats
Alongside the themes, the dashboard surfaces two operational stats so you can see how much work requests take, not just what they're about:
- **Average tool calls** — how many tools the assistant invokes to service a request, on average.
- **Average time to service** — how long a conversation runs end to end (first message to last).
Both appear as headline numbers for the period **and** broken down **per theme**, so you can compare request types — a "refund policy" question might resolve in one turn with no tools, while "track my order" averages several tool calls and a longer back-and-forth. The numbers are computed from stored conversations as each insight is distilled, and fill in for conversations processed from when you enable insights onward.
## Card actions
If your assistant surfaces product cards with action buttons (for example **Add** or **View**), the dashboard shows how those actions perform:
- **Surfaced** — how many action buttons were shown across the period, broken down by action, plus the average number of cards shown per conversation. This is counted deterministically from the stored conversations.
- **Clicked** — how many of each action your users actually clicked, captured live as the click happens (both the model-turn and the direct-navigation kinds).
- **Click-through** — clicked ÷ surfaced, per action and overall, so you can see which actions earn engagement and which are ignored.
### Top cards
Alongside the per-action totals, the dashboard ranks the **individual cards** your assistant showed. Each row is one card — how many times it was **shown**, how many times it was **clicked**, and the resulting click-through rate — and you can sort by any of those columns (it opens sorted by clicks). Cards that appear across many conversations roll up into a single row, so you see at a glance which products or results actually earn engagement.
Under each card, the clicks are **broken down by action**, so you can answer the specific question — for example *"the Nike Air Zoom was added to cart 32 times and viewed 8 times"* — not just that the card was engaged, but how.
The click-through rate is measured **per action button**, not per card: it divides clicks by the number of action buttons shown, so a card carrying two buttons counts two opportunities each time it appears. This keeps the rate comparable between cards that offer different numbers of actions.
#### Grouping the same card together
Cards roll up by a **stable id from your source data** — an `externalId` on the card — when one is present. This is the most reliable way to keep "the same item" together, whatever it is (a product, article, listing, course, booking…): it survives a title being reworded and never merges two different items that happen to share a title. The assistant populates `externalId` from your data's own id, SKU, or slug; if a card has no such id, grouping falls back to its **title** (case- and whitespace-normalised). It's optional and backward-compatible — cards without an `externalId` keep grouping by title.
The section only appears for applications that surface card grids. Card titles and ids are recorded in your own insights to build this ranking, but are never sent to third-party analytics; the prompt text a button injects is never recorded anywhere. Per-action breakdowns fill in for engagement from when this ships onward — a card's earlier clicks still count toward its total.
## Enabling it
In the dashboard, open your application's Insights settings and turn on **Conversation insights**. It requires:
- **Conversation storage** on — there's no source data to analyse otherwise.
- A **Growth plan or higher** — insights aren't part of the PAYG plan.
Once enabled, conversations that settle from then on are distilled automatically (it's forward-looking — past conversations aren't backfilled). View the results under **Insights** in the dashboard, where a **7-day / 30-day** toggle sets the window the themes, effort stats and distributions are rolled up over.
Insights are **insights only**. There is no endpoint or screen that exposes a conversation's verbatim transcript to the business — only the distilled summary, labels and aggregate themes.
## Privacy
Insights never process a conversation you didn't choose to store, and the distillation step is instructed to exclude sensitive identifiers. Everything is scoped to your business and the application it belongs to.
Customer memory is a separate, per-user feature — see [Customer memory](/docs/customization/customer-memory). Enabling insights does not enable memory, and vice-versa.
---
# Conversation storage
URL: https://docs.yak.io/docs/customization/conversation-storage
Conversation storage persists each chat so end users can revisit their history across sessions and devices. When it's on, the widget shows a **history** menu where users can reopen past conversations; signed-in users see their history follow them between devices. When it's off, nothing is stored — chat still works, it's just stateless — and the history menu is hidden.
You control this **per application** from the dashboard, including how long conversations are kept before they're deleted automatically.
## How it works
1. With storage **on**, each conversation and its messages are written as the chat progresses.
2. When a conversation is first created, an expiry is stamped on it from your **retention period** — e.g. a 6-month retention means the conversation (and all its messages) are deleted automatically about 6 months after it started.
3. The history menu lists a user's past conversations, most recently started first; they can reopen or delete any of them.
Retention is measured from a conversation's **creation time**, and the expiry is fixed when the conversation is created. Changing the retention period later only affects conversations created **afterward** — existing ones keep the window they were created with.
## Enabling it
In the dashboard, open your application's Data settings and turn on **Store conversations**. Enabling requires you to enter a **retention period** in months and save.
When storage is **off**:
- `/api/chat` writes no conversation or message rows — the chat is stateless.
- The history endpoints are closed off server-side, and the widget hides the history menu.
- Usage metering is **unaffected** — billing counts the messages users send the assistant, and that works exactly the same whether or not conversations are stored.
New applications ship with storage **off** by default (privacy-by-default). Applications that existed before this setting were enabled with a 3-month retention period.
Storage is a server-side gate the operator owns. It is **not** overridable from the host page's widget config, so a per-page integration can't re-enable storage you've turned off.
## Retention caps by plan
The retention period you can choose is capped by your plan:
| Plan | Maximum retention |
| --- | --- |
| PAYG | 3 months |
| Growth, Business, Scale, Enterprise | 24 months |
If you **downgrade** to a plan with a lower cap, any application set above the new cap is automatically clamped down to it. Conversations already stored keep their original expiry and are not deleted early.
## Turning storage off
Disabling storage stops new conversations from being written and closes off history immediately. Conversations already stored are **not** purged — they remain until their existing retention window elapses, then are deleted automatically.
Turning storage off also turns off [Conversation insights](/docs/customization/conversation-insights) and [Customer memory](/docs/customization/customer-memory). Both analyse stored conversations, so neither can run without storage — Yak switches them off for you rather than leaving them enabled with nothing to read. Turning storage back on does **not** re-enable them; switch them on again yourself.
---
# Customer memory
URL: https://docs.yak.io/docs/customization/customer-memory
Customer memory lets your assistant pick up where it left off with a **returning, identified user**. It distils durable facts and notable moments from a user's past conversations into a compact profile, then recalls that profile in the system prompt at the start of their next chat — so the assistant already knows they're on the Pro plan, prefer concise answers, or reported a bug last week, without them repeating themselves.
It applies only to **verified end-users** — see [End-user identity](/docs/customization/end-user-identity). Anonymous visitors have no cross-conversation identity, so memory never applies to them.
Memory spans both surfaces: [voice](/docs/customization/voice) conversations build and recall memory just like chat, so a user who talked to the assistant yesterday is remembered whether they return by voice or text.
## How it works
1. When an identified user's conversation settles, a background pass reads it **together with that user's current memory profile** and returns an updated profile — merging in new durable facts (each with a confidence) and notable moments, superseding anything that changed (a new plan, a preference they've dropped), and discarding transient or uncertain details. Sensitive identifiers (payment, government, health, credentials) are never recorded.
2. That single per-user profile is what's stored — one evolving "big picture" that grows across every conversation, not a window of the most recent few. Because each pass consolidates rather than appends, a fact you mentioned twenty conversations ago is still there if it's still true, while the whole profile stays compact.
3. On that user's next conversation, the profile is added to the assistant's instructions as a short **returning customer context** section.
The memory rolls forward as the user keeps chatting and goes stale only after a long period of inactivity.
Memory is injected so it can't degrade response caching — it lives in the per-conversation tail of the prompt, never the shared, cacheable part. You don't need to change anything for this to hold.
## Enabling it
In the dashboard, open your application's Customer settings and turn on **Customer memory**. It requires:
- **Conversation storage** on — there's no source data to distil otherwise.
- A **Growth plan or higher** — memory isn't part of the PAYG plan.
- A **verified end-user identity** on the chats you want remembered (see [End-user identity](/docs/customization/end-user-identity)).
Memory is forward-looking: it builds from conversations that settle after you enable it.
## Privacy
Memory is strictly scoped to one `(application, user)` and built only from conversations you chose to store. It never crosses applications or users, and anonymous conversations never contribute to anyone's memory.
Conversation insights are a separate, owner-facing feature — see [Conversation insights](/docs/customization/conversation-insights). Enabling memory does not enable insights, and vice-versa.
---
# End-user identity
URL: https://docs.yak.io/docs/customization/end-user-identity
Pass a signed user identity into the widget and Yak will attribute conversations to that user. When the same user returns — on a new device, or in a new browser — the history pane shows the chats they've had with you before, so they can resume one or start fresh.
Identity controls **who a conversation belongs to**, not whether it is stored. Storage is a separate per-application setting — see [Conversation storage](/docs/customization/conversation-storage), which is **off by default** on new applications. Without storage on, passing a user changes nothing that persists.
This is opt-in. If you don't pass a user, conversations are still stored when storage is on, but they're keyed to the visitor's **session** rather than an account: history follows the browser session and is lost when it resets, and it can't follow the person to another device.
## How it works
1. Your backend signs the user id with your application's API secret using `HMAC-SHA256`.
2. The signed identity is passed to `` in the browser.
3. The widget forwards `{ id, hash }` to Yak on every request that touches that user's data.
4. Yak verifies the hash against the secret and stamps the conversation with that user id, so it can be listed back to them later.
There is no separate "end-user" record to manage. An end-user exists exactly as far as their conversations do — verification is a pure HMAC check against your secret, with nothing stored about the person themselves.
The secret never leaves your server. The hash proves the browser is acting on behalf of a real account from your system — without it, anyone with an end-user's id could impersonate them.
This is the same identity-verification model used by Intercom and similar widget vendors. Only the **user id** is signed, so the identity is tamper-proof.
## Set it up
### Grab your API secret
Open your application's Customer settings and reveal the **End-user identity** card. Copy the secret somewhere safe — treat it like any other backend credential.
Store it as an environment variable on your server (never expose it to the browser):
```bash
YAK_API_SECRET=...
```
### Sign the user id on your server
Compute an HMAC-SHA256 hex digest of the user id using the secret:
```ts
const userHash = crypto
.createHmac("sha256", process.env.YAK_API_SECRET!)
.update(currentUser.id)
.digest("hex");
```
```python
user_hash = hmac.new(
os.environ["YAK_API_SECRET"].encode(),
current_user.id.encode(),
hashlib.sha256,
).hexdigest()
```
```ruby
require "openssl"
user_hash = OpenSSL::HMAC.hexdigest(
"SHA256",
ENV["YAK_API_SECRET"],
current_user.id,
)
```
```go
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"os"
)
mac := hmac.New(sha256.New, []byte(os.Getenv("YAK_API_SECRET")))
mac.Write([]byte(currentUser.ID))
userHash := hex.EncodeToString(mac.Sum(nil))
```
Send the hash to the browser however you already pass user data (server-rendered HTML, a session API, etc.).
### Pass the identity into the widget
Supply `{ id, hash }` when you set up the widget. React/Next take it as a prop; the other SDKs take it as an option to `createYakProvider`.
```tsx
{children}
```
```ts
const yak = createYakProvider({
appId: "your-app-id",
user: { id: currentUser.id, hash: currentUser.yakHash },
});
```
```ts
const yak = createYakProvider({
appId: "your-app-id",
user: { id: currentUser.id, hash: currentUser.yakHash },
});
```
```ts
const yak = createYakProvider({
appId: "your-app-id",
user: { id: currentUser.id, hash: currentUser.yakHash },
});
```
```ts
const yak = createYakProvider({
appId: "your-app-id",
user: { id: currentUser.id, hash: currentUser.yakHash },
});
```
That's it. Open the widget and you'll see a new history icon in the header — click it to browse past conversations or start a new one.
## Updating the user (login / logout)
Your end-user may sign in or out without a full page reload. Keep the widget's identity in sync so conversations follow the right account.
- **React / Next.js** are declarative — render `` with the new `user` prop (or omit it / pass `undefined` on logout) and the provider re-threads the identity for you.
- The **Vue / Svelte / Angular / Nuxt** SDKs return a handle from `createYakProvider`; call `setUser()` from your own reactivity (a Vue `watch`, Svelte `$effect`, or Angular `effect`) whenever the signed-in user changes. Pass `undefined` to log out.
```ts
watch(currentUser, (u) => {
yak.setUser(u ? { id: u.id, hash: u.yakHash } : undefined);
});
```
```ts
$effect(() => {
yak.setUser($currentUser ? { id: $currentUser.id, hash: $currentUser.yakHash } : undefined);
});
```
```ts
// `currentUser` is a signal; re-run whenever it changes
effect(() => {
const u = currentUser();
yak.setUser(u ? { id: u.id, hash: u.yakHash } : undefined);
});
```
```ts
watch(currentUser, (u) => {
yak.setUser(u ? { id: u.id, hash: u.yakHash } : undefined);
});
```
Switching to a **different** id (or logging out) rotates the stored session token, so one user's session is never reused under another account. The **first** sign-in after anonymous browsing keeps the session, so the visitor's pre-login conversation is claimed by their new account.
## The `user` prop
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Stable user id from your system. HMAC-protected. |
| `hash` | `string` | Yes | Hex HMAC-SHA256 of `id` signed with your application's API secret. |
## What the end-user sees
Whenever [conversation storage](/docs/customization/conversation-storage) is on:
- A menu button (the hamburger) sits in the chat header.
- Opening it reveals **Conversation history**, which lists past conversations, most recently started first.
- Selecting a conversation re-hydrates the chat with the persisted messages — the user can keep talking right where they left off.
- **New conversation** in the same menu starts a fresh one without losing the old ones.
- Each conversation in the history list has a trash icon for removing it (and its messages) permanently.
Passing `user` doesn't add this UI — it changes **which** conversations the list can contain.
## Anonymous vs identified
The history pane appears whenever storage is on, signed in or not. What differs is the key the conversations hang off:
| | Anonymous | Identified (`user` passed) |
| --- | --- | --- |
| Conversations stored | Yes, when storage is on | Yes, when storage is on |
| Keyed to | The browser session | Your user id |
| Survives a session reset | No | Yes |
| Follows the user to another device | No | Yes |
If you don't pass `user`, conversations are **still written server-side** when storage is on — they're just session-keyed and anonymous. "No user" does not mean "nothing stored". To store nothing at all, turn [conversation storage](/docs/customization/conversation-storage) off.
You can mix the two modes inside the same application — for example, anonymous chat on a public marketing page and signed-in chat in your dashboard.
## Rotating the secret
If you suspect the secret has leaked, rotate it from the dashboard. Rotation invalidates every previously signed hash, so deployed integrations will fail until you re-deploy with the new value. Plan the rotation alongside a deploy of your server code.
To force every current visitor onto a fresh session **without** touching your identity signing (no redeploy needed), use **Sign out all sessions** instead — see [Session revocation](/docs/reference/security#session-revocation).
## Security model
- **What's signed**: only the user `id`. A holder of the secret can produce a valid identity for any user id, so keep the secret on the server.
- **Where verification happens**: the endpoints that accept an identity re-verify the hash on every request and check the user's ownership of the conversation — `/api/chat` when `user` is present, the conversation history endpoints, and the voice session endpoint that mints a realtime token. There are no session cookies; the signed identity rides along on each request.
- **Voice transcripts**: `/api/voice/transcript` doesn't take a `user`. The identity is verified once, up front, when the voice session is minted, and the transcript is authorized by the voice session id — which only counts as a write key for a session your application actually minted. The user id is read from that stored session rather than re-supplied by the browser.
For the broader security context, see the [Security](/docs/reference/security) page.
---
# Image Generation
URL: https://docs.yak.io/docs/customization/image-generation
Image generation lets the assistant produce **product-mockup images**: a user uploads a photo — their living room, their bedroom, or themselves — and the assistant edits that photo to composite your products into the scene, preserving its real layout, lighting, and perspective. It builds on the widget's existing file-upload support, so there's nothing extra to integrate on the host page — you enable it per application in the dashboard.
Image generation is a **paid, metered capability**. Each generated image counts toward your plan's monthly image allowance and is billed per image beyond that (see [Billing](#billing)). It is **off by default** — enable it explicitly per application.
## How it works
1. The user uploads a photo through the widget (the same paperclip/attachment flow used for any image or document).
2. They ask for a mockup — e.g. _"show me this quilt on my bed"_ or _"put this jacket on me"_.
3. The assistant edits the uploaded photo and returns a generated image, which renders inline in the conversation and persists in history.
The model only ever **edits the user's uploaded photo** — it preserves the existing scene and composites your product in. It will not invent unrelated brands, and it never adds, removes, or swaps the people in the photo. It can dress or re-dress a person who is already there, so a customer can see a garment on themselves.
## Your product photo is the source of truth
The assistant chooses **which** product image to render, but never describes it. Colour, material, pattern and shape are taken from the photo's pixels, and the product's name, `colour` field and description are deliberately kept out of the renderer entirely.
This matters because catalog text and catalog photography drift apart in practice — a stale colour field, a colourway whose hero shot shows a different variant, a lifestyle image of last season's cut. When they disagree, the renderer follows the photograph.
**If a product's metadata disagrees with its photograph, the photograph is what your customer sees.** To change what a mockup looks like, change the product image the assistant is given — editing the product's description or colour field will not affect the render.
For products sold in several colourways, give each variant its own image so the assistant can pick the right one when a customer asks for "the blue one".
### Where the source image may come from
Your product data can be any shape — the assistant reads whatever your tools return and finds the image field itself, whether you call it `images`, `media`, `photos` or something else. Two rules are enforced server-side regardless of shape:
- **It must come from one of your tools.** Only image URLs returned by a product lookup in that conversation can be rendered. A URL the assistant wrote from memory, guessed from a URL pattern, or read off the page is refused.
- **It must belong to the product being rendered.** Lookups often return related or recommended products alongside the one asked for. The assistant names the product it is rendering, and an image taken from a neighbouring record in the same response is refused rather than rendered.
If your media lives outside the product record — a separate assets collection, say — the second check can't be applied and the render proceeds on the first alone.
## Enabling it
In the dashboard, open your application's Behavior settings and turn on **Enable image generation**. While the toggle is off, the assistant has no access to the image tool and cannot generate images.
### Quality
Once enabled, choose a **quality** tier:
| Quality | Best for | Relative cost |
| --- | --- | --- |
| Low | Fast previews | Lowest |
| Medium | Everyday mockups | Medium |
| **High** *(default)* | Marketing-grade mockups | Highest |
You control the quality tier — the model never does. Every generated image is billed at the same per-image rate for your plan regardless of the quality you pick, so a lower tier only reduces latency and the underlying generation cost, not your invoice line.
Higher quality produces more convincing mockups but takes longer to generate. Start with **Medium** if latency matters more than fidelity for your use case.
## Guardrails
A few limits keep cost and abuse bounded, enforced server-side:
- **Per-conversation cap** — once a conversation has generated several images, the tool is withheld for the rest of that conversation.
- **Per-turn cap** — a single assistant turn generates at most a small number of images.
- **Rate limiting** — image-capable turns are rate-limited per client; over the limit, the assistant continues as text-only rather than failing.
## Billing
Image generation is its own metered dimension, separate from AI messages and voice. Each plan includes a monthly image allowance; usage beyond it is billed at the plan's per-image overage rate. Image generation is available on **every plan**, including PAYG.
| Plan | Included images / mo | Overage |
| --- | --- | --- |
| PAYG | 0 | $0.45 / image |
| Growth | 100 | $0.45 / image |
| Business | 400 | $0.40 / image |
| Scale | 1,000 | $0.35 / image |
| Enterprise | Custom | Custom |
You can track image usage on the **Usage** page and see your included allowance, usage, and any overage on the **Billing** page.
**Privacy.** Photos a user uploads are sent to the image model to generate the mockup and are stored on Yak's private CDN, served only through signed CloudFront access. Generated mockups are retained for 30 days so they remain visible in conversation history, then automatically deleted. Uploaded source photos follow the standard 7-day chat-upload retention.
---
# Greeting & Intro
URL: https://docs.yak.io/docs/customization/intro
By default both surfaces open with an AI-written greeting: the chat widget asks the assistant to introduce itself when it opens, and voice mode speaks a short greeting when the session connects. That greeting is helpful, but it isn't free — every open costs a model call (and, for voice, voice minutes). The **Intro** setting lets you choose how each surface opens.
## Modes
| Mode | Behaviour | Cost |
| --- | --- | --- |
| `Generated` *(default)* | The assistant writes a fresh greeting each session. | A model call per open; voice minutes for the spoken greeting. |
| `Custom message` | A fixed message you provide is used every time. | Chat renders it instantly with **no model call**. Voice speaks it verbatim (still uses voice minutes). |
| `No intro` | The widget opens silent and waits for the user to speak or type. | None — no greeting is generated. |
Chat and voice are configured **independently**. A common setup is a generated chat greeting with no spoken voice intro, so voice sessions don't burn minutes before the user has said anything.
## Configure it
Open your application's intro settings and set the mode for **Chat** and **Voice** separately. When you choose **Custom message**, a text box appears — enter your message and click **Save** to apply it. Voice settings apply once voice mode is enabled under [Modes](/docs/customization/voice).
Leaving the setting untouched keeps today's behaviour (a generated greeting), so existing applications are unchanged until you opt in.
A custom voice greeting is spoken by the realtime model, so it still consumes voice minutes — it's predictable, not free. Choose **No intro** if you want voice sessions to stay silent until the user speaks.
## Next steps
- [Voice Mode](/docs/customization/voice) — enable voice and pick the language and voice
- [Styling](/docs/customization/styling) — tune the widget's appearance
---
# Programmatic Control
URL: https://docs.yak.io/docs/customization/programmatic-control
Control the chat widget programmatically from anywhere in your application using the `useYak` hook.
## Basic Usage
```tsx
// or: import { useYak } from "@yak-io/react";
function MyComponent() {
const { open, close, openWithPrompt, isOpen } = useYak();
return (
{isOpen && }
);
}
```
## useYak API
| Property | Type | Description |
| --- | --- | --- |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `isOpen` | `boolean` | Whether the chat panel is currently open |
| `isReady` | `boolean` | Whether the chat iframe has loaded and can receive messages |
| `chatLoading` | `boolean` | `isOpen && !isReady` — the panel is opening but not yet interactive |
Prompts sent via `openWithPrompt` are automatically queued if the widget isn't ready yet.
## Custom Loading State
When you replace the built-in trigger with your own button, use `chatLoading` to show a spinner while the chat iframe boots. It's `true` from the moment the panel opens until the iframe is ready — so you don't have to derive `isOpen && !isReady` by hand.
```tsx
const { open, close, isOpen, chatLoading } = useYak();
return (
);
}
```
Voice exposes the same idea: `voiceLoading` is `true` while a session is connecting. See [Voice Mode](/docs/customization/voice).
## Common Patterns
### Context-Sensitive Help
Open with prompts tailored to the current page:
```tsx
"use client";
const { openWithPrompt } = useYak();
const pathname = usePathname();
const getHelpPrompt = () => {
if (pathname.includes("/billing")) return "Help me with billing";
if (pathname.includes("/settings")) return "Guide me through settings";
return "Help me with this page";
};
return (
);
}
```
### Error Assistance
Offer AI help when errors occur:
```tsx
function ErrorFallback({ error }: { error: Error }) {
const { openWithPrompt } = useYak();
return (
);
}
```
### Keyboard Shortcuts
Toggle the chat with a keyboard shortcut:
```tsx
function useYakShortcut() {
const { open, close, isOpen } = useYak();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
isOpen ? close() : open();
}
if (e.key === "Escape" && isOpen) {
close();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [open, close, isOpen]);
}
```
### Help Menu
Create a help menu with predefined prompts:
```tsx
const helpTopics = [
{ label: "Getting Started", prompt: "Show me how to get started" },
{ label: "Account Settings", prompt: "Help me configure my account" },
{ label: "Billing Questions", prompt: "I have questions about billing" },
];
function HelpMenu() {
const { openWithPrompt } = useYak();
return (
How can we help?
{helpTopics.map((topic) => (
))}
);
}
```
## Best Practices
**Use specific, contextual prompts:**
```tsx
// Good — specific and actionable
openWithPrompt("How do I export my project data to CSV?");
// Less effective — too vague
openWithPrompt("Help");
```
**Avoid auto-opening without user action:**
```tsx
// Good — user-initiated
// Use sparingly — only for critical situations
useEffect(() => {
if (isCriticalError) {
openWithPrompt("Help me resolve this error");
}
}, [isCriticalError]);
```
Don't include sensitive data in prompts (passwords, tokens, personal information).
---
# Styling & Theming
URL: https://docs.yak.io/docs/customization/styling
## Theme Options
Customize the widget through the `theme` prop on `YakProvider`:
```tsx
```
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `position` | `WidgetPosition` | `"bottom-right"` | Widget position on screen |
| `colorMode` | `"light"` \| `"dark"` \| `"system"` | `"system"` | Color mode preference |
| `displayMode` | `"chatbox"` \| `"drawer"` | `"chatbox"` | Floating panel or full-height side drawer |
| `light` | `ThemeColors` | — | Color customization for light mode |
| `dark` | `ThemeColors` | — | Color customization for dark mode |
## Position Options
| Position | Description |
| --- | --- |
| `"top-left"` | Top-left corner |
| `"top-center"` | Top center |
| `"top-right"` | Top-right corner |
| `"left-center"` | Left side, vertically centered |
| `"right-center"` | Right side, vertically centered |
| `"bottom-left"` | Bottom-left corner |
| `"bottom-center"` | Bottom center |
| `"bottom-right"` | Bottom-right corner (default) |
## Custom Colors
Customize colors for light and dark modes separately:
```tsx
```
### ThemeColors Properties
| Property | Type | Description |
| --- | --- | --- |
| `background` | `string` | Main background color of the chat panel |
| `border` | `string` | Border color for the panel and elements |
| `messageBackground` | `string` | User message bubble background |
| `placeholderColor` | `string` | Input placeholder text color |
| `submitButtonColor` | `string` | Submit button background color |
| `submitButtonTextColor` | `string` | Submit button text/icon color |
| `headerIconColor` | `string` | Header icon color (restart, close buttons) |
## Display Modes
### Chatbox (Default)
A floating panel that appears near the trigger button:
```tsx
```
### Drawer
A full-height side panel that slides in from the edge of the screen:
```tsx
```
For drawer mode, the `position` prop controls which side:
- Positions containing `"left"` → drawer slides in from the left
- Positions containing `"right"` or center → drawer slides in from the right
The drawer becomes full-width on mobile screens (below 640px).
### Customizing Drawer Width
The panel is 500px wide by default. Override it with CSS:
```css
.yak-panel-container.yak-panel-drawer {
width: 600px !important;
}
@media (min-width: 768px) {
.yak-panel-container.yak-panel-drawer {
width: 720px !important;
}
}
```
## Trigger Button
Customize the trigger button appearance with `lightButton` and `darkButton` props:
```tsx
```
The trigger is icon-only — it renders your logo and the mode icons, with no text label. Colors are the only thing to customize here.
| Property | Type | Description |
| --- | --- | --- |
| `background` | `string` | Button background color |
| `color` | `string` | Button text/icon color |
| `border` | `string` | Button border color |
Or replace the trigger entirely with a custom button:
```tsx
function CustomTrigger() {
const { open, isOpen, chatLoading } = useYak();
if (isOpen) return null;
return (
);
}
// In your layout — don't render YakWidget if using a custom trigger
{children}
```
## Color Mode
```tsx
// Force light mode
// Force dark mode
// Follow system preference (default)
```
---
# UI Synchronization
URL: https://docs.yak.io/docs/customization/ui-synchronization
When the AI assistant executes tool calls that modify data (e.g., updating an order, creating a record), you may need to refresh parts of your UI. Yak provides hooks and callbacks to react to completed tool calls.
## React Hook: useYakToolEvent
Subscribe to tool call completion events from any component inside `YakProvider`:
```tsx
// or: import { useYakToolEvent } from "@yak-io/react";
function OrderPage({ orderId }: { orderId: string }) {
const queryClient = useQueryClient();
useYakToolEvent((event) => {
if (event.ok && event.name.startsWith("order.")) {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
}
});
return ;
}
```
The hook automatically unsubscribes when the component unmounts, making it safe for page-specific invalidation.
`useYakToolEvent` fires for **every** tool that flows through `onToolCall` — host functions,
tRPC, and the [GraphQL](/docs/tool-adapters/graphql) / [REST](/docs/tool-adapters/rest)
adapters alike. GraphQL/REST tools are named `graphql_` / `rest_`, so you can match
them with `event.name.startsWith("graphql_")`.
## Event Object
| Property | Type | Description |
| --- | --- | --- |
| `name` | `string` | The tool name that was called (e.g., `"order.cancel"`) |
| `args` | `unknown` | The arguments passed to the tool |
| `ok` | `boolean` | Whether the call succeeded |
| `result` | `unknown` | The result (if `ok` is true) |
| `error` | `string` | The error message (if `ok` is false) |
## tRPC Example
Invalidate tRPC queries when related tools are called:
```tsx
function PlanPage({ planId }: { planId: string }) {
const utils = trpc.useUtils();
useYakToolEvent((event) => {
if (event.ok) {
if (event.name.startsWith("plan.")) {
utils.plan.get.invalidate({ id: planId });
}
if (event.name.startsWith("planItem.")) {
utils.planItem.list.invalidate({ planId });
}
}
});
return ;
}
```
## JavaScript SDK: onToolCallComplete
For non-React integrations, use the `onToolCallComplete` callback on `YakClient`:
```ts
const client = new YakClient({
appId: "your-app-id",
onToolCall: async (name, args) => {
return executeToolCall(name, args);
},
onToolCallComplete: (event) => {
if (event.ok && event.name.startsWith("order.")) {
// Refresh your data
queryClient.invalidateQueries({ queryKey: ["orders"] });
}
},
});
```
Each page or component can subscribe to its own relevant events independently. This keeps synchronization logic co-located with the UI that needs refreshing.
---
# Voice Mode
URL: https://docs.yak.io/docs/customization/voice
Voice mode turns your product into a hands-free copilot. Users speak naturally and the assistant moves them through your routes and fires the same tool calls your APIs already expose — no separate widget, no separate integration. One trigger pill exposes a chat icon, a voice icon, or both, controlled by a single `mode` prop. Voice runs entirely on the host page over WebRTC against the OpenAI Realtime API; no extra iframe is mounted and tool calls flow through the same handlers as chat.
Voice and chat share the same `getConfig` and `onToolCall` handlers. Wire them up once on the provider and both surfaces inherit them.
## Enable voice on your application first
Voice is gated per application on the server. Turn on **Enable voice mode** in your application's Modes settings before wiring up the widget.
The `mode` prop below is only advisory — the server is the authority. If voice isn't enabled for the app, minting a voice session fails with a `403` (`Voice mode is not enabled for this application`) no matter what `mode` says.
Applications created before this setting existed are treated as **disabled** until you switch it on. If voice fails with a 403 on an older app, this is why.
## The `mode` prop
| Value | Trigger renders | Behaviour |
| --- | --- | --- |
| `"chat"` *(default)* | Logo + chat icon | Opens the chat iframe |
| `"voice"` | Logo + voice icon | Starts a voice session on click |
| `"both"` | Logo + chat icon + voice icon | Either surface, one mount, shared handlers |
When `mode` excludes voice, no voice session is constructed — there's no extra WebRTC machinery or asset cost.
## Quick start
```tsx
{children}
```
```ts
createYakProvider({
appId: "your-app-id",
mode: "both",
});
```
```ts
const yak = createYakProvider({
appId: "your-app-id",
mode: "both",
});
```
```ts
this.yak = createYakProvider({
appId: "your-app-id",
mode: "both",
});
```
```ts
// plugins/yak.client.ts
const yak = createYakProvider({
appId: "your-app-id",
mode: "both",
});
nuxtApp.provide("yak", yak);
});
```
```ts
const embed = new YakEmbed({
appId: "your-app-id",
mode: "both",
});
embed.mount();
```
## Tool calls in voice
Voice tool calls flow through your existing handlers — the assistant decides which tool to call based on the same routes and tool definitions returned from `getConfig`. No extra wiring is required.
```tsx
{
const res = await fetch("/api/yak");
return res.json();
}}
onToolCall={async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
}}
>
{children}
```
## Conversation history, insights & memory
Voice conversations are first-class: they persist, build [insights](/docs/customization/conversation-insights), and feed [customer memory](/docs/customization/customer-memory) exactly like chat.
If you pass a signed [`user`](/docs/customization/end-user-identity) to the provider, it applies to voice too — no extra props. When a verified user starts a voice session, Yak:
- **persists the transcript** server-side against that user, so it appears in their history and powers insights, and
- **recalls their memory** into the session as it connects — the assistant greets returning users already aware of their standing facts and recent moments, the same recall chat uses.
Anonymous voice sessions (no `user`) still persist per session when conversation storage is on, but aren't tied to an identifiable user. Persistence follows your app's **Store conversations**, **Insights**, and **Memory** settings, just like chat — turn storage off and voice runs without writing anything.
Voice transcripts are text only — Yak never stores the audio. Memory is recalled once when the session is minted (a session-start snapshot), where chat re-checks every turn.
## Programmatic control
`useYak()` (and its framework equivalents) exposes voice methods alongside chat:
```tsx
function VoiceButton() {
const { voiceState, voiceToggle, voiceIsActive, voiceLoading } = useYak();
return (
);
}
```
| Method / property | Type | Description |
| --- | --- | --- |
| `voiceState` | `"idle" \| "connecting" \| "listening" \| "thinking" \| "speaking" \| "error"` | Current session state |
| `voiceMachine` | `VoiceMachine` | Full snapshot including `errorMessage` when state is `"error"` |
| `voiceErrorMessage` | `string \| undefined` | Why the last session failed — set when `voiceState` is `"error"`. Shorthand for `voiceMachine.errorMessage`. |
| `voiceIsActive` | `boolean` | `true` while connecting, listening, thinking, or speaking |
| `voiceLoading` | `boolean` | `true` while the session is connecting (`voiceState === "connecting"`) — show a spinner |
| `voiceStart()` | `Promise` | Start a session — must be invoked from a user gesture |
| `voiceStop()` | `Promise` | Stop the current session |
| `voiceToggle()` | `Promise` | Start if idle/error, stop if active |
## Permissions
Voice requires microphone access. The browser will prompt the user on the first session — call `voiceStart()` or click the voice icon directly from a user gesture so `getUserMedia` has transient activation. If permission is denied the state transitions to `"error"` with a descriptive `errorMessage`.
Voice mode is currently in beta. Latency and recognition quality depend on the OpenAI Realtime API; rate limits and pricing apply per session.
By default a voice session opens with a spoken greeting, which uses voice minutes. You can customize or disable it per app — see [Greeting & Intro](/docs/customization/intro).
## Next steps
- [Greeting & Intro](/docs/customization/intro) — choose a generated, fixed, or silent opening for voice and chat
- [Styling](/docs/customization/styling) — tune the trigger pill's colors, position, and color mode
- [Programmatic Control](/docs/customization/programmatic-control) — drive the widget from your own buttons and shortcuts
- [Tool Adapters](/docs/tool-adapters) — connect your APIs so the assistant can act on voice requests too
---
# Astro
URL: https://docs.yak.io/docs/frameworks/astro
Astro sites use the `@yak-io/react` package as a React island for the client-side widget and `@yak-io/javascript` for server handlers.
## Installation
```bash
npm install @yak-io/react @yak-io/javascript react react-dom
npx astro add react
```
```bash
pnpm add @yak-io/react @yak-io/javascript react react-dom
pnpm astro add react
```
```bash
yarn add @yak-io/react @yak-io/javascript react react-dom
yarn astro add react
```
```bash
bun add @yak-io/react @yak-io/javascript react react-dom
bunx astro add react
```
## Client Component
Create a React component for the Yak widget:
```tsx
// src/components/YakChat.tsx
interface YakChatProps {
appId: string;
}
return (
{
const res = await fetch("/api/yak");
return res.json();
}}
onToolCall={async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
}}
onRedirect={(path) => window.location.href = path}
>
);
}
```
## Layout Integration
Add the component to your layout as a React island:
```astro
---
// src/layouts/Layout.astro
---
{title}
```
The `client:load` directive ensures the widget loads on the client side immediately. Use `client:idle` for lower priority loading.
## Server Handler
Create an API endpoint for Yak:
```ts
// src/pages/api/yak.ts
const { GET: yakGet, POST: yakPost } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/about", title: "About" },
{ path: "/blog", title: "Blog" },
],
});
return yakGet(request);
};
return yakPost(request);
};
```
## Dynamic Routes from Content Collections
Pull routes from Astro content collections:
```ts
// src/pages/api/yak.ts
async function getRoutes() {
const posts = await getCollection("blog");
return [
{ path: "/", title: "Home" },
{ path: "/blog", title: "Blog" },
...posts.map((post) => ({
path: `/blog/${post.slug}`,
title: post.data.title,
description: post.data.description,
})),
];
}
const routes = await getRoutes();
const { GET } = createYakHandler({ routes });
return GET(request);
};
```
## Adding Tools
Connect to your data sources:
```ts
// src/pages/api/yak.ts
const contentTools = {
id: "content",
getTools: async () => [
{
name: "content.searchPosts",
description: "Search blog posts by keyword",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
limit: { type: "number", default: 5 },
},
required: ["query"],
},
},
],
executeTool: async (name: string, args: Record) => {
if (name === "content.searchPosts") {
const posts = await getCollection("blog");
const query = (args.query as string).toLowerCase();
const limit = (args.limit as number) ?? 5;
return posts
.filter((post) =>
post.data.title.toLowerCase().includes(query) ||
post.body.toLowerCase().includes(query)
)
.slice(0, limit)
.map((post) => ({
title: post.data.title,
slug: post.slug,
description: post.data.description,
}));
}
throw new Error(`Unknown tool: ${name}`);
},
};
const { GET, POST } = createYakHandler({
routes: [...],
tools: [contentTools],
});
```
## Environment Variables
Add your app ID to `.env`:
```bash
PUBLIC_YAK_APP_ID=yak_app_123
```
Use `PUBLIC_` prefix for environment variables that need to be accessible on the client side.
---
# Remix
URL: https://docs.yak.io/docs/frameworks/remix
Remix applications use the `@yak-io/react` package for the client-side widget and `@yak-io/javascript` for server handlers.
## Installation
```bash
npm install @yak-io/react @yak-io/javascript
```
```bash
pnpm add @yak-io/react @yak-io/javascript
```
```bash
yarn add @yak-io/react @yak-io/javascript
```
```bash
bun add @yak-io/react @yak-io/javascript
```
## Client Setup
Add the provider and widget to your root layout:
```tsx
// app/root.tsx
const navigate = useNavigate();
return (
{
const res = await fetch("/api/yak");
return res.json();
}}
onToolCall={async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
}}
onRedirect={(path) => navigate(path)}
>
);
}
```
## Server Handler
Create a resource route for the API:
```ts
// app/routes/api.yak.ts
const { GET, POST } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/dashboard", title: "Dashboard" },
{ path: "/settings", title: "Settings" },
],
tools: [
// Add your tool adapters here
],
});
const response = await GET(request);
const data = await response.json();
return json(data);
}
const response = await POST(request);
const data = await response.json();
return json(data);
}
```
## Dynamic Routes
Fetch routes from your Remix configuration or a CMS:
```ts
// app/routes/api.yak.ts
const routes = await getRoutes();
const { GET } = createYakHandler({
routes,
});
const response = await GET(request);
const data = await response.json();
return json(data);
}
```
## Adding Tools
Integrate with your data layer:
```ts
// app/routes/api.yak.ts
const databaseTools = {
id: "database",
getTools: async () => [
{
name: "db.getUser",
description: "Get user profile information",
inputSchema: {
type: "object",
properties: { userId: { type: "string" } },
required: ["userId"],
},
},
],
executeTool: async (name: string, args: Record) => {
if (name === "db.getUser") {
return db.user.findUnique({ where: { id: args.userId as string } });
}
throw new Error(`Unknown tool: ${name}`);
},
};
const { GET, POST } = createYakHandler({
routes: [...],
tools: [databaseTools],
});
```
## Programmatic Control
Use the `useYak` hook in any component:
```tsx
// app/components/HelpButton.tsx
const { openWithPrompt } = useYak();
return (
);
}
```
The `onRedirect` prop integrates with Remix's `useNavigate` for client-side navigation without full page reloads.
---
# Vite + React
URL: https://docs.yak.io/docs/frameworks/vite
Vite React applications use `@yak-io/react` for the client-side widget. Server handlers depend on your backend framework.
## Installation
```bash
npm install @yak-io/react @yak-io/javascript
```
```bash
pnpm add @yak-io/react @yak-io/javascript
```
```bash
yarn add @yak-io/react @yak-io/javascript
```
```bash
bun add @yak-io/react @yak-io/javascript
```
## Client Setup
Add the provider and widget to your app:
```tsx
// src/main.tsx
function AppWithProvider() {
const navigate = useNavigate();
return (
{
const res = await fetch("/api/yak");
return res.json();
}}
onToolCall={async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
}}
onRedirect={(path) => navigate(path)}
>
} />
} />
);
}
createRoot(document.getElementById("root")!).render(
);
```
## Server Handler Options
Since Vite is a client-side bundler, you'll need a separate backend for the API handlers.
### Express Backend
```ts
// server/index.ts
const app = express();
app.use(express.json());
const { GET, POST } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/dashboard", title: "Dashboard" },
],
});
app.get("/api/yak", async (req, res) => {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const request = new Request(url);
const response = await GET(request);
res.json(await response.json());
});
app.post("/api/yak", async (req, res) => {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const request = new Request(url, {
method: "POST",
body: JSON.stringify(req.body),
headers: { "Content-Type": "application/json" },
});
const response = await POST(request);
res.json(await response.json());
});
app.listen(3001);
```
### Hono Backend
```ts
// server/index.ts
const app = new Hono();
const { GET, POST } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/dashboard", title: "Dashboard" },
],
});
app.get("/api/yak", (c) => GET(c.req.raw));
app.post("/api/yak", (c) => POST(c.req.raw));
serve({ fetch: app.fetch, port: 3001 });
```
### Vite Dev Proxy
Configure Vite to proxy API requests during development:
```ts
// vite.config.ts
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});
```
## Environment Variables
Add your app ID to `.env`:
```bash
VITE_YAK_APP_ID=yak_app_123
```
Vite requires the `VITE_` prefix for environment variables exposed to the client.
## Programmatic Control
Use the `useYak` hook in any component:
```tsx
// src/components/HelpButton.tsx
const { openWithPrompt, isOpen } = useYak();
return (
);
}
```
## TanStack Router
If using TanStack Router instead of React Router:
```tsx
const router = createRouter({ routeTree });
function App() {
return (
router.navigate({ to: path })}
>
);
}
```
---
# Knowledge Integrations (MCP)
URL: https://docs.yak.io/docs/integrations
Knowledge integrations let you connect **any remote MCP server** to one of
your applications — Zendesk, Notion, Confluence, GitHub, or your own internal
server are all just examples; anything that speaks the
[Model Context Protocol](https://modelcontextprotocol.io) works the same way. An org admin authorizes the connection once in the portal; from
then on, every end-user chat **and** voice session for that application can use
the provider's tools. Yak is the MCP *client* — you don't run an MCP server.
This is configured in the dashboard, not in code. It complements
[Tool Adapters](/docs/tool-adapters) (tools your own app exposes) — here the
tools come from a third-party provider you connect to.
## How it works
```text
Org admin Provider's MCP server
│ connect once (OAuth) │
▼ ▼
Yak (MCP client) ──── stored, encrypted ───┘
│ used server-side
▼
End user on your site → chat / voice → provider's tools
```
The connection is **owned by an application**. Each application has its own
credentials and its own tool settings — connecting Zendesk to your "Support"
app does not expose it to any other app, and one org's connections are never
visible to another.
## Connecting a server
1. In the dashboard, open your application and go to the **Integrations** tab.
2. Click **Add MCP server**, give it a name, and paste the server's URL
(for example `https://mcp.example.com/mcp`).
3. Click **Connect**. You're redirected to the provider to authorize access,
then back to Yak. The connection shows as **Connected** once tools have
been discovered.
Only **organization admins or owners** can add, reconnect, or remove a
connection — connecting a provider grants org-wide access to that provider's
data, so it is gated behind the admin role. Listing connections and their
status is available to any member.
**No OAuth?** Some MCP servers gate on a static token instead. Expand
**Advanced** in the Add dialog and paste a bearer token; the server is
connected immediately using that token.
**Public servers** that require no authorization at all (for example AWS's
managed Knowledge MCP server) connect in one step — no redirect. Yak probes
the server unauthenticated and, since it doesn't ask for authorization, stores
it as a no-auth connection straight away.
## Controlling which tools the AI can use
After a server connects, Yak discovers its tools — but **none of them are
exposed to the assistant until you enable them**. Tools are restricted by
default, so a freshly connected server can do nothing until an admin opts
tools in.
On the connection's card, check the specific tools you want the assistant to
call. Enable only what you need — for example, read-only knowledge-base
lookups, while keeping write and destructive tools off.
The same allowlist applies to both chat and voice.
## Chat and voice
Connected tools work identically in both modes. Tool calls execute
**server-side** — the browser never sees the provider credentials or the
server URL. In voice, the model's tool call is relayed through Yak's backend,
executed against the MCP server, and the result is spoken back; the data
channel only ever carries the tool name, arguments, and result.
If a tool call fails, the assistant degrades gracefully (it tells the user it
couldn't complete the request) rather than ending the conversation.
## Security model
- **Tokens never reach the browser.** All MCP calls go browser → Yak backend →
MCP server.
- **Encrypted at rest.** OAuth refresh tokens and static tokens are
envelope-encrypted with a customer-managed key; they are never returned by
any API.
- **Strict tenancy.** A session can only ever reach the connections owned by
the application it belongs to. The application (and its org) is resolved
server-side from the request — it can't be widened by the client.
- **Reconnect on revoke.** Access tokens are refreshed automatically. If a
provider revokes access or the grant expires, the connection moves to
**Needs reconnect**, the assistant degrades gracefully, and an admin can
reconnect with one click from the Integrations tab.
## Limitations
A connection authorizes Yak to the MCP server with a single
application-level grant shared by all of that app's end-user sessions. Servers
that need to federate to a downstream service **per individual visitor**, or
that drive their own downstream setup through MCP elicitation (URL-mode
"finish connecting" flows), are not supported yet — connect those providers'
downstream sources in the provider's own console first.
- One grant per (application, server); not per end-user.
- Server-initiated elicitation / "finish setup elsewhere" prompts are not
surfaced in the dashboard yet.
- There is a per-application cap on the number of connections; keep the tool
allowlist tight, since every exposed tool is offered to the model on every
turn.
---
# Security
URL: https://docs.yak.io/docs/reference/security
Yak is designed with security as a priority. The widget runs in an isolated iframe, all communication is origin-validated, and your tool calls execute in your own server context with your existing authentication.
## Key Security Features
- **Origin isolation** — The widget iframe is sandboxed from your application's DOM
- **Origin validation** — All messages are validated against expected origins; unexpected origins are rejected
- **Tool allowlisting** — Only explicitly allowed tools can be executed
- **Server-side execution** — Tool calls run on your server with your auth and access controls
- **Identity verification** — Persist conversations per end-user with HMAC-signed user identities
- **Redirect protection** — Built-in protection against open redirect attacks
## Tool Security
### Restricting Exposed Procedures
When using the tRPC adapter, control which procedures are available:
```ts
const toolAdapter = createTRPCToolAdapter({
router: appRouter,
createContext,
allowedProcedures: [
"orders.list",
"orders.getById",
"products.search",
],
});
```
Always restrict procedures to the minimal set needed by the assistant. Avoid exposing destructive operations without safeguards.
### Authentication
Tool calls receive the original `Request` object, so your existing auth works automatically:
```ts
const toolAdapter = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => {
const session = await getSession(req);
if (!session) throw new Error("Unauthorized");
return { user: session.user };
},
allowedProcedures: ["..."],
});
```
### Input Validation
Tool inputs are validated against the JSON Schema derived from your Zod schemas. Always validate on the server side as well:
```ts
list: protectedProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
status: z.enum(["pending", "shipped", "delivered"]).optional(),
}))
.query(async ({ ctx, input }) => {
return db.orders.findMany({
where: { userId: ctx.userId, status: input.status },
take: input.limit,
});
}),
});
```
## User Identity Verification
When you want to persist conversations against a specific end-user (so they can resume past chats from the widget), the widget needs a tamper-proof way to know who's chatting. Yak uses the same HMAC pattern as Intercom and similar tools:
1. Your application gets an `apiSecret` (revealable from your application's Customer settings).
2. Your backend signs the user id: `hash = HMAC-SHA256(apiSecret, userId)`.
3. The browser passes `{ id, hash }` into ``.
4. Every Yak endpoint that touches user data re-verifies the hash with a constant-time comparison.
```ts
// Backend
const userHash = crypto
.createHmac("sha256", process.env.YAK_API_SECRET!)
.update(currentUser.id)
.digest("hex");
```
```tsx
// Browser
```
Never expose `YAK_API_SECRET` to the browser. Compute the hash on your server and send only the resulting hex string to the client.
Only the user `id` is HMAC-protected. A holder of the secret can produce a valid identity for any id, so keep the secret on the server.
If you suspect the secret has leaked, rotate it from the dashboard. Rotation invalidates every previously signed hash, so plan it alongside a deploy of your server code.
See [End-user identity](/docs/customization/end-user-identity) for the full guide and language-specific signing snippets.
## Session Revocation
Each visitor's chat widget holds a short-lived, HMAC-signed **session token** that ties them to their conversation history. From your application's Customer settings you can **sign out all sessions** for an app in one click — this immediately invalidates every outstanding session token, including any that may have leaked. On its next action, each widget transparently re-mints a fresh session; no visitor sees an error.
This **resets session tokens — it does not lock users out.** An anonymous visitor loses their conversation history and starts over. A signed-in end-user whose page still passes their [signed identity](#user-identity-verification) simply re-binds the fresh session to the same account on their next request. To keep a specific person out for good, also stop signing their identity from your backend (i.e. your own logout).
Revocation applies to the **chat** widget only. Voice sessions don't carry a session token — they authenticate per request with the signed identity — so signing out all sessions does not clear voice conversation history.
### Session tokens are bound to the end-user who claims them
When a session token is first used alongside a [signed identity](#user-identity-verification), it is permanently **bound** to that end-user via a signed fingerprint carried in the token. The server rejects a bound token presented for any other identity, so a leaked token can't be replayed to read someone else's history. Binding is one-way: a token bound to a user can never be re-bound to a different one.
The SDK handles this for you — when the `user` you pass changes (a login or an account switch), it proactively discards the old token and mints a fresh one bound to the new identity.
Use it when you want to cut off outstanding sessions — a suspected token leak, or forcing everyone onto a fresh session — without disrupting your integration. It is distinct from rotating your `apiSecret`:
- **Sign out all sessions** — one click, no redeploy. Only invalidates session tokens; your identity signing is untouched and the SDK auto-recovers.
- **Rotate `apiSecret`** — invalidates every signed identity hash, so you must redeploy your backend with the new secret. Use it when the secret itself may have leaked.
Enforcement is applied on each visitor's next request (or page load), so it is effectively immediate for anyone actively using the widget.
## Redirect Protection
The SDK validates all redirect paths:
- **Allowed**: Relative paths (`/dashboard`, `/settings`), hash paths (`#section`), query paths (`?tab=profile`)
- **Blocked**: External domain URLs, protocol-relative URLs (`//evil.com`)
To customize redirect behavior:
```tsx
{
if (isAllowedPath(path)) {
router.push(path);
}
}}
/>
```
## Content Security Policy
If your application uses CSP headers, allow the widget iframe:
```
frame-src https://chat.yak.io;
```
[Voice mode](/docs/customization/voice) needs more. Unlike chat, voice does **not** run in an iframe — it runs directly on your page, calling Yak to mint a session and connecting to OpenAI's Realtime API over WebRTC. If you enable voice, also allow:
```
connect-src https://chat.yak.io https://api.openai.com;
```
Voice also calls `getUserMedia` to capture the microphone from your own origin. If you send a restrictive `Permissions-Policy`, make sure it still permits `microphone=(self)`.
## Production Checklist
Before deploying to production, verify:
- [ ] **Tool allowlist** — Only necessary procedures are exposed
- [ ] **Authentication** — Tool requests require valid auth
- [ ] **Rate limiting** — Consider rate limiting your tool endpoint
- [ ] **HTTPS** — All endpoints use HTTPS
- [ ] **CSP headers** — Allow `frame-src chat.yak.io` if using CSP, plus `connect-src chat.yak.io api.openai.com` if you use voice
- [ ] **Logging** — Tool invocations are logged for audit
- [ ] **Allowed origins** — Restrict which domains can embed the widget (configure in your dashboard)
- [ ] **User identity** — If you persist conversations per end-user, the `apiSecret` is server-only and the hash is computed on every page load (never cached client-side)
## Reporting Security Issues
If you discover a security vulnerability, please report it responsibly:
1. **Do not** create a public GitHub issue
2. Email security@yak.io with details
3. Include steps to reproduce if possible
---
# Angular SDK
URL: https://docs.yak.io/docs/sdks/angular
The `@yak-io/angular` package provides an Angular-compatible provider for integrating Yak. It exposes getter-based reactive state, a state subscription API, and explicit `mount()`/`destroy()` methods for lifecycle control.
## Installation
```bash
npm install @yak-io/angular @yak-io/javascript
```
```bash
pnpm add @yak-io/angular @yak-io/javascript
```
```bash
yarn add @yak-io/angular @yak-io/javascript
```
```bash
bun add @yak-io/angular @yak-io/javascript
```
## Quick Start
### Create a Yak service
Wrap the provider in an Angular service for dependency injection:
```ts
// yak.service.ts
@Injectable({ providedIn: "root" })
private yak: YakApi;
readonly isOpen: YakApi["isOpen"];
readonly isReady: YakApi["isReady"];
constructor() {
this.yak = createYakProvider({
appId: "your-app-id",
// Renders the floating launcher. Without this, the widget mounts
// but there is no button to open it.
trigger: true,
getConfig: async () => {
const res = await fetch("/api/yak");
return res.json();
},
onToolCall: async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
},
});
// Expose reactive getters
Object.defineProperty(this, "isOpen", {
get: () => this.yak.isOpen,
});
Object.defineProperty(this, "isReady", {
get: () => this.yak.isReady,
});
}
mount() {
this.yak.mount();
}
ngOnDestroy() {
this.yak.destroy();
}
open() {
this.yak.open();
}
close() {
this.yak.close();
}
openWithPrompt(prompt: string) {
this.yak.openWithPrompt(prompt);
}
subscribeToToolEvents(handler: (event: import("@yak-io/angular").ToolCallEvent) => void) {
return this.yak.subscribeToToolEvents(handler);
}
subscribeToState(handler: (state: { isOpen: boolean; isReady: boolean }) => void) {
return this.yak.subscribeToState(handler);
}
}
```
### Mount in your root component
```ts
// app.component.ts
@Component({
selector: "app-root",
template: `
`,
})
constructor(public yakService: YakService) {}
ngOnInit() {
this.yakService.mount();
}
ngOnDestroy() {
this.yakService.ngOnDestroy();
}
}
```
### Set up server handlers
Use `@yak-io/javascript` to create the API endpoints on your backend. See the [JavaScript SDK](/docs/sdks/javascript) for runtime-specific examples (Express, Hono, etc.).
```ts
// api/yak.ts
routes: [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
],
});
```
## API
### createYakProvider
Creates a Yak widget instance with plain getter-based reactive state. Use inside an Angular service or component.
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `appId` | `string` | Yes | Your Yak application ID |
| `mode` | `"chat" \| "voice" \| "both"` | No | Which surfaces the trigger exposes. Defaults to `"chat"`. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | No | Config provider for routes and tools (used by chat **and** voice) |
| `onToolCall` | `(name, args) => Promise` | No | Handler for tool execution (used by chat **and** voice) |
| `theme` | `Theme` | No | Widget [styling options](/docs/customization/styling) |
| `onRedirect` | `(path: string) => void` | No | Custom navigation handler |
| `disableRestartButton` | `boolean` | No | Hide the restart button in the header |
| `disablePageContent` | `boolean` | No | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `trigger` | `boolean \| TriggerButtonConfig` | No | Render the floating trigger button. **Defaults to `false`** — set `trigger: true` (or pass a config object) or the widget mounts with no way to open it. |
| `user` | `{ id, hash }` | No | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). Call `setUser()` to change it after setup (login/logout). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
### Return value (`YakApi`)
| Property | Type | Description |
| --- | --- | --- |
| `isOpen` | `boolean` (getter) | Whether the chat panel is currently open |
| `isReady` | `boolean` (getter) | Whether the widget iframe is ready |
| `chatLoading` | `boolean` (getter) | `isOpen && !isReady` — opening but not yet interactive |
| `voiceMachine` | `VoiceMachine` (getter) | Current voice state — see [Voice Mode](/docs/customization/voice) |
| `voiceLoading` | `boolean` (getter) | `true` while the voice session is connecting |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `voiceStart` | `() => Promise` | Start a voice session |
| `voiceStop` | `() => Promise` | Stop the current voice session |
| `voiceToggle` | `() => Promise` | Start if idle/error, stop if active |
| `setUser` | `(user?: { id, hash }) => void` | Set or clear the [signed end-user identity](/docs/customization/end-user-identity) after setup — call on login/logout |
| `subscribeToToolEvents` | `(handler) => () => void` | Subscribe to tool call events (returns unsubscribe) |
| `subscribeToState` | `(handler) => () => void` | Subscribe to combined chat + voice state changes (returns unsubscribe) |
| `mount` | `() => void` | Mount the widget DOM — call in `ngOnInit` |
| `destroy` | `() => void` | Destroy the widget DOM — call in `ngOnDestroy` |
## Reactive State
Use `subscribeToState` for reactive updates in Angular templates:
```ts
@Component({
selector: "app-chat-button",
template: `
`,
})
isOpen = signal(false);
private unsubscribe?: () => void;
constructor(public yakService: YakService) {}
ngOnInit() {
this.unsubscribe = this.yakService.subscribeToState((state) => {
this.isOpen.set(state.isOpen);
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
}
```
## Tool Events
Subscribe to tool call completion events for [UI synchronization](/docs/customization/ui-synchronization):
```ts
@Component({
selector: "app-orders",
template: ``,
})
private unsubscribe?: () => void;
constructor(private yakService: YakService) {}
ngOnInit() {
this.unsubscribe = this.yakService.subscribeToToolEvents((event) => {
if (event.ok && event.name.startsWith("order.")) {
this.refreshOrders();
}
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
private refreshOrders() {
// re-fetch order data
}
}
```
## Router Integration
Pass Angular Router's `navigate` for client-side navigation:
```ts
@Injectable({ providedIn: "root" })
private yak: YakApi;
constructor(private router: Router) {
this.yak = createYakProvider({
appId: "your-app-id",
onRedirect: (path) => this.router.navigateByUrl(path),
// ...other options
});
}
// ...
}
```
---
# JavaScript SDK
URL: https://docs.yak.io/docs/sdks/javascript
The `@yak-io/javascript` package provides the core client SDK and server handlers. It works with any JavaScript runtime that supports the Fetch API.
## When to Use
- Building with frameworks without a dedicated SDK (Solid, Ember, etc.)
- Need server handlers for custom runtimes (Cloudflare Workers, Deno, Bun)
- Building a custom integration without framework components
For framework-specific SDKs, see [Next.js](/docs/sdks/nextjs), [React](/docs/sdks/react), [Vue](/docs/sdks/vue), [Svelte](/docs/sdks/svelte), [Nuxt](/docs/sdks/nuxt), or [Angular](/docs/sdks/angular).
## Installation
```bash
npm install @yak-io/javascript
```
```bash
pnpm add @yak-io/javascript
```
```bash
yarn add @yak-io/javascript
```
```bash
bun add @yak-io/javascript
```
Ships both **ESM and CommonJS**, so it works with native ESM, bundlers, and CommonJS
`require()` / Jest out of the box — no transform config needed. See
[Module Format Issues](/docs/troubleshooting#module-format-issues-esm--commonjs) if you
hit an `Unexpected token 'export'` error on older tooling.
## Server Handlers
Create API endpoints that serve route configuration and handle tool calls:
```ts
const { GET, POST } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/pricing", title: "Pricing" },
],
tools: [
{
id: "custom",
getTools: async () => [
{
name: "greet",
description: "Greet a user by name",
inputSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
],
// `args` arrives as `unknown` — narrow it before use.
executeTool: async (name, args) => {
if (name === "greet") {
const { name: who } = args as { name: string };
return { message: `Hello, ${who}!` };
}
throw new Error(`Unknown tool: ${name}`);
},
},
],
});
```
### Splitting config and tools across routes
`createYakHandler` returns a `GET` (config) and a `POST` (tool execution) for one endpoint. If your runtime needs them on separate routes, build each half on its own:
```ts
createYakConfigHandler,
createYakToolsHandler,
} from "@yak-io/javascript/server";
// GET — serves routes + the tool manifest
// POST — executes a tool call
```
Each returns a single `(req: Request) => Promise`. `createYakConfigHandler` needs `routes` (with `tools` optional); `createYakToolsHandler` needs at least one tool source and throws at construction if given none.
### Route Schema
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | Yes | URL path |
| `title` | `string` | No | Human-readable title |
| `description` | `string` | No | Brief description for AI context |
| `search` | `RouteSearch` | No | Marks the route as able to run a free-text search, e.g. `{ queryParam: "q" }` lets "show me boots" drive `/search?q=boots`. The assistant prefers navigating here over answering inside the chat. |
| `filters` | `RouteFilter[]` | No | Query-param filters this route accepts. The assistant only sets params you declare here and never invents others, so "size 10 boots" can reliably drive `/products?size=10`. |
See [Manual Routes](/docs/sdks/nextjs/manual-routes) for worked `search` and `filters` examples.
### Dynamic Route Sources
Fetch routes from external sources:
```ts
const staticRoutes = [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
];
const cmsRoutes = {
id: "cms",
getRoutes: async () => {
const res = await fetch("https://cms.example.com/api/pages");
return res.json();
},
};
const { GET, POST } = createYakHandler({
routes: [staticRoutes, cmsRoutes],
});
```
## Drop-in widget (`YakEmbed`)
For vanilla JS apps, `YakEmbed` renders the trigger pill and manages the chat iframe and voice session for you:
```ts
const embed = new YakEmbed({
appId: "your-app-id",
mode: "both", // "chat" (default) | "voice" | "both"
getConfig: async () => fetch("/api/yak").then((r) => r.json()),
onToolCall: async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
},
});
embed.mount();
```
The same `onToolCall` and `getConfig` handlers serve chat and voice — see [Voice Mode](/docs/customization/voice) for the full voice API exposed via `embed.voiceStart()` / `voiceStop()` / `voiceToggle()`.
## Client SDK
The `YakClient` class manages widget communication. For most use cases, use `@yak-io/react` or `@yak-io/nextjs` instead — this is the low-level API.
`YakClient` is **headless**. It does not render anything: `mount()` only starts listening for messages from the widget iframe. Use it only when you render and own the iframe yourself — you must hand its `contentWindow` to `setIframeWindow()` before `sendPrompt()` or `setWidgetOpen()` will do anything. If you just want a working widget, use [`YakEmbed`](#embed-sdk) above, which creates the iframe for you.
```ts
const client = new YakClient({
appId: "your-app-id",
// YakClient has no `getConfig` — pass routes and tools directly.
chatConfig: { routes, tools },
onToolCall: async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
},
onReady: () => console.log("Widget ready"),
onClose: () => console.log("Widget closed"),
});
client.mount();
// Render the iframe yourself, then hand its window to the client.
const iframe = document.createElement("iframe");
iframe.src = client.getEmbedUrl();
iframe.addEventListener("load", () => {
client.setIframeWindow(iframe.contentWindow);
client.setWidgetOpen(true);
client.sendPrompt("Help me");
});
document.body.appendChild(iframe);
// On teardown:
// client.setIframeWindow(null);
// client.unmount();
```
### Configuration Options
| Option | Type | Description |
| --- | --- | --- |
| `appId` | `string` | Your Yak application ID |
| `chatConfig` | `ChatConfig` | Routes and tools to send to the widget. `YakClient` has no `getConfig` — this is how you supply them. |
| `onToolCall` | `(name, args) => Promise` | Tool execution handler |
| `onToolCallComplete` | `(event: ToolCallEvent) => void` | Called after each tool call completes |
| `onRedirect` | `(path: string) => void` | Navigation handler |
| `onReady` | `() => void` | Called when the widget is ready |
| `onClose` | `() => void` | Called when the widget is closed |
| `theme` | `Theme` | Widget [styling options](/docs/customization/styling) |
| `origin` | `string` | Override the chat origin (defaults to `https://chat.yak.io`). Most integrators never set this — it exists for non-production environments. |
| `options.disableRestartButton` | `boolean` | Hide the restart session button in the widget header. |
| `options.disablePageContent` | `boolean` | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `user` | `{ id, hash }` | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `options.disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
## Runtime Examples
### Cloudflare Workers
```ts
const { GET, POST } = createYakHandler({
routes: [{ path: "/", title: "Home" }],
});
async fetch(request: Request): Promise {
const url = new URL(request.url);
if (url.pathname === "/api/yak") {
if (request.method === "GET") return GET(request);
if (request.method === "POST") return POST(request);
}
return new Response("Not Found", { status: 404 });
},
};
```
### Hono
```ts
const app = new Hono();
const { GET, POST } = createYakHandler({
routes: [{ path: "/", title: "Home" }],
});
app.get("/api/yak", (c) => GET(c.req.raw));
app.post("/api/yak", (c) => POST(c.req.raw));
```
### Deno
```ts
const { GET, POST } = createYakHandler({
routes: [{ path: "/", title: "Home" }],
});
Deno.serve(async (request) => {
const url = new URL(request.url);
if (url.pathname === "/api/yak") {
if (request.method === "GET") return GET(request);
if (request.method === "POST") return POST(request);
}
return new Response("Not Found", { status: 404 });
});
```
### Bun
```ts
const { GET, POST } = createYakHandler({
routes: [{ path: "/", title: "Home" }],
});
Bun.serve({
port: 3000,
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/api/yak") {
if (request.method === "GET") return GET(request);
if (request.method === "POST") return POST(request);
}
return new Response("Not Found", { status: 404 });
},
});
```
### Express / Node.js
```ts
const app = express();
app.use(express.json());
const { GET, POST } = createYakHandler({
routes: [{ path: "/", title: "Home" }],
});
app.all("/api/yak", async (req, res) => {
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const request = new Request(url, {
method: req.method,
headers: req.headers as HeadersInit,
body: req.method !== "GET" ? JSON.stringify(req.body) : undefined,
});
const response = await (req.method === "POST" ? POST(request) : GET(request));
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
res.send(Buffer.from(await response.arrayBuffer()));
});
app.listen(3000);
```
## TypeScript
Import types for your integrations:
```ts
// Client-side types
YakClientConfig,
Theme,
ThemeColors,
ToolCallHandler,
ToolCallEvent,
} from "@yak-io/javascript";
// Server-side types
RouteInfo,
RouteSource,
ToolDefinition,
ToolSource,
ToolExecutor,
ToolCallPayload,
ToolCallResult,
} from "@yak-io/javascript/server";
```
---
# Nuxt SDK
URL: https://docs.yak.io/docs/sdks/nuxt
The `@yak-io/nuxt` package provides a Nuxt 3-compatible provider for integrating Yak. It uses Vue refs for reactive state and exposes explicit `mount()`/`destroy()` methods for client-side lifecycle control via Nuxt plugins.
## Installation
```bash
npm install @yak-io/nuxt @yak-io/javascript
```
```bash
pnpm add @yak-io/nuxt @yak-io/javascript
```
```bash
yarn add @yak-io/nuxt @yak-io/javascript
```
```bash
bun add @yak-io/nuxt @yak-io/javascript
```
## Quick Start
### Create a client-side Nuxt plugin
Create a `.client.ts` plugin to ensure the widget only runs in the browser:
```ts
// plugins/yak.client.ts
const yak = createYakProvider({
appId: "your-app-id",
// Renders the floating launcher. Without this, the widget mounts
// but there is no button to open it.
trigger: true,
getConfig: async () => {
const res = await $fetch("/api/yak");
return res;
},
onToolCall: async (name, args) => {
const res = await $fetch("/api/yak", {
method: "POST",
body: { name, args },
});
if (!res.ok) throw new Error(res.error);
return res.result;
},
});
nuxtApp.hook("app:mounted", () => yak.mount());
// Provide to the app for use in composables
return {
provide: { yak },
};
});
```
### Set up server handlers
Use `@yak-io/javascript` to create the API endpoints. Create a Nitro server route:
```ts
// server/api/yak.get.ts
const { GET } = createYakHandler({
routes: [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
],
});
const request = toWebRequest(event);
const response = await GET(request);
return response.json();
});
```
```ts
// server/api/yak.post.ts
const { POST } = createYakHandler({
// ... same config
});
const request = toWebRequest(event);
const response = await POST(request);
return response.json();
});
```
Sourcing pages from a headless CMS instead of hand-listing routes? See the [Prismic adapter](/docs/sdks/prismic) for a full Nuxt example that drops straight into the Nitro server routes above.
## API
### createYakProvider
Creates a Yak widget instance with Vue-compatible readonly refs. Unlike the Vue SDK, the Nuxt SDK does not use Vue's `provide`/`inject` or lifecycle hooks — you manage the lifecycle explicitly through Nuxt plugins.
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `appId` | `string` | Yes | Your Yak application ID |
| `mode` | `"chat" \| "voice" \| "both"` | No | Which surfaces the trigger exposes. Defaults to `"chat"`. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | No | Config provider for routes and tools (used by chat **and** voice) |
| `onToolCall` | `(name, args) => Promise` | No | Handler for tool execution (used by chat **and** voice) |
| `theme` | `Theme` | No | Widget [styling options](/docs/customization/styling) |
| `onRedirect` | `(path: string) => void` | No | Custom navigation handler |
| `disableRestartButton` | `boolean` | No | Hide the restart button in the header |
| `disablePageContent` | `boolean` | No | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `trigger` | `boolean \| TriggerButtonConfig` | No | Render the floating trigger button. **Defaults to `false`** — set `trigger: true` (or pass a config object) or the widget mounts with no way to open it. |
| `user` | `{ id, hash }` | No | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). Call `setUser()` to change it after setup (login/logout). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
### Return value (`YakApi`)
| Property | Type | Description |
| --- | --- | --- |
| `isOpen` | `Readonly>` | Whether the chat panel is currently open |
| `isReady` | `Readonly>` | Whether the widget iframe is ready |
| `chatLoading` | `Readonly>` | `isOpen && !isReady` — opening but not yet interactive |
| `voiceMachine` | `Readonly>` | Current voice state — see [Voice Mode](/docs/customization/voice) |
| `voiceLoading` | `Readonly>` | `true` while the voice session is connecting |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `voiceStart` | `() => Promise` | Start a voice session |
| `voiceStop` | `() => Promise` | Stop the current voice session |
| `voiceToggle` | `() => Promise` | Start if idle/error, stop if active |
| `setUser` | `(user?: { id, hash }) => void` | Set or clear the [signed end-user identity](/docs/customization/end-user-identity) after setup — call on login/logout |
| `subscribeToToolEvents` | `(handler) => () => void` | Subscribe to tool call events (returns unsubscribe) |
| `mount` | `() => void` | Mount the widget DOM — call in `app:mounted` hook |
| `destroy` | `() => void` | Destroy the widget DOM |
## Using in Components
Access the provider via `useNuxtApp()`:
```vue
Chat is open
```
## Tool Events
Subscribe to tool call completion events for [UI synchronization](/docs/customization/ui-synchronization):
```vue
```
## Router Integration
Pass Nuxt's `navigateTo` for client-side navigation:
```ts
// plugins/yak.client.ts
const yak = createYakProvider({
appId: "your-app-id",
onRedirect: (path) => navigateTo(path),
// ...other options
});
// ...
});
```
The `.client.ts` suffix ensures the plugin only runs in the browser, making `YakEmbed` instantiation SSR-safe.
---
# Prismic
URL: https://docs.yak.io/docs/sdks/prismic
The `@yak-io/prismic` package adapts your Prismic content into Yak primitives. It produces standard `RouteSource`, `ToolSource`, and `ToolAdapter` values that compose with the existing framework SDKs — most commonly [`@yak-io/nextjs`](/docs/sdks/nextjs) or [`@yak-io/nuxt`](/docs/sdks/nuxt).
## What you get
- `createPrismicRouteAdapter` — turn published Prismic documents into the route manifest the LLM uses for navigation.
- `createPrismicToolAdapter` — expose `prismic.getByUID`, `prismic.getAllByType`, and `prismic.search` as tools the assistant can invoke.
- `createPrismicGraphQLToolAdapter` — introspect your repo's GraphQL schema and return a browser-executed [`ToolAdapter`](/docs/tool-adapters/graphql) (a `graphql_prismic` tool) so the assistant can query your content model directly.
## Installation
```bash
npm install @yak-io/prismic @yak-io/javascript @prismicio/client
```
```bash
pnpm add @yak-io/prismic @yak-io/javascript @prismicio/client
```
```bash
yarn add @yak-io/prismic @yak-io/javascript @prismicio/client
```
```bash
bun add @yak-io/prismic @yak-io/javascript @prismicio/client
```
`@prismicio/client` (v7+) is a peer dependency. If you want to use the GraphQL adapter, also install `graphql`.
## Quick Start
### Create a Prismic client
Wherever you would normally configure Prismic — usually a shared `prismicio.ts`:
```ts
accessToken: process.env.PRISMIC_ACCESS_TOKEN,
});
```
### Build the adapters
Each factory takes the client plus a small config. Routes need a `resolveRoute` mapper from document → `RouteInfo`. Tools need an `allowedTypes` list — this is the security boundary for what the LLM can read.
```ts
createPrismicRouteAdapter,
createPrismicToolAdapter,
} from "@yak-io/prismic";
client: prismicClient,
documentTypes: ["page", "blog_post"],
resolveRoute: (doc) => ({
path: `/${doc.uid}`,
title: doc.data.meta_title ?? doc.data.title,
description: doc.data.meta_description,
}),
});
client: prismicClient,
allowedTypes: ["page", "blog_post"],
});
```
### Plug them into your Yak handler
See the framework sections below — the exact wiring differs between Next.js and Nuxt, but in both cases you pass the adapters straight into the standard handler config.
## Using with Next.js
Pair `@yak-io/prismic` with [`@yak-io/nextjs`](/docs/sdks/nextjs). The Next.js handler accepts route and tool sources as arrays, so you can combine Prismic-sourced routes with the filesystem auto-scan or with other tool adapters in the same handler.
```ts title="app/api/yak/[[...yak]]/route.ts"
createPrismicRouteAdapter,
createPrismicToolAdapter,
} from "@yak-io/prismic";
const prismicRoutes = createPrismicRouteAdapter({
client: prismicClient,
documentTypes: ["page", "blog_post"],
resolveRoute: (doc) => ({
path: `/${doc.uid}`,
title: doc.data.meta_title ?? doc.data.title,
description: doc.data.meta_description,
}),
});
const prismicTools = createPrismicToolAdapter({
client: prismicClient,
allowedTypes: ["page", "blog_post"],
});
appDir: "./src/app",
routes: [prismicRoutes],
tools: [prismicTools],
});
```
If you want filesystem routes **and** Prismic routes, just include both in the array — the handler merges and deduplicates by path:
```ts
routes: [
() => scanRoutes("./src/app"),
prismicRoutes,
],
tools: [prismicTools],
});
```
### GraphQL adapter on the client
The GraphQL adapter runs in the browser. Compose it with any server-relayed tools using
`createYakToolset`, then wire the toolset's `getConfig` / `onToolCall` into your `YakProvider`:
```tsx title="app/providers.tsx"
"use client";
let toolset: ReturnType | null = null;
async function getToolset() {
if (!toolset) {
const prismicGraphQL = await createPrismicGraphQLToolAdapter({
client: prismicClient,
headers: async () => ({ "Prismic-ref": await prismicClient.getMasterRef().then((r) => r.ref) }),
});
toolset = createYakToolset([createYakServerAdapter({ endpoint: "/api/yak" }), prismicGraphQL]);
}
return toolset;
}
return (
({ routes, ...(await (await getToolset()).getConfig()) })}
onToolCall={async (name, args) => (await getToolset()).onToolCall(name, args)}
>
{children}
);
}
```
Cache the introspection result. The GraphQL schema rarely changes between sessions, and you don't want to re-introspect on every chat open.
## Using with Nuxt
Pair `@yak-io/prismic` with [`@yak-io/nuxt`](/docs/sdks/nuxt). Nuxt's server side uses Nitro routes; you call the underlying `createYakHandler` from `@yak-io/javascript/server` and pass the adapters into it the same way.
```ts title="server/api/yak.get.ts"
createPrismicRouteAdapter,
createPrismicToolAdapter,
} from "@yak-io/prismic";
const prismicRoutes = createPrismicRouteAdapter({
client: prismicClient,
documentTypes: ["page", "blog_post"],
resolveRoute: (doc) => ({
path: `/${doc.uid}`,
title: doc.data.meta_title ?? doc.data.title,
description: doc.data.meta_description,
}),
});
const prismicTools = createPrismicToolAdapter({
client: prismicClient,
allowedTypes: ["page", "blog_post"],
});
const { GET } = createYakHandler({
routes: [prismicRoutes],
tools: [prismicTools],
});
const request = toWebRequest(event);
const response = await GET(request);
return response.json();
});
```
```ts title="server/api/yak.post.ts"
const { POST } = createYakHandler({
routes: [],
tools: [
createPrismicToolAdapter({
client: prismicClient,
allowedTypes: ["page", "blog_post"],
}),
],
});
const request = toWebRequest(event);
const response = await POST(request);
return response.json();
});
```
### GraphQL adapter on the client
In your `.client.ts` Nuxt plugin, compose the Prismic GraphQL adapter with the server adapter:
```ts title="plugins/yak.client.ts"
let toolset: ReturnType | null = null;
const yak = createYakProvider({
appId: useRuntimeConfig().public.yakAppId,
getConfig: async () => {
toolset ??= createYakToolset([
createYakServerAdapter({ endpoint: "/api/yak" }),
await createPrismicGraphQLToolAdapter({ client: prismicClient }),
]);
return { routes, ...(await toolset.getConfig()) };
},
onToolCall: async (name, args) => toolset!.onToolCall(name, args),
});
nuxtApp.hook("app:mounted", () => yak.mount());
return { provide: { yak } };
});
```
## API Reference
### createPrismicRouteAdapter
```ts
const routes = createPrismicRouteAdapter({
client: prismicClient,
documentTypes: ["page", "blog_post"],
resolveRoute: (doc) => ({ path: `/${doc.uid}` }),
});
```
| Option | Type | Description |
| --- | --- | --- |
| `client` | `Client` | A `@prismicio/client` instance |
| `documentTypes` | `string[]` | Which Prismic custom types to include in the route manifest |
| `resolveRoute` | `(doc) => RouteInfo \| null` | Map each document to a `RouteInfo` entry. Return `null` to skip a document (drafts, hidden pages, etc.) |
| `filters` | `string[]` | Optional additional Prismic filter expressions, appended after the type filter |
| `id` | `string` | Optional source identifier (default: `"prismic"`) |
### createPrismicToolAdapter
```ts
const tools = createPrismicToolAdapter({
client: prismicClient,
allowedTypes: ["page", "blog_post"],
});
```
| Option | Type | Description |
| --- | --- | --- |
| `client` | `Client` | A `@prismicio/client` instance |
| `allowedTypes` | `string[]` | Document types the LLM is allowed to query. Required — this is the security boundary |
| `fields` | `Record` | Optional per-type field projection. Translated to Prismic's GraphQuery DSL on the wire |
| `id` | `string` | Optional source identifier (default: `"prismic"`) |
The adapter exposes three tools:
| Tool name | Purpose |
| --- | --- |
| `prismic.getByUID` | Fetch one document by `(type, uid)` |
| `prismic.getAllByType` | List all documents of a given type (default limit: 20) |
| `prismic.search` | Full-text search across allowed types |
### createPrismicGraphQLToolAdapter
```ts
const prismicGraphQL = await createPrismicGraphQLToolAdapter({
client: prismicClient,
});
```
Introspects your Prismic repo's `/graphql` endpoint once and returns a browser-executed
[`ToolAdapter`](/docs/tool-adapters/graphql) exposing a `graphql_prismic` tool.
| Option | Type | Description |
| --- | --- | --- |
| `client` | `Client` | A `@prismicio/client` instance |
| `name` | `string` | Tool name suffix — exposed as `graphql_` (default: `"prismic"`) |
| `endpoint` | `string` | Override the GraphQL endpoint (defaults to the repo's derived `/graphql` URL) |
| `headers` | `HeadersInit \| (() => HeadersInit \| Promise)` | Execution headers, e.g. `Prismic-ref`. May be async to fetch the master ref per call |
| `fetchFn` | `typeof fetch` | Optional fetch override for the one-time introspection (testing) |
GraphQL mode requires the `graphql` package to be installed alongside this adapter.
## Composing with other sources
Route and tool sources merge cleanly. You can pair Prismic with anything else in the same handler:
```ts
appDir: "./src/app",
tools: [
createPrismicToolAdapter({
client: prismicClient,
allowedTypes: ["page", "blog_post"],
}),
createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
allowedProcedures: ["orders.list", "orders.getById"],
}),
],
});
```
The route manifest merger dedupes by `path`, so a Prismic-sourced `/about` route won't conflict with a filesystem-sourced one.
## Security
`allowedTypes` defines the read surface the LLM has into your Prismic repository. Without it, anyone using the assistant could ask it to fetch internal document types (`settings`, `feature_flags`, etc.). Always set this to the minimal list of public content types.
- Restrict `documentTypes` (routes) and `allowedTypes` (tools) to the document types that are genuinely public.
- Use `resolveRoute` returning `null` to filter unpublished drafts out of the route manifest.
- With `createPrismicToolAdapter` / `createPrismicRouteAdapter`, tool calls execute server-side via `createYakHandler` / `createNextYakHandler` — your Prismic access token never reaches the browser.
- **`createPrismicGraphQLToolAdapter` is different: it runs in the browser** and queries your repository's GraphQL endpoint directly from the page. Anything you pass in `headers` is exposed to the client, so use it only against a publicly-readable repository — never hand it a private access token. If your content needs a token to read, use the server-executed adapters above instead.
---
# React SDK
URL: https://docs.yak.io/docs/sdks/react
The `@yak-io/react` package provides React components and hooks for integrating Yak into any React application.
For Next.js applications, use [`@yak-io/nextjs`](/docs/sdks/nextjs) instead for automatic route scanning and App Router integration.
## Installation
Works with React 17, 18, and 19.
```bash
npm install @yak-io/react @yak-io/javascript
```
```bash
pnpm add @yak-io/react @yak-io/javascript
```
```bash
yarn add @yak-io/react @yak-io/javascript
```
```bash
bun add @yak-io/react @yak-io/javascript
```
## Quick Start
### Add the Provider and Widget
```tsx
// App.tsx
return (
{
const res = await fetch("/api/yak");
return res.json();
}}
onToolCall={async (name, args) => {
const res = await fetch("/api/yak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, args }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
return data.result;
}}
>
{/* Your app content */}
);
}
```
### Set up server handlers
Use `@yak-io/javascript` to create the API endpoints on your backend. See the [JavaScript SDK](/docs/sdks/javascript) for runtime-specific examples (Express, Hono, Cloudflare Workers, etc.).
```ts
// api/yak.ts
routes: [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
],
});
```
## Components
### YakProvider
Wraps your application and manages widget state and communication.
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `appId` | `string` | Yes | Your Yak application ID |
| `mode` | `"chat" \| "voice" \| "both"` | No | Which surfaces the trigger exposes. Defaults to `"chat"`. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | No | Config provider for routes and tools (used by chat **and** voice). May return the config synchronously. |
| `onToolCall` | `(name, args) => Promise` | No | Handler for tool execution (used by chat **and** voice) |
| `theme` | `Theme` | No | Widget [styling options](/docs/customization/styling) — including `position` and `colorMode` |
| `trigger` | `boolean \| TriggerButtonConfig` | No | Render a built-in trigger button without mounting `YakWidget` yourself. Defaults to `false`. |
| `onRedirect` | `(path: string) => void` | No | Custom navigation handler |
| `disableRestartButton` | `boolean` | No | Hide the restart button in the header |
| `disablePageContent` | `boolean` | No | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `user` | `{ id, hash }` | No | Signed end-user identity. Enables [server-side conversation persistence](/docs/customization/end-user-identity) and the history pane. |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
### YakWidget
Renders the trigger pill — logo plus one or two icon buttons depending on `mode`.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"chat" \| "voice" \| "both"` | inherited from provider | Override the provider mode for this trigger |
| `lightButton` | `{ background?, color?, border? }` | — | Custom pill colors in light mode |
| `darkButton` | `{ background?, color?, border? }` | — | Custom pill colors in dark mode |
Position and color mode are **not** props on `YakWidget` — they come from the provider's `theme`. Set `theme={{ position: "bottom-left", colorMode: "dark" }}` on `YakProvider` instead. See [Styling & Theming](/docs/customization/styling) for the eight available positions.
## Hooks
### useYak
Access widget controls from any component inside `YakProvider`:
```tsx
const { open, close, openWithPrompt, isOpen } = useYak();
return (
{isOpen && }
);
}
```
| Property | Type | Description |
| --- | --- | --- |
| `mode` | `"chat" \| "voice" \| "both"` | The current mode the provider was configured with |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `isOpen` | `boolean` | Whether the chat panel is currently open |
| `isReady` | `boolean` | Whether the chat iframe has loaded and can receive messages |
| `chatLoading` | `boolean` | `isOpen && !isReady` — the panel is opening but not yet interactive |
| `voiceState` | `VoiceState` | `"idle" \| "connecting" \| "listening" \| "thinking" \| "speaking" \| "error"` |
| `voiceIsActive` | `boolean` | `true` while a voice session is live |
| `voiceLoading` | `boolean` | `true` while the voice session is connecting |
| `voiceStart()` | `() => Promise` | Start a voice session (call from a user gesture) |
| `voiceStop()` | `() => Promise` | Stop the current voice session |
| `voiceToggle()` | `() => Promise` | Start if idle/error, stop if active |
| `voiceErrorMessage` | `string \| undefined` | Why the last voice session failed — set when `voiceState` is `"error"` |
| `subscribeToToolEvents` | `(handler) => () => void` | Subscribe to tool-call events imperatively; returns an unsubscribe function. Prefer [`useYakToolEvent`](#useyaktoolevent) in components. |
See [Voice Mode](/docs/customization/voice) for the full voice API and behavior.
### useYakToolEvent
Subscribe to tool call completion events — useful for [keeping your UI in sync](/docs/customization/ui-synchronization) with agent actions:
```tsx
function OrderPage({ orderId }: { orderId: string }) {
const queryClient = useQueryClient();
useYakToolEvent((event) => {
if (event.ok && event.name.startsWith("order.")) {
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
}
});
return ;
}
```
See [UI Synchronization](/docs/customization/ui-synchronization) for more details.
## Router Integration
Pass your framework's navigation function to `onRedirect` for client-side navigation:
```tsx
// React Router
function App() {
const navigate = useNavigate();
return (
navigate(path)} {...props}>
{children}
);
}
// TanStack Router
router.navigate({ to: path })} />
```
## Next Steps
- [Voice Mode](/docs/customization/voice) — Add a voice icon to the trigger pill
- [Styling](/docs/customization/styling) — Customize appearance, position, and colors
- [Programmatic Control](/docs/customization/programmatic-control) — Open widget from code, context-sensitive help
- [UI Synchronization](/docs/customization/ui-synchronization) — Keep your UI in sync with agent actions
- [Tool Adapters](/docs/tool-adapters) — Connect your APIs as tools
---
# Svelte SDK
URL: https://docs.yak.io/docs/sdks/svelte
The `@yak-io/svelte` package provides a Svelte-compatible provider for integrating Yak. It exposes Svelte readable stores for reactive state and explicit `mount()`/`destroy()` methods for lifecycle control.
## Installation
```bash
npm install @yak-io/svelte @yak-io/javascript
```
```bash
pnpm add @yak-io/svelte @yak-io/javascript
```
```bash
yarn add @yak-io/svelte @yak-io/javascript
```
```bash
bun add @yak-io/svelte @yak-io/javascript
```
## Quick Start
### Set up the provider in your root component
Call `createYakProvider` and wire up `mount()`/`destroy()` to Svelte's lifecycle:
```svelte
```
### Set up server handlers
Use `@yak-io/javascript` to create the API endpoints on your backend. See the [JavaScript SDK](/docs/sdks/javascript) for runtime-specific examples (Express, Hono, Cloudflare Workers, etc.).
```ts
// api/yak.ts
routes: [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
],
});
```
## API
### createYakProvider
Creates a Yak widget instance with Svelte-compatible stores. Unlike the Vue and React SDKs, the Svelte SDK does not use framework-level context — you manage the lifecycle explicitly with `mount()` and `destroy()`.
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `appId` | `string` | Yes | Your Yak application ID |
| `mode` | `"chat" \| "voice" \| "both"` | No | Which surfaces the trigger exposes. Defaults to `"chat"`. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | No | Config provider for routes and tools (used by chat **and** voice) |
| `onToolCall` | `(name, args) => Promise` | No | Handler for tool execution (used by chat **and** voice) |
| `theme` | `Theme` | No | Widget [styling options](/docs/customization/styling) |
| `onRedirect` | `(path: string) => void` | No | Custom navigation handler |
| `disableRestartButton` | `boolean` | No | Hide the restart button in the header |
| `disablePageContent` | `boolean` | No | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `trigger` | `boolean \| TriggerButtonConfig` | No | Render the floating trigger button. **Defaults to `false`** — set `trigger: true` (or pass a config object) or the widget mounts with no way to open it. |
| `user` | `{ id, hash }` | No | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). Call `setUser()` to change it after setup (login/logout). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
### Return value (`YakApi`)
| Property | Type | Description |
| --- | --- | --- |
| `isOpen` | `Readable` | Whether the chat panel is currently open |
| `isReady` | `Readable` | Whether the widget iframe is ready |
| `chatLoading` | `Readable` | `isOpen && !isReady` — opening but not yet interactive |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `subscribeToToolEvents` | `(handler) => () => void` | Subscribe to tool call events (returns unsubscribe) |
| `voiceMachine` | `Readable` | Current voice state — see [Voice Mode](/docs/customization/voice) |
| `voiceLoading` | `Readable` | `true` while the voice session is connecting |
| `voiceStart` | `() => Promise` | Start a voice session |
| `voiceStop` | `() => Promise` | Stop the current voice session |
| `voiceToggle` | `() => Promise` | Start if idle/error, stop if active |
| `setUser` | `(user?: { id, hash }) => void` | Set or clear the [signed end-user identity](/docs/customization/end-user-identity) after setup — call on login/logout |
| `mount` | `() => void` | Mount the widget DOM — call in `onMount` |
| `destroy` | `() => void` | Destroy the widget DOM — call in `onDestroy` |
## Reactive State
`isOpen` and `isReady` are Svelte [readable stores](https://svelte.dev/docs/svelte-store#readable). Access their values with the `$` prefix in templates or subscribe manually:
```svelte
{#if $isOpen}
{/if}
Ready: {$isReady}
```
## Tool Events
Subscribe to tool call completion events for [UI synchronization](/docs/customization/ui-synchronization):
```svelte
```
## Router Integration
Pass SvelteKit's navigation function to `onRedirect` for client-side navigation:
```svelte
```
---
# Vue SDK
URL: https://docs.yak.io/docs/sdks/vue
The `@yak-io/vue` package provides Vue 3 composables for integrating Yak into any Vue application. It uses Vue's `provide`/`inject` system and lifecycle hooks for automatic setup and cleanup.
## Installation
```bash
npm install @yak-io/vue @yak-io/javascript
```
```bash
pnpm add @yak-io/vue @yak-io/javascript
```
```bash
yarn add @yak-io/vue @yak-io/javascript
```
```bash
bun add @yak-io/vue @yak-io/javascript
```
## Quick Start
### Set up the provider in your root component
Call `createYakProvider` in your root component's `
```
### Set up server handlers
Use `@yak-io/javascript` to create the API endpoints on your backend. See the [JavaScript SDK](/docs/sdks/javascript) for runtime-specific examples (Express, Hono, Cloudflare Workers, etc.).
```ts
// api/yak.ts
routes: [
{ path: "/", title: "Home" },
{ path: "/products", title: "Products" },
],
});
```
## Composables
### createYakProvider
Sets up the Yak widget and provides it to descendant components via Vue's `provide`/`inject`. Call this once in a root or layout component.
The widget automatically mounts on `onMounted` and destroys on `onUnmounted`.
| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `appId` | `string` | Yes | Your Yak application ID |
| `mode` | `"chat" \| "voice" \| "both"` | No | Which surfaces the trigger exposes. Defaults to `"chat"`. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | No | Config provider for routes and tools (used by chat **and** voice) |
| `onToolCall` | `(name, args) => Promise` | No | Handler for tool execution (used by chat **and** voice) |
| `theme` | `Theme` | No | Widget [styling options](/docs/customization/styling) |
| `onRedirect` | `(path: string) => void` | No | Custom navigation handler |
| `disableRestartButton` | `boolean` | No | Hide the restart button in the header |
| `disablePageContent` | `boolean` | No | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `trigger` | `boolean \| TriggerButtonConfig` | No | Render the floating trigger button. **Defaults to `false`** — set `trigger: true` (or pass a config object) or the widget mounts with no way to open it. |
| `user` | `{ id, hash }` | No | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). Call `setUser()` to change it after setup (login/logout). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
Returns a `YakApi` object (also injected into the component tree).
### useYak
Access widget controls from any descendant component:
```vue
```
| Property | Type | Description |
| --- | --- | --- |
| `isOpen` | `Readonly>` | Whether the chat panel is currently open |
| `isReady` | `Readonly>` | Whether the widget iframe is ready |
| `chatLoading` | `Readonly>` | `isOpen && !isReady` — opening but not yet interactive |
| `open` | `() => void` | Open the chat panel |
| `close` | `() => void` | Close the chat panel |
| `openWithPrompt` | `(prompt: string) => void` | Open and send a specific prompt |
| `subscribeToToolEvents` | `(handler) => () => void` | Subscribe to tool call events (returns unsubscribe) |
| `voiceMachine` | `Readonly>` | Current voice state (`state`, optional `errorMessage`) |
| `voiceLoading` | `Readonly>` | `true` while the voice session is connecting |
| `voiceStart` | `() => Promise` | Start a voice session — see [Voice Mode](/docs/customization/voice) |
| `voiceStop` | `() => Promise` | Stop the current voice session |
| `voiceToggle` | `() => Promise` | Start if idle/error, stop if active |
| `setUser` | `(user?: { id, hash }) => void` | Set or clear the [signed end-user identity](/docs/customization/end-user-identity) after setup — call on login/logout |
`isOpen` and `isReady` are readonly Vue refs — use `.value` in scripts and unwrap automatically in templates.
### useYakToolEvent
Subscribe to tool call completion events — automatically cleans up on component unmount. Useful for [keeping your UI in sync](/docs/customization/ui-synchronization) with agent actions:
```vue
```
See [UI Synchronization](/docs/customization/ui-synchronization) for more details.
## Router Integration
Pass Vue Router's navigation function to `onRedirect` for client-side navigation:
```vue
```
---
# Custom Adapters
URL: https://docs.yak.io/docs/tool-adapters/custom
For databases, custom services, or any logic not covered by schema-based adapters, you can build custom tool adapters.
## Adapter Interface
A tool adapter implements the `ToolSource` interface:
```ts
const myAdapter: ToolSource = {
id: "my-adapter",
// Return available tool definitions
getTools: async () => [
{
name: "my-adapter.action",
description: "Performs an action",
inputSchema: {
type: "object",
properties: {
param: { type: "string" },
},
required: ["param"],
},
},
],
// Execute a tool call
executeTool: async (name: string, args: Record) => {
if (name === "my-adapter.action") {
return { result: `Executed with ${args.param}` };
}
throw new Error(`Unknown tool: ${name}`);
},
};
```
## Database Adapter Example
```ts
const databaseTools: ToolSource = {
id: "database",
getTools: async () => [
{
name: "db.searchOrders",
description: "Search orders by customer email or status",
inputSchema: {
type: "object",
properties: {
email: { type: "string" },
status: {
type: "string",
enum: ["pending", "shipped", "delivered"]
},
limit: { type: "number", default: 10 },
},
},
},
{
name: "db.getOrderDetails",
description: "Get full details of an order",
inputSchema: {
type: "object",
properties: {
orderId: { type: "string" },
},
required: ["orderId"],
},
},
],
executeTool: async (name, args) => {
switch (name) {
case "db.searchOrders":
return db.orders.findMany({
where: {
...(args.email && { customerEmail: args.email }),
...(args.status && { status: args.status }),
},
take: args.limit ?? 10,
});
case "db.getOrderDetails":
return db.orders.findUnique({
where: { id: args.orderId },
include: { items: true, customer: true },
});
default:
throw new Error(`Unknown tool: ${name}`);
}
},
};
```
## Using Custom Adapters
Pass adapters to your handler:
```ts
tools: [databaseTools, otherAdapter],
});
```
## Combining with GraphQL / REST adapters
Browser-executed adapters (`@yak-io/graphql`, `@yak-io/rest`) and your own client-side tools all
compose through `createYakToolset`, which yields one merged manifest and one routed `onToolCall`:
```tsx
// Your own client-side tools, expressed as a ToolAdapter.
const databaseTools = {
id: "db",
getTools: () => [{ name: "db.searchOrders", description: "Search orders" }],
// Optional: claim your tools by name so the toolset can route without
// resolving every adapter's manifest first.
ownsTool: (name) => name.startsWith("db."),
execute: async (name, args) => {
/* call your service... */
},
};
const toolset = createYakToolset([
databaseTools,
createRESTToolAdapter({ name: "externalApi", spec: externalSpec, execute: callExternalApi }),
]);
return (
({ routes, ...(await toolset.getConfig()) })}
onToolCall={toolset.onToolCall}
>
);
}
```
To mix in **server-executed** tools (e.g. a tRPC adapter behind `createNextYakHandler`), add a
`createYakServerAdapter({ endpoint: "/api/yak" })` to the same `createYakToolset([...])` array.
`ownsTool` is optional. Omit it and the toolset routes by matching the names your `getTools()` returns — which means it must resolve every adapter's manifest before it can dispatch. Providing it lets the toolset dispatch immediately, and lets an adapter claim a whole namespace without enumerating it up front.
## Best Practices
### Use Clear Naming
Prefix tool names with adapter ID:
```ts
{
name: "db.searchOrders", // Clear it's from database adapter
name: "payments.refund", // Clear it's from payments adapter
}
```
### Write Descriptive Descriptions
Help the AI understand when to use each tool:
```ts
{
name: "db.searchOrders",
description: "Search orders by customer email, status, or date range. Returns a list of order summaries.",
}
```
### Validate Inputs
Even with JSON Schema validation, add runtime checks:
```ts
executeTool: async (name, args) => {
if (name === "db.getOrderDetails") {
if (!args.orderId || typeof args.orderId !== "string") {
throw new Error("orderId is required and must be a string");
}
// Continue...
}
}
```
### Handle Errors Gracefully
Return meaningful errors:
```ts
executeTool: async (name, args) => {
try {
return await db.orders.findUnique({ where: { id: args.orderId } });
} catch (error) {
if (error.code === "NOT_FOUND") {
return { error: `Order ${args.orderId} not found` };
}
throw error;
}
}
```
### Add Authorization
Check user permissions:
The third argument is the incoming `Request` itself — read cookies or headers off it to identify the caller:
```ts
executeTool: async (name, args, req) => {
const user = await getUser(req);
if (name === "db.searchOrders") {
// Only allow users to search their own orders
return db.orders.findMany({
where: {
userId: user.id,
...otherFilters,
},
});
}
}
```
Always validate that the current user should be able to perform the requested operation.
---
# GraphQL Adapter
URL: https://docs.yak.io/docs/tool-adapters/graphql
The `@yak-io/graphql` package exposes a GraphQL API to the assistant as a single
`graphql_` tool. The model authors a query or mutation from the SDL you provide, and the
adapter hands the request to **your** client to execute — Yak never makes the call itself, so your
existing auth, transport, and error handling all apply.
Adapters compose into one tool manifest and one `onToolCall` via `createYakToolset`, so GraphQL
calls flow through the same path as every other tool, including
[`useYakToolEvent`](/docs/customization/ui-synchronization).
## Installation
```bash
npm install @yak-io/graphql
```
```bash
pnpm add @yak-io/graphql
```
```bash
yarn add @yak-io/graphql
```
```bash
bun add @yak-io/graphql
```
## Quick Start
```tsx
const schema = `
type Order { id: ID! total: Float! status: String! }
type Query {
orders(status: String): [Order!]!
order(id: ID!): Order
}
type Mutation { updateOrderStatus(id: ID!, status: String!): Order }
`;
const toolset = createYakToolset([
createGraphQLToolAdapter({
name: "shop",
schema,
// Run the model-authored request with your own client. Use anything — Apollo, urql,
// graphql-request, or a plain fetch like this. Return `data`; throw to surface an error.
execute: async ({ query, variables }) => {
const res = await fetch("https://api.example.com/graphql", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
},
}),
]);
return (
({ routes, ...(await toolset.getConfig()) })}
onToolCall={toolset.onToolCall}
>
);
}
```
The model receives the SDL in the tool's description and produces a `{ query, variables }`
request. Yak passes that request straight to your `execute` callback and returns whatever it
resolves to — you own the endpoint, credentials, and transport.
Already have a configured GraphQL client? Hand the request straight to it:
`execute: (req) => myGraphQLClient.request(req.query, req.variables)`. The adapter only builds the
tool and the request payload — it never owns the client.
## Lazy schema (introspection)
If your SDL comes from introspection — or any other deferred source — pass `schema` as a resolver
instead of a string. The factory stays synchronous, so you still construct the adapter without
`await`; the resolver runs once when the toolset first materializes its tools, and the resulting SDL
is cached.
```ts
createGraphQLToolAdapter({
name: "shop",
// Resolved once, on first use — not at construction.
schema: async () => {
const res = await fetch("https://api.example.com/graphql", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ query: getIntrospectionQuery() }),
});
const { data } = await res.json();
return printSchema(buildClientSchema(data));
},
execute: ({ query, variables }) => myGraphQLClient.request(query, variables),
});
```
The resolver is invoked once and cached, so introspection runs a single time — not on every chat
open. A rejection is _not_ cached: a transient failure simply retries on the next load.
## Multiple APIs
Each adapter owns one schema. Compose several — they merge into one manifest and route by tool
name automatically:
```ts
const toolset = createYakToolset([
createGraphQLToolAdapter({ name: "users", schema: usersSchema, execute: runUsersQuery }),
createGraphQLToolAdapter({ name: "inventory", schema: invSchema, execute: runInventoryQuery }),
]);
```
## Configuration
| Option | Type | Description |
| ---------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `name` | `string` | Tool name suffix — exposed as `graphql_`. |
| `schema` | `string \| (() => string \| Promise)` | GraphQL SDL — a string, or a resolver returning it (sync or async). Resolved once and cached; use a resolver for introspection. |
| `execute` | `(request: GraphQLRequest) => unknown \| Promise` | Runs the model-authored `{ query, variables, operationName }` with your client. Return the result (typically `data`); throw to surface an error. |
| `id` | `string` | Stable id for diagnostics. Defaults to the tool name. |
## Auth & transport
Your `execute` callback owns the request, so authentication, headers, base URL, retries, and
error handling are entirely yours — reuse the same authenticated client the rest of your app
already uses. If `execute` runs in the browser, the endpoint must allow your origin (CORS); to
keep the call server-side instead, route it through your backend (e.g. front your API with
[`createNextYakHandler`](/docs/tool-adapters/custom) and bridge it via `createYakServerAdapter`).
## Migrating from `schemaSources`
Earlier versions used `getConfig().schemaSources` plus an `onGraphQLSchemaCall` prop. Replace both
with a `createGraphQLToolAdapter` composed through `createYakToolset` (see Quick Start). The schema
now travels in the tool definition, and your old `onGraphQLSchemaCall` handler becomes the
adapter's `execute` callback — same idea (Yak hands you the request, your client runs it), now
scoped per adapter and flowing through `onToolCall`, so `useYakToolEvent` fires for GraphQL calls too.
Be careful which operations you expose. Avoid mutations that could be destructive without
proper authorization checks on your endpoint.
---
# Tool Adapters
URL: https://docs.yak.io/docs/tool-adapters
Tool adapters let you expose APIs, databases, and services as tools the AI can invoke. This section covers both schema-based and explicit adapters.
Looking to connect a third-party provider (Zendesk, Notion, Confluence, …)
instead of wiring tools in code? See
[Knowledge Integrations (MCP)](/docs/integrations) — an admin connects a
remote MCP server once from the dashboard.
## Adapter Types
### Schema-Based Tools
Provide your API schema (GraphQL or OpenAPI) and an `execute` callback. Yak turns the schema into a tool the model can call, then hands each model-authored request to your callback — your own client runs it (auth, transport, error handling), and the result flows back through the unified `onToolCall` funnel.
- [GraphQL](/docs/tool-adapters/graphql) – Provide your schema + an `execute` callback
- [REST/OpenAPI](/docs/tool-adapters/rest) – Provide your spec + an `execute` callback
### Explicit Tool Adapters
Define tools programmatically with full control over the interface.
- [tRPC](/docs/tool-adapters/trpc) – Expose tRPC procedures as tools
- [Custom](/docs/tool-adapters/custom) – Build your own adapter
## Quick Comparison
| Approach | Best For | Setup |
| --- | --- | --- |
| Schema-based | Existing APIs with schemas | Provide schema + an execute callback |
| tRPC Adapter | Existing tRPC routers | Configure allowed procedures |
| Custom Adapter | Databases, custom logic | Define tools and execution |
## Combining Adapters
Use multiple adapters together:
```ts
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
allowedProcedures: ["orders.list", "orders.get"],
});
const databaseTools = {
id: "database",
getTools: async () => [...],
executeTool: async (name, args) => {...},
};
tools: [trpcTools, databaseTools],
});
```
## Best Practices
### Whitelist Operations
Only expose safe operations:
```ts
// ✓ Safe read operations
allowedProcedures: ["orders.list", "orders.get", "products.search"]
// ✗ Never expose destructive operations without safeguards
// "admin.deleteUser", "billing.refund"
```
### Use Descriptive Names
Help the AI understand when to use each tool:
```ts
{
name: "orders.searchByStatus",
description: "Search orders by their current status (pending, shipped, delivered)",
}
```
### Handle Errors Gracefully
Return meaningful error messages:
```ts
executeTool: async (name, args) => {
try {
return await executeOperation(name, args);
} catch (error) {
if (error instanceof AuthError) {
return { error: "Not authorized to access this data" };
}
throw error;
}
}
```
Be careful about which operations you expose. Always add authentication and validate that the current user should be able to perform the action.
---
# REST/OpenAPI Adapter
URL: https://docs.yak.io/docs/tool-adapters/rest
The `@yak-io/rest` package exposes a REST API to the assistant as a single `rest_` tool.
The model authors a request (method, path, query, body) from the OpenAPI spec you provide, and the
adapter hands it to **your** client to execute — Yak never makes the call itself, so your existing
base URL, auth, and transport all apply.
Adapters compose into one tool manifest and one `onToolCall` via `createYakToolset`, so REST
calls flow through the same path as every other tool, including
[`useYakToolEvent`](/docs/customization/ui-synchronization).
## Installation
```bash
npm install @yak-io/rest
```
```bash
pnpm add @yak-io/rest
```
```bash
yarn add @yak-io/rest
```
```bash
bun add @yak-io/rest
```
## Quick Start
```tsx
const openApiSpec = {
openapi: "3.0.0",
paths: {
"/orders": { get: { summary: "List orders" } },
"/orders/{id}": { get: { summary: "Get order by ID" } },
},
};
const toolset = createYakToolset([
createRESTToolAdapter({
name: "orders",
spec: openApiSpec,
// Run the model-authored request with your own client (base URL, auth, transport).
// Use anything — axios, your app's API client, or a plain fetch like this.
execute: async ({ method, path, query, body }) => {
const url = new URL(path, "https://api.example.com");
if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
},
}),
]);
return (
({ routes, ...(await toolset.getConfig()) })}
onToolCall={toolset.onToolCall}
>
);
}
```
The model receives the spec in the tool's description and produces a
`{ method, path, query, body }` request. Yak passes that request straight to your `execute`
callback and returns whatever it resolves to — you own the base URL, credentials, and transport.
Already have an API client (axios, a configured `fetch`, your app's SDK)? Hand the request
straight to it. The adapter only builds the tool and the request shape — it never owns the client.
## Lazy spec (fetched at runtime)
If your OpenAPI spec lives behind a URL — or is generated at runtime — pass `spec` as a resolver
instead of an object or string. The factory stays synchronous, so you still construct the adapter
without `await`; the resolver runs once when the toolset first materializes its tools, and the
resulting spec is cached.
```ts
createRESTToolAdapter({
name: "billing",
// Resolved once, on first use — not at construction.
spec: async () => {
const res = await fetch("https://api.example.com/openapi.json", {
headers: { Authorization: `Bearer ${getToken()}` },
});
return res.json();
},
execute: ({ method, path, query, body }) => myApiClient(method, path, { query, body }),
});
```
The resolver is invoked once and cached, so the spec is fetched a single time — not on every chat
open. A rejection is _not_ cached: a transient failure simply retries on the next load.
## Multiple APIs
```ts
const toolset = createYakToolset([
createRESTToolAdapter({ name: "billing", spec: billingSpec, execute: callBillingApi }),
createRESTToolAdapter({ name: "catalog", spec: catalogSpec, execute: callCatalogApi }),
]);
```
## Configuration
| Option | Type | Description |
| --------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `name` | `string` | Tool name suffix — exposed as `rest_`. |
| `spec` | `Record \| string \| (() => Record \| string \| Promise<…>)` | OpenAPI spec — an object, a JSON string, or a resolver returning either (sync or async). Resolved once and cached; use a resolver to fetch the spec lazily. |
| `execute` | `(request: RESTRequest) => unknown \| Promise` | Runs the model-authored `{ method, path, query, body }` with your client. Return the result; throw to surface an error. |
| `id` | `string` | Stable id for diagnostics. Defaults to the tool name. |
## Auth & transport
Your `execute` callback owns the request, so the base URL, authentication, retries, and error
handling are entirely yours — reuse the same authenticated client the rest of your app already
uses. If `execute` runs in the browser, the API must allow your origin (CORS); to keep the call
server-side instead, route it through your backend (e.g. front your API with
[`createNextYakHandler`](/docs/tool-adapters/custom) and bridge it via `createYakServerAdapter`).
## Migrating from `schemaSources`
Earlier versions used `getConfig().schemaSources` plus an `onRESTSchemaCall` prop. Replace both
with a `createRESTToolAdapter` composed through `createYakToolset` (see Quick Start). Your old
`onRESTSchemaCall` handler becomes the adapter's `execute` callback — same idea (Yak hands you the
request, your client runs it), now scoped per adapter and flowing through `onToolCall`, so
`useYakToolEvent` fires for REST calls too.
Keep the spec focused on the operations you want to expose, and enforce authorization on your
API for any write operations.
---
# tRPC Adapter
URL: https://docs.yak.io/docs/tool-adapters/trpc
The `@yak-io/trpc` package adapts your tRPC procedures into tools. It introspects your router to generate tool definitions and handles execution with proper context.
## Installation
```bash
npm install @yak-io/trpc
```
```bash
pnpm add @yak-io/trpc
```
```bash
yarn add @yak-io/trpc
```
```bash
bun add @yak-io/trpc
```
## Quick Start
The simplest way to integrate tRPC is with `createTRPCToolAdapter`. By default, all procedures from your router are exposed as tools:
```ts
// app/api/yak/[[...yak]]/route.ts
// All procedures are available by default
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
});
tools: [trpcTools],
});
```
### Restricting procedures
Use `allowedProcedures` to whitelist specific procedures:
```ts
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
allowedProcedures: ["orders.list", "orders.getById", "products.search"],
});
```
Use `disallowedProcedures` to block specific procedures while allowing the rest:
```ts
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
disallowedProcedures: ["admin.deleteUser", "billing.refund"],
});
```
### Using separate manifest and executor
For more control, you can use `buildToolManifest` and `createTRPCToolExecutor` separately:
```ts
const disallowedProcedures = [
"admin.deleteUser",
"billing.refund",
];
getTools: async () => buildToolManifest(appRouter, { disallowedProcedures }),
executeTool: createTRPCToolExecutor({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
disallowedProcedures,
}),
});
```
## API Reference
### createTRPCToolAdapter
Creates a complete tool source with manifest and executor bundled:
```ts
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
// Optional: restrict to specific procedures
disallowedProcedures: ["admin.deleteUser"],
});
```
| Option | Type | Description |
| --- | --- | --- |
| `router` | `AnyRouter` | Your tRPC app router |
| `createContext` | `(opts?) => Promise` | Context factory |
| `allowedProcedures` | `string[]` | Optional whitelist of procedures. If omitted, all procedures are allowed. |
| `disallowedProcedures` | `string[]` | Optional blocklist of procedures. Applied after `allowedProcedures`. |
| `id` | `string` | Optional source identifier (default: `"trpc"`) |
### buildToolManifest
Introspects a router and generates tool definitions:
```ts
// All procedures
const tools = buildToolManifest(appRouter);
// Only specific procedures
const tools = buildToolManifest(appRouter, {
allowedProcedures: ["orders.list", "orders.getById"],
});
// All except specific procedures
const tools = buildToolManifest(appRouter, {
disallowedProcedures: ["admin.deleteUser"],
});
```
Returns tool definitions with:
- Names from procedure paths (e.g., `orders.list`)
- Descriptions from procedure metadata
- JSON schemas from Zod input validators
### createTRPCToolExecutor
Creates an executor function:
```ts
const executeTool = createTRPCToolExecutor({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
disallowedProcedures: ["admin.deleteUser"],
});
```
| Option | Type | Description |
| --- | --- | --- |
| `router` | `AnyRouter` | Your tRPC app router |
| `createContext` | `(opts?) => Promise` | Context factory |
| `allowedProcedures` | `string[]` | Optional whitelist of procedures |
| `disallowedProcedures` | `string[]` | Optional blocklist of procedures |
## Procedure Filtering
By default, all procedures from your router are exposed as tools. You can restrict access using:
- **`allowedProcedures`** – Whitelist approach: only specified procedures are available
- **`disallowedProcedures`** – Blocklist approach: all procedures except specified ones are available
When both options are provided, `allowedProcedures` is applied first, then `disallowedProcedures` filters the result.
```ts
// Blocklist approach (recommended for most cases)
const disallowedProcedures = [
// Block sensitive operations
"admin.deleteUser",
"billing.refund",
"system.reset",
];
// Whitelist approach (for maximum control)
const allowedProcedures = [
// Safe read operations
"orders.list",
"orders.getById",
"products.search",
// Controlled mutations
"cart.addItem",
"user.updatePreferences",
];
```
## Adding Descriptions
The description is what the model reads to decide *when* to call a procedure, so it's the highest-leverage thing you can add. Set it with `.meta({ description })`:
```ts
// server/trpc/router.ts
list: publicProcedure
.meta({ description: "List all orders for the current user" })
.query(async ({ ctx }) => {
return ctx.db.orders.findMany({ where: { userId: ctx.userId } });
}),
getById: publicProcedure
.meta({ description: "Get detailed information about a specific order" })
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.orders.findUnique({ where: { id: input.id } });
}),
});
```
A procedure without a `description` falls back to a generated `tRPC procedure ()` — enough for the model to know the procedure exists, but not what it's for. Describe anything you want the assistant to choose deliberately.
Requires `@yak-io/trpc` 0.3.6 or later; earlier versions ignored `meta` and always used the generated description.
## Context Handling
Use your existing `createContext` function:
```ts
executeTool: createTRPCToolExecutor({
router: appRouter,
createContext, // Pass directly
}),
});
```
The executor passes the incoming request to your context factory, so authentication works automatically.
## Troubleshooting
### Tool not appearing in manifest
- If using `allowedProcedures`, verify the procedure path is in the list
- If using `disallowedProcedures`, ensure the procedure is not in the blocklist
- Check the procedure is exported from your router
- Ensure the procedure has a Zod input schema
### Context errors
- Verify `createContext` returns the expected shape
- Check authentication is passed correctly
### Procedure not executing
- Check server logs for validation errors
- Verify the procedure path matches exactly (case-sensitive)
- Ensure input matches the Zod schema
---
# Next.js SDK
URL: https://docs.yak.io/docs/sdks/nextjs
The `@yak-io/nextjs` package provides the best experience for Next.js applications with automatic route scanning, a CLI for production builds, and App Router integration.
## Prerequisites
- Next.js 14+ (App Router)
- React 18+
- Node.js 18+
- A Yak app ID (from your dashboard)
## Installation
```bash
npm install @yak-io/nextjs
```
```bash
pnpm add @yak-io/nextjs
```
```bash
yarn add @yak-io/nextjs
```
```bash
bun add @yak-io/nextjs
```
## Quick Start
### Configure environment
Add your app ID to `.env.local`:
```bash
NEXT_PUBLIC_YAK_APP_ID=yak_app_123
```
### Add the API handler
Create a catch-all route that serves configuration and handles tool calls:
```ts
// app/api/yak/[[...yak]]/route.ts
```
### Wrap your layout
Add `YakProvider` and `YakWidget` to your root layout:
```tsx
// app/layout.tsx
return (
{children}
);
}
```
That's it — the widget appears in your app and can navigate users between pages.
## Adding Tools
Pass tool adapters to expose your APIs to the AI assistant. Here's an example with tRPC:
```ts
// app/api/yak/[[...yak]]/route.ts
const trpcTools = createTRPCToolAdapter({
router: appRouter,
createContext: async ({ req }) => createContext({ req }),
allowedProcedures: ["orders.list", "orders.detail", "products.search"],
});
tools: [trpcTools],
});
```
See [Tool Adapters](/docs/tool-adapters) for all available options including [tRPC](/docs/tool-adapters/trpc), [GraphQL](/docs/tool-adapters/graphql), [REST/OpenAPI](/docs/tool-adapters/rest), and [custom adapters](/docs/tool-adapters/custom).
Sourcing pages from a headless CMS instead of (or in addition to) the filesystem? See the [Prismic adapter](/docs/sdks/prismic) for a full Next.js example that composes with `createNextYakHandler`.
## YakProvider Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `appId` | `string` | — | Your Yak application ID (required) |
| `mode` | `"chat" \| "voice" \| "both"` | `"chat"` | Which surfaces the trigger exposes. See [Voice Mode](/docs/customization/voice). |
| `getConfig` | `() => Promise \| ChatConfig` | Fetches from `/api/yak` | Custom config provider (used by chat **and** voice). May return the config synchronously. |
| `onToolCall` | `(name, args) => Promise` | POSTs to `/api/yak` | Custom tool call handler (used by chat **and** voice) |
| `theme` | `Theme` | — | Widget [styling options](/docs/customization/styling) — including `position` and `colorMode` |
| `trigger` | `boolean \| TriggerButtonConfig` | `false` | Render a built-in trigger button instead of mounting `YakWidget` yourself |
| `onRedirect` | `(path: string) => void` | — | Custom navigation handler |
| `disableRestartButton` | `boolean` | `false` | Hide the restart button in the chat header |
| `disablePageContent` | `boolean` | `false` | Stop sending any page context (URL, title, and visible text) to the assistant. The widget still works, but it won't be aware of the page the user is on. |
| `user` | `{ id, hash }` | — | Signed end-user identity. Enables [conversation persistence and history](/docs/customization/end-user-identity). |
**Privacy:** By default Yak shares the current page's URL, title, and visible text with the assistant so it can answer questions about the page the user is viewing. Set `disablePageContent` to turn this off entirely — the SDK then sends nothing about the page (not even the URL), so the assistant can't answer page-specific questions.
## YakWidget Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode` | `"chat" \| "voice" \| "both"` | inherited from provider | Override the provider mode for this trigger |
| `lightButton` / `darkButton` | `{ background?, color?, border? }` | — | Custom pill colors per mode |
Position and color mode are **not** props on `YakWidget` — they come from the provider's `theme`. Set `theme={{ position: "bottom-left", colorMode: "dark" }}` on `YakProvider` instead. See [Styling & Theming](/docs/customization/styling) for the eight available positions.
## Next Steps
- [Voice Mode](/docs/customization/voice) — Add a voice icon to the trigger pill
- [Automatic Routes](/docs/sdks/nextjs/routes) — How route scanning works and production setup
- [Manual Routes](/docs/sdks/nextjs/manual-routes) — Define routes explicitly or fetch them dynamically
- [Programmatic Control](/docs/customization/programmatic-control) — Open the widget, send prompts from code
- [Styling](/docs/customization/styling) — Customize appearance and theming
---
# Manual Routes
URL: https://docs.yak.io/docs/sdks/nextjs/manual-routes
If you'd rather not rely on filesystem scanning, you can define routes explicitly. This is useful when you want precise control over what's exposed, need to include routes from external sources, or are building a non-standard application structure.
## Static Route List
Pass a `routes` array to bypass automatic scanning entirely:
```ts
// app/api/yak/[[...yak]]/route.ts
routes: [
{ path: "/", title: "Home" },
{ path: "/pricing", title: "Pricing", description: "View our plans" },
{ path: "/dashboard", title: "Dashboard" },
],
});
```
When you provide `routes`, automatic filesystem scanning is disabled. Only the routes you list will be visible to the AI.
## Combining Route Sources
Mix automatic scanning with additional manual sources using the `scanRoutes` helper:
```ts
const marketingRoutes = {
id: "marketing",
getRoutes: async () => [
{ path: "/", title: "Home" },
{ path: "/pricing", title: "Pricing", description: "View our plans" },
],
};
routes: [() => scanRoutes("./src/app"), marketingRoutes],
});
```
Each route source is an object with an `id` and a `getRoutes` async function, or a function returning routes directly.
## Dynamic Route Sources
Fetch routes at runtime from external sources like a CMS or database:
```ts
const cmsRoutes = {
id: "cms",
getRoutes: async () => {
const res = await fetch("https://cms.example.com/api/pages");
return res.json();
},
};
routes: [cmsRoutes],
});
```
## Route Schema
Each route object supports these properties:
| Property | Type | Required | Description |
| --- | --- | --- | --- |
| `path` | `string` | Yes | The URL path |
| `title` | `string` | No | Human-readable page title |
| `description` | `string` | No | Brief description for AI context |
| `search` | `{ queryParam: string }` | No | Marks a free-text search page so the AI can drive it (e.g. `/search?q=…`) |
| `filters` | `{ param, description?, values? }[]` | No | Query-param filters the route accepts, so the AI can narrow a listing |
Descriptive titles and descriptions help the AI make better decisions about when to navigate users to specific pages. To make the AI prefer **driving your UI** (navigating to a search or filtered listing) over rendering results in the chat, declare `search`/`filters` — see [Search & filter routes](/docs/sdks/nextjs/routes#search--filter-routes).
---
# Automatic Routes
URL: https://docs.yak.io/docs/sdks/nextjs/routes
Routes tell the AI what pages exist in your application, enabling it to navigate users to the right place when asked. The recommended approach is automatic scanning — Yak reads your filesystem to discover routes with zero configuration.
## Default Behavior
By default, `createNextYakHandler` automatically scans your `./src/app` directory. It skips `api/` folders and extracts `title` and `description` from static metadata exports in your page files.
```ts
// app/api/yak/[[...yak]]/route.ts
// Scans ./src/app automatically
```
The handler scans the **App Router only**. If your pages live in a `pages/` directory, list them explicitly with the `routes` option — see [Manual Routes](/docs/sdks/nextjs/manual-routes).
That's it — no route list to maintain. As you add or remove pages, Yak picks them up automatically.
## Customizing Route Scanning
Override the directories to scan or filter which routes are included:
```ts
appDir: "./app", // Override app directory path
routeFilter: {
include: [/^\/docs/], // Only include routes matching these patterns
exclude: [/^\/docs\/drafts/], // Exclude routes matching these patterns
},
});
```
If `routeFilter.include` is set, at least one regex must match the path. `routeFilter.exclude` removes any matching routes after inclusion.
## Production Setup
**This setup is required for production deployments** when using automatic scanning. If you provide routes manually via the `routes` option, you can skip this section.
During local development, routes are scanned from your filesystem. In production, the compiled `.next` output doesn't include source files like `./src/app`, so route scanning fails. The route manifest solves this by pre-generating routes at build time.
### Generate the manifest at build time
Add a `prebuild` script to your `package.json`:
```json
{
"scripts": {
"prebuild": "yak-nextjs generate-manifest",
"build": "next build"
}
}
```
This generates `./src/yak.routes.ts` containing your routes.
**Different directory structure?** The CLI auto-detects common layouts. For non-standard paths:
| Project structure | Command |
|-------------------|-------------------------------------------------------|
| `src/app/` (default) | `yak-nextjs generate-manifest` |
| `app/` | `yak-nextjs generate-manifest --app-dir ./app --output ./app/yak.routes.ts` |
| `src/app/` + `src/pages/` | `yak-nextjs generate-manifest --pages-dir ./src/pages` |
| `app/` + `pages/` | `yak-nextjs generate-manifest --app-dir ./app --pages-dir ./pages --output ./app/yak.routes.ts` |
### Add to `.gitignore`
Since the file is generated at build time:
```bash
# Generated route manifest
src/yak.routes.ts
```
### Use the route manifest in your handler
```ts
// app/api/yak/[[...yak]]/route.ts
routes: createRouteManifestAdapter({ routes }),
});
```
The import path depends on your `tsconfig.json` paths. Common configurations:
- `src/yak.routes.ts` → `@/yak.routes` (when `@/*` maps to `./src/*`)
- `app/yak.routes.ts` → `@/yak.routes` (when `@/*` maps to `./app/*`) or use a relative path
### Filtering Manifest Routes
Control which routes from the manifest are exposed using `allowedRoutes` and `disallowedRoutes`:
```ts
routes: createRouteManifestAdapter({
routes,
allowedRoutes: ["/docs/*", "/pricing", "/"],
disallowedRoutes: ["/docs/internal/*"],
}),
});
```
Pattern matching:
- **Exact match**: `"/pricing"` matches only `/pricing`
- **Prefix match**: `"/docs/*"` matches `/docs`, `/docs/getting-started`, etc.
## Search & filter routes
By default the assistant's first instinct for a "show me…" / "find…" request is to **drive your UI** — navigate the user to a page that answers it — rather than fetch the data and render it inside the chat. For that to work for searches and filtered listings, tell Yak which routes are searchable and which query params they accept. The assistant only ever uses params you declare here; it never invents them.
Add `search` and/or `filters` to a route. Use the `transform` callback to annotate auto-scanned routes (the scanner can't infer these), or set them directly when you author routes manually:
```ts
// app/api/yak/[[...yak]]/route.ts
routes: createRouteManifestAdapter({
routes,
transform: (route) =>
route.path === "/products"
? {
...route,
// Free-text search: "show me boots" → /products?q=boots
search: { queryParam: "q" },
// Filters: "size 10 boots" → /products?q=boots&size=10
filters: [
{ param: "category", description: "Product category" },
{ param: "size", values: ["8", "9", "10", "11"] },
],
}
: route,
}),
});
```
| Field | Type | Description |
| --- | --- | --- |
| `search` | `{ queryParam: string }` | Marks the route as a free-text search page. The assistant sets `queryParam` to the user's terms. |
| `filters` | `{ param, description?, values? }[]` | Query-param filters the route accepts. Provide `values` to constrain to a known set so e.g. "size 10" maps to `size=10`. Declared values pass through verbatim, casing included, so write them exactly as your data holds them (`Black` if that's the tag). Declare none and the assistant lowercases the value it puts in the URL. |
Your page must actually read these query params. Declaring `search`/`filters` only tells the assistant how to build the URL — make sure the destination page filters its results from `searchParams` (e.g. an `?q=` and `?category=` aware listing page), or the redirect will land on an unfiltered page.
### Filters come before free-text search
Where a filter covers what the shopper asked for, the assistant puts it there rather than in the search param, and only terms no filter covers fall through to free text. With `category` and `color` declared, "black dresses" becomes `/products?category=dresses&color=Black` — not `/products?q=black%20dresses` — so the shopper lands on a real faceted listing your page already knows how to render.
Values follow your data. Declare `values` and they are passed through as given: a catalog that tags a product `Black` gets `color=Black`. Declare none and the assistant lowercases the value, the safer default for slug-shaped params. Either way, treating filter values case-insensitively in your page is worth doing — a shopper typing the URL by hand won't match your casing either. If a param takes several values at once, say how in its `description` (e.g. "comma-separate several, e.g. `color=Black,Navy`").
When no route can serve a browse request, the assistant falls back to rendering the results in chat — so existing apps that don't declare search/filter routes keep working exactly as before.
### CLI Reference
```bash
yak-nextjs generate-manifest [options]
```
| Option | Default | Description |
| --- | --- | --- |
| `--app-dir ` | `./src/app` | App directory to scan |
| `--pages-dir ` | — | Pages directory (optional, scanned in addition to app-dir) |
| `--output ` | `./src/yak.routes.ts` | Output file path |