How-to Guide

Site navigation — one fire-and-forget go_to tool for any website

Let the agent move the visitor to the right page and section of your site while it answers. One client tool, two string arguments, no result to wait for, so it can never spiral or leave the avatar silent.

agent:    go_to({ path: "/guides/pause-resume/", section: "edge-case-dont" })
browser:  navigate("/guides/pause-resume/#the-edge-case-dont-leave-the-avatar-stuck-paused") → scroll → point
agent:    "Pause before the video starts and resume on its ended event..."

Three pieces, one shared contract:

Piece Entry point Runs where
Manifest builder + resolvers ./management (also src/core/site-keys.js) your site build, Node
goToTool(), siteMapPrompt(), SITE_NAV_RULES_PROMPT, loadSectionsManifest() ./management provisioning script, Node
SiteNavigator ./experience/site-nav browser, next to the session

Why fire-and-forget

A navigation tool needs no answer from the page. The tool is provisioned with wait_for_response: false, so the turn continues without waiting for the browser, and the browser never sends anything back. There is nothing to ACK, time out on, or retry.

The model writes its spoken answer in the same turn. Compare Client-Side Commands § Tool spirals for what happens to tools that do wait.

Two consequences shape the browser side:

The manifest (sections.json)

The site build publishes one public JSON file. The same file feeds the prompt (rendered as a SITE MAP) and the browser (resolution).

{
  "version": 1,
  "lang": "en",
  "generatedAt": "2026-09-04T10:00:00.000Z",
  "pages": [
    {
      "path": "/guides/pause-resume/",
      "title": "Pause/Resume for Video",
      "sections": [
        { "key": "what-it-is", "id": "what-it-is", "text": "What it is" },
        { "key": "edge-case-dont", "id": "the-edge-case-dont-leave-the-avatar-stuck-paused", "text": "The edge case: don't leave the avatar stuck paused" },
        { "key": "demo-box", "id": "demo-box", "text": "Live demo", "kind": "target" }
      ]
    }
  ]
}
Field Meaning
version Format version, always 1. validateSectionsManifest rejects anything else.
lang Language code used for the stop-word list.
pages[].path Site-relative path with leading slash, prefix-free (/guides/x/, not /docs/guides/x/). Sorted.
pages[].title Optional. When present, it's the first line of that page's block in the rendered SITE MAP, so it does reach the model. See Rendering the SITE MAP.
sections[].key Compact key the model uses (2–3 content words). Unique per page.
sections[].id Real DOM id of the element to scroll to.
sections[].text Heading text, used by the fuzzy resolver.
sections[].kind heading (default) or target for hand-placed anchors.

Only public data belongs here: paths and headings that are already on the page. Never add internal notes, draft pages, or anything a visitor should not see.

Building it

buildSectionsManifest(pages, opts) takes any page source: built HTML, a markdown tree, a SPA route table, a CMS export. Only path is mandatory per page.

import { buildSectionsManifest } from '@kaltura/intelligent-agents/management';

const manifest = buildSectionsManifest([
  { path: '/guides/pause-resume/', title: 'Pause/Resume', headings: [
    { id: 'what-it-is', text: 'What it is', level: 2 },
    { id: 'the-edge-case-dont-leave-the-avatar-stuck-paused', text: "The edge case: don't leave the avatar stuck paused", level: 2 },
  ], targets: [{ id: 'demo-box', text: 'Live demo' }] },
], { lang: 'en', depth: 2 });
writeFileSync('_site/nova/sections.json', JSON.stringify(manifest));
Option Default Effect
lang 'en' Picks the stop-word list. Only en ships.
stopWords { fr: ['le', 'de', 'la'] } adds or replaces a list for a language.
depth 2 Keep headings with level <= depth. Headings without a level are always kept. 3 adds h3 keys and about 50% more tokens.
maxWords 3 Preferred key length in words.
overrides { 'heading-id-or-text': 'better-key' } hand-fixes a weak key without touching the algorithm.
generatedAt now Pin it for reproducible builds and tests.

How keys are derived

Deterministic, so a docs change re-keys only the page it touched:

  1. NFKC-normalize the heading, drop URLs, drop apostrophes and dots inside words (don't → dont, v2.3 → v23), split on anything that is not a letter or number. Hebrew, CJK, accented Latin and emoji-laden headings all yield usable keys.
  2. Lower-case and drop stop words. A single upper-case letter like the A in Recipe A survives. Pure numbers drop when at least two other words remain.
  3. Take the first maxWords words, hyphen-joined. Fewer than two words left means the full word list is used; nothing left means section.
  4. On a collision within the page, extend the key one word at a time, then suffix -2, -3.
  5. Boilerplate headings (Related docs, Contents, On this page, Next steps and friends, see BOILERPLATE_IDS) are left out so the model never targets them.

pageSectionKeys(headings, opts) exposes step 1–5 for one page when you need only the keys.

Rendering the SITE MAP

renderSiteMap(manifest) gives the prompt text: two lines per page, the page title and then path: key1, key2, with a blank line between pages. About 45 tokens per page. The title lets the model match what a visitor calls a page ("the wire protocol page") to its path. The path line carries only the path and the keys: a label next to the path gets copied into go_to as part of the path.

siteMapPrompt(manifest) wraps it as a prompt block and warns (never throws) when the estimate passes maxTokens (default 3000).

Provisioning

import {
  Management, goToTool, siteMapPrompt, SITE_NAV_RULES_PROMPT, PAGE_CONTEXT_PROMPT, loadSectionsManifest,
} from '@kaltura/intelligent-agents/management';

const m = new Management({ partnerId, adminSecret });
const ks = await m.sessions.createAdminToken({ userId: 'admin@example.com' });
const manifest = await loadSectionsManifest('https://docs.example.com/nova/sections.json');

const tool = await m.tools.add(goToTool({ siteLabel: 'the Example docs' }), ks);
await m.intellects.create({
  type: 'internal',
  status: 2,
  tool_ids: [tool.id],
  allow_client_variables: true,
  base_directive: '...',
  prompts: [identityPrompt, siteMapPrompt(manifest), SITE_NAV_RULES_PROMPT, PAGE_CONTEXT_PROMPT], // identityPrompt is your own prompt block, not part of this SDK
  capabilities: { /* your own capabilities config */ },
}, ks);
Export What it is
goToTool({ name?, siteLabel?, timeout?, displayName? }) The client tool config. Wire shape: { name: 'go_to', type: 'client', wait_for_response: false, timeout: 5, args: { path, section } }. Same options produce the same config, so an upsert by name is idempotent. Pass it to tools.add() or tools.update(id, { config }).
siteMapPrompt(manifest, { key?, label?, maxTokens?, warn? }) The SITE MAP prompt block (type: 'custom').
SITE_NAV_RULES_PROMPT Frozen prompt block with four rules: call once when a SITE MAP page covers the topic, never call when none does, never two calls per reply, never mention the screen.
PAGE_CONTEXT_PROMPT Optional. Renders {{page_context}} so the browser can tell the agent which page the visitor is on. Needs allow_client_variables on. It is on by default. Pin it with intellects.setClientVariablesEnabled(configId, true, adminKs). See Dynamic Data Injection.
loadSectionsManifest(url, { fetch?, maxBytes?, timeoutMs? }) Fetches and validates the manifest at provisioning time. Size-guarded (512 KiB), 15 s timeout, throws KalturaError with code bad_arg, http_error, network_error, timeout, too_large or bad_manifest.
SITE_NAV_TOOL_NAME 'go_to'. Change the tool name only if you also rewrite the rules prompt.
estimateTokens(text) Rough estimate (characters / 3.2) used for the budget warning.

Prompt order matters: identity first, then SITE MAP, then rules, then page context. Turn kaltura_genie_experiences off on the intellect, otherwise the built-in experience tools compete with go_to.

Redeploys stay on the same tool id when you look the tool up by name and call tools.update(id, { config: goToTool() }). tools.delete refuses while an intellect still references the tool, so retire an old tool in a separate explicit step after the intellect's tool_ids has moved on.

Browser plugin

import { SiteNavigator } from '@kaltura/intelligent-agents/experience/site-nav';

const nav = new SiteNavigator({
  session,
  manifestUrl: '/nova/sections.json',
  navigate: (url) => router.push(url),          // SPA
  // navigate: (url) => location.assign(url),   // MPA full reload
  point: (el) => el.classList.add('is-pointed'),
  onNavigate: (info) => analytics.track('go_to', info),
});
// when the session ends
nav.destroy();

Construct it once per session, right after the session. destroy() unsubscribes everything and is idempotent (stop() is an alias). A second live navigator on the same session logs a warning.

Option Default Effect
session required Anything with onToolCall(name, handler) and on('turnStart', handler) returning unsubscribes: KalturaAvatarSession, KalturaChatSession, KalturaAgentSession.
navigate(url, info) required Moves the visitor to url (same origin, already validated). Return a promise to delay section scrolling until the new page is in the DOM. Called only for a different page.
manifest Inline manifest. Required unless manifestUrl is set. Serves until the fetch resolves when both are set.
manifestUrl Same-origin URL of sections.json. Fetched once, size-guarded (manifestMaxBytes, default 512 KiB), validated. Calls that arrive before the fetch resolves wait for it.
pathPrefix '' Deployment prefix (/docs on a project Pages site). Prepended to URLs and stripped from location.pathname.
scrollTo(el, info) scrollIntoView smooth/start Custom scroll.
point(el, info) Highlight hook, called after the scroll.
onNavigate(info) no-op Fires once per tool call with what happened (below).
currentPath() location.pathname minus prefix Same-page detection for SPAs with their own router state.
toolCallName 'go_to' Listen for a different tool name (several navigators, one per embedded product).
oncePerTurn true First call that resolves to a page wins; later ones in the same turn drop with reason: 'once_per_turn'. Invalid calls never use up the slot. Resets on turnStart with isNewTurn: true.
updateHash true Write #<section-id> with history.replaceState and append it to the URL given to navigate.
settleMs 250 After navigate resolves: look up the section now, then next frame, then once more after this delay.
warn(msg) console.warn Warning sink.
window globalThis Inject a window-like object for tests.

Behaviour contract

Rule Detail
No ACK ever The plugin never answers the tool call. There is nothing to respond to.
Act immediately The handler runs on the tool segment, whatever the speech state.
Path safety resolveTarget must return a manifest page. resolvePath first (exact → normalized → last-segment word overlap ≥ 0.5 with a single winner). When no page matches, the last path segment is tried as a section of the page named by the rest of the path; that parent must be an exact or normalized manifest path and the segment must resolve as one of its sections (splitPath: true). This catches a model that fuses { path: '/', section: 'license' } into { path: '/license' }. No match at all drops with reason: 'unknown_path'. A match whose final URL fails safeUrl drops with reason: 'unsafe_url' instead.
Section fallback key → id → normalized text equality → request words are a subset of one section → Jaccard ≥ 0.5 with a single winner → page top (fellBackToTop: true). On a split path the section argument wins when it resolves on the parent page, else the split-off segment is the section. A wrong section never fails the navigation.
Cross-page await navigate(url, info), then find the section in the new DOM (now, next frame, after settleMs), then scroll, hash, point.
Same page navigate is skipped. Scroll, hash, point.
One per turn See oncePerTurn. Identical name+args repeats are already dropped by the session itself.
Hash history.replaceState, never location.hash =, so there is no second scroll. Failures are swallowed.
DOM hygiene Elements are found by id. No selector building, no innerHTML, no eval.
Full reload When navigate reloads the page, the URL already carries #<id>, so the browser lands on the anchor natively and this instance dies with the page.
Manifest failure A failed fetch logs one warning and every call drops with reason: 'no_manifest'. Nothing throws.

onNavigate receives either a result or a drop:

// resolved
{ path, url, section, sectionId, resolvedBy, fellBackToTop, splitPath, samePage, sectionFound, args }
// dropped
{ dropped: true, reason: 'unknown_path' | 'once_per_turn' | 'no_manifest' | 'unsafe_url', args }

Telling the agent where the visitor is

Send the current page and its keys as the page_context request variable. session.setDynamicPrompt() sets it for you, or pass requestVars at connect. The manifest already has the keys:

const page = resolvePath(nav.manifest, location.pathname);
session.setDynamicPrompt({ url: page.path, sections: page.sections.map((s) => s.key) });

Adapters

Host navigate Manifest source
Eleventy or any static site generator location.assign(url) if the agent embed survives a reload, else a client-side router eleventy.after hook: parse each written .html for h2[id] and data-*-target elements, call buildSectionsManifest, write _site/nova/sections.json.
SPA (React, Vue, Svelte) router.push(url) returning a promise that resolves after the route renders Build step over the route table, or a runtime crawl with document.querySelectorAll('h2[id]') per route.
MPA with the agent in a persistent frame frame.location.assign(url) Same as static.
CMS (WordPress and others) location.assign(url) Export headings per page from the CMS API into PageInput[]. Full guide: WordPress and other multi-page sites.

WordPress and other multi-page sites

The question is what happens to the avatar when the visitor moves to another page. It depends on how the site changes pages.

Site type What happens on navigation What to do
SPA or headless front end, avatar outside the routed view Same document. The session, the mic and the video keep running. Keep it alive
Normal multi-page theme (full page load) The page and its session are destroyed. The next page must start a new session. Resume the thread
Page-transition library (Barba.js, Swup, htmx boost) Same document, but the library swaps part of the DOM. The avatar survives only if it is outside the swapped part or is preserved. Per-library rules

Normal multi-page theme: resume the thread

Every page load creates a new KalturaAvatarSession. The old one is gone with its page. What carries over is the thread. Save session.threadId and pass it as cfg.threadId on the next page. The join of the new session carries it, so the agent continues the same conversation.

import { KalturaAvatarSession } from '@kaltura/intelligent-agents/experience';
import { SiteNavigator } from '@kaltura/intelligent-agents/experience/site-nav';

// Your server mints a per-visitor token, calls appInit and returns its ks and host fields.
const { ks, conversationManagerUrl, srsBaseUrl, turnServerUrl } = await (await fetch('/api/avatar-session')).json();

const session = new KalturaAvatarSession({
  token: ks,
  conversationManagerUrl,
  srsBaseUrl,
  turnServerUrl,
  videoEl: document.querySelector('#avatar-video'),
  audioEl: document.querySelector('#avatar-audio'),
  socketFactory: (url, opts) => io(url, opts),
  threadId: sessionStorage.getItem('avatar.thread') || undefined,
});
session.on('turnEnd', () => {
  if (session.threadId) sessionStorage.setItem('avatar.thread', session.threadId);
});
session.on('warning', (w) => {
  if (w.code === 'playback_blocked') showTapToStart(() => session.startPlayback());
});

new SiteNavigator({
  session,
  manifestUrl: '/nova/sections.json',
  navigate: (url) => location.assign(url),
});
await session.connect();

What the visitor gets and what they do not:

Item After a full page load
Conversation memory Kept, through threadId.
Connection, video, audio Rebuilt. Expect a gap of a few seconds while the new session connects (about 2 s in a live test).
Microphone The new page calls getUserMedia again. The browser's permission rules decide whether it asks again.
Autoplay Allowed in most browsers, not guaranteed. A page load can still start paused. Handle playback_blocked as above.
Opening phrase Plays again on every join, also on a resumed thread. See Start the Conversation for the first-visit guard.
go_to navigate loads the new page, so the navigator dies with the old page. The URL already carries #<section-id>, so the browser scrolls to the section. A new navigator starts on the new page.
Back button Reloads the page and resumes the thread. Chromium does not restore the page from its back/forward cache: notRestoredReasons lists mediastream and rtc.
End-of-thread signal The old page sends it on pagehide (see README § Ending a conversation cleanly). Memory continues after that signal, so the next page can still resume.

Keeping the threadId in the browser is safe only with a per-visitor token (createAgentToken({ agentId, userId }), see Per-visitor browser path). With a widget token, anyone who has a threadId can continue that thread, so keep each threadId per user on your server instead. See Security & Compliance § Session type.

sessionStorage is per tab. Use localStorage only if a returning visitor in a new tab should also resume.

SPA and headless front ends

Keep the avatar elements outside the view the router replaces and create the session once at the app shell. Route changes do not touch the session, the mic or the picture. Give SiteNavigator the router:

new SiteNavigator({
  session,
  manifestUrl: '/nova/sections.json',
  navigate: (url) => router.push(url), // return a promise that resolves after the route renders
});

If the framework re-mounts the <video>, rebind it with session.setVideoEl(el). See README § Avatar audio and video rendering.

Page-transition libraries

All of these keep one document, so the session, the mic permission and the WebRTC connection survive. The risk is the avatar's DOM and your boot script.

Library Pattern that works Boot script
Barba.js Avatar outside the container. Navigate with barba.go(url). If the avatar must be inside the container, the old <video> is detached and the picture stops, while the voice continues. Rebind the picture (below). Inline scripts in the new container do not run again.
Swup Avatar outside the #swup container. Navigate with swup.navigate(url). Load the UMD build. If the avatar is inside the container, the picture stops in the same way. Rebind it in content:replace (below). Inline scripts do not run again.
htmx hx-boost Put hx-preserve="true" and an id on the avatar element. Without it, each swap builds a new session and the old ones keep their sockets and mic. htmx runs inline scripts in swapped content again. Put the boot script in <head> and guard against a second boot.

Barba rebind. Read the new container from the hook data. At afterEnter both containers are in the DOM, so getElementById finds the old element.

barba.hooks.afterEnter((data) => {
  const video = data.next.container.querySelector('#avatar-video');
  if (video) session.setVideoEl(video);
});
barba.init({ preventRunning: true });
new SiteNavigator({ session, manifestUrl, navigate: (url) => barba.go(url) });

Swup rebind. content:replace runs after the new content is in the DOM, so getElementById finds the new element.

swup.hooks.on('content:replace', () => {
  const video = document.getElementById('avatar-video');
  if (video) session.setVideoEl(video);
});

Boot guard for htmx or any setup where the script can run twice:

if (!window.avatarSession) {
  window.avatarSession = new KalturaAvatarSession(cfg);
}

WordPress

Verified on a WordPress block theme (Twenty Twenty-Five) with a footer script, with Barba.js and with Swup, against a live backend. The avatar markup and the boot module are printed from the footer. Persistent-container rules for Barba.js and Swup are in Page-transition libraries.

Path When Setup
1. Footer script, resume on each page Any theme that loads full pages. This is the default. Use the multi-page pattern. Verified: the thread, and with it the conversation memory, carried to the next page. A new visitor did not get it.
2. Keep the avatar alive The theme already uses a page-transition library. Avatar outside the swapped container, one boot, SiteNavigator calling the library's navigate, plus the two block theme fixes. Verified: one session, one thread and moving video across link clicks, go_to, Back and Forward.

Token endpoint. Add a route on your site that mints the visitor's token and returns the ks and host fields from appInit, as in the per-visitor browser path. A PHP route that makes those two calls over HTTP worked. A small Node service that runs Management works too.

Rule Why
The admin secret lives only in the server's environment. A scan of every page, script and JSON response from the verified site found no secret.
Use a stable userId: the WordPress user id, or an opaque id in an HttpOnly cookie for anonymous visitors. The thread belongs to that identity.
Send Cache-Control: no-store on the route. One visitor's token must never be served to another.
Fetch the token from the boot script at run time. Do not print it into the page. A cached page would freeze an expiring token.

Keep each threadId per user on the server when you use a widget token. See Security & Compliance § Session type.

Build sections.json from your published pages with buildSectionsManifest (see The manifest). Re-run it when content changes.

Block themes with a page-transition library

A router that swaps only one container breaks two things on a block theme. Both reproduced in Chromium, Firefox and WebKit.

  1. Back and Forward reload the page. The theme's Interactivity API reloads on popstate when the history entry has no wpInteractivityId. Barba.js and Swup write entries without it, so the avatar restarts on every Back. Keep the id on every entry the router writes. Run this once, before the router starts:
for (const method of ['pushState', 'replaceState']) {
  const original = history[method].bind(history);
  history[method] = (state, ...rest) => {
    const id = history.state?.wpInteractivityId;
    return original(state && typeof state === 'object' && id !== undefined ? { ...state, wpInteractivityId: id } : state, ...rest);
  };
}
  1. The new page is only half applied. The container swap leaves out the body classes, the per-page block styles (<style id="wp-block-…-inline-css">) and the inline scripts. Copy them from the fetched page after each swap:
function syncPage(html, container) {
  const next = new DOMParser().parseFromString(html, 'text/html');
  document.body.className = next.body.className;
  for (const el of next.querySelectorAll('style[id], link[rel="stylesheet"][id]')) {
    if (!document.getElementById(el.id)) document.head.append(el.cloneNode(true));
  }
  container.querySelectorAll('script').forEach((old) => {
    const fresh = document.createElement('script');
    for (const attr of old.attributes) fresh.setAttribute(attr.name, attr.value);
    fresh.textContent = old.textContent;
    old.replaceWith(fresh);
  });
}
// Barba.js: skip the first load, the page already ran its scripts.
barba.hooks.afterEnter((data) => { if (data.current.container) syncPage(data.next.html, data.next.container); });
// Swup:
swup.hooks.on('page:view', (visit) => syncPage(visit.to.html, document.getElementById('swup')));

With both fixes, the transitioned page matched a direct load of the same URL (title, body classes, style ids).

Not tested: whether a browser asks for the microphone again after a full page load (the tests granted it), caching plugins, and other themes or page-builder plugins.

Taking it to another app

  1. Produce sections.json with buildSectionsManifest from whatever you have. Only path is mandatory per page. Publish it at a public, same-origin URL.
  2. Provision: tool_ids: [ (await tools.add(goToTool({ siteLabel }), ks)).id ], prompts [identity, siteMapPrompt(manifest), SITE_NAV_RULES_PROMPT, PAGE_CONTEXT_PROMPT], allow_client_variables: true, kaltura_genie_experiences off.
  3. Browser: new SiteNavigator({ session, manifestUrl, navigate }).
  4. Optional: point for a visual pointer, onNavigate for analytics, lang and stopWords for a non-English site, overrides for keys that read badly.

Security

Doc What it adds
README.md § Site navigation Quick start and export tables.
Client-Side Commands The general client-command contract this tool is built on, and why waiting tools spiral.
Dynamic Data Injection page_context and the other ways to keep the agent in sync with the page.
Security & Compliance The output-handling rules the plugin follows.
Click to talk with Nova — she knows this whole SDK.
Nova AI assistant — knows this whole site

Nova is an AI assistant and can make mistakes. Check important details in the docs. Reloading starts a fresh chat.