# Browser SDK

Load /sdk/v1.js from your Telltale server once (/sdk.js is the original alias; to pin an exact build with Subresource Integrity, see the [Content Security Policy guide](/docs/csp)). The SDK is a browser script; do not execute browser globals during server rendering. Use the Site publishable key, never its server key.

**The hosted tag records from page load by default.** A site with a cookie or consent banner holds it instead: add data-telltale-wait-for-consent to the tag (or pass waitForConsent: true to init()), and it collects nothing until your banner calls optIn(). EU and UK sites usually need a consent banner for this kind of measurement and should hold. A visitor's saved refusal is always honored, and optOut() always stops collection and discards pending evidence.

## Supported browsers

The SDK targets current evergreen browsers: Chrome and Edge 98 or later, Firefox 104 or later, and Safari 15.4 or later (iOS 15.4). It relies on structuredClone, Object.hasOwn, Array.prototype.at, findLast and logical assignment, which set that floor. Older browsers are not supported: the script may fail to load or keep retrying uploads. AbortSignal.any (Safari 17.4, Chrome 116, Firefox 124) is used when present and is not required. WebMCP tool registration needs a browser that exposes document.modelContext or navigator.modelContext; collection works without it.

### Pointer evidence completeness

The behaviour batch's `d.p` is a bounded pointer-loss indicator, not an exact total of lost movement samples. It includes known coalesced samples omitted above the 64-sample per-dispatch limit, plus one affected dispatch when reading coalesced samples fails and the omitted count is unknown; the indicator is capped at 100,000 per batch. An unavailable coalescing API or a successful empty result is normal and does not set it. The original dispatched movement is still retained. Completeness-gated behavioural combinations cannot use a batch with a positive loss indicator to establish an absence of human input.

## Start on page load (sites without a consent banner)

`init()` only creates the client; it sends nothing until you call `start()` (the hosted tag calls it for you). When you initialize from your own code, start it in the same step:

```javascript
const client = window.Telltale.init({
  endpoint: 'https://app.trytelltale.dev',
  key: 'YOUR_SITE_PUBLISHABLE_KEY'
});
client.start();
```

## Start held (sites with a consent banner)

```javascript
const client = window.Telltale.init({
  endpoint: 'https://app.trytelltale.dev',
  key: 'YOUR_SITE_PUBLISHABLE_KEY',
  waitForConsent: true,
  discover: true
});
```

With waitForConsent: true, initialization is inert. Apply your CMP's current saved choice and subscribe to changes. Call await client.optIn() only when permitted; call await client.optOut() for refusal or withdrawal. An unknown choice stays held. Reapply the choice on each full navigation. Do not use login as consent. Keep one owner for initialization and CMP subscriptions; avoid combining direct, GTM and Zaraz installers.

## Identity and protected actions

Browser identity is unverified telemetry. Call await client.identify({userId, accountId}) using stable IDs from current authentication. identify() takes exactly one object with userId and/or accountId: either may be omitted or null, not both, and each is a non-empty string of at most 128 characters. Anything else, such as positional strings (identify('usr_123', 'acct_456')), an array, or an object with other keys, throws a TypeError and sends nothing. An optional second argument takes {signal}. Use await client.reset() on logout or identity replacement, then identify the new user if still permitted. If identify() is called with a different user than the one bound to the saved session (for example after a full-page logout and login), the SDK starts a fresh session first; it stores only a hash of that binding beside the saved session. The last identify() input is also kept in page memory (never stored), so a session the SDK replaces in the background is identified again and a different user is still detected; reset(), logout in another tab and withdrawal forget it. Reapproval must read current authentication instead of cached pre-withdrawal identity. Server-side POST /api/v1/identify establishes server-verified identity from your own authenticated session; it does not grant application authorization.

Obtain await client.getSessionToken() and send it to your own authenticated backend immediately before protected work. Never send a Site server key to the browser. The backend verifies evidence and applies its authorization policy. Unknown does not mean human.

## Native POST forms

For a browser-submitted HTML form that posts to your own backend, add the data-telltale attribute instead of writing token code:

```html
<form method="post" action="/signup" data-telltale>
  <input name="email" type="email" required>
  <button type="submit">Sign up</button>
</form>
```

When the browser builds the form data set (native submission, requestSubmit(), form.submit() or new FormData(form)), the SDK adds the current session token as the field telltaleSessionToken. Your backend reads that field and verifies it with POST /api/v1/verify exactly like a token sent with fetch.

The field is added only when all of these hold: exactly one Telltale client is initialized on the page, collection is permitted, a live session exists, the effective method is POST (a submit button's formmethod overrides the form's method), and the action is same-origin without credentials in the URL. Native GET submissions never receive it, so the token cannot leak into a query string. The SDK does not create a session or delay submission to obtain one; if no live session exists, the field is absent and your backend applies that action's failure posture (never treat a missing token as human). If the form already has a control named telltaleSessionToken, the SDK leaves its value alone, so treat the name as reserved. (The SDK also sends the same token under the field's older name, for backends written before the rename; see [Renamed names](#renamed-names).) Pages with more than one client should call the intended client's getSessionToken() and add the value themselves.

When calling `new FormData(form)` inside a submit handler, call `event.preventDefault()` first if you are handling the request yourself. Until the submission is cancelled or its task finishes, serialization uses the active submit button’s method and destination. After cancellation, standalone serialization uses the form’s own method and action. The browser does not reliably distinguish these two kinds of `formdata` event. Send token-bearing form data only to your own backend using POST.

## Get the page client

```javascript
const client = window.Telltale.getClient();
```

getClient() returns the already initialized client when the page has exactly one, for example one started by the hosted tag, Google Tag Manager or Cloudflare Zaraz, so your code does not need the key or endpoint. It never creates or starts a client, and it throws when the page has none or several. With several clients, pass the scope (window.Telltale.getClient({ endpoint, key })) or keep the value returned by init(). A managed installer may finish after your code runs; call getClient() after the SDK script has loaded (for example after the window load event) and handle the error when nothing is installed.

## Methods and lifecycle

| Method | What it does |
| --- | --- |
| `start()` | Starts collection when it is permitted. |
| `optIn()` / `optOut()` | Apply the consent decision: grant, or refuse and withdraw. Both return promises. optIn() never rejects because the Telltale server is unreachable, slow, rate limiting or temporarily unavailable: it resolves and keeps starting in the background (backoff up to a minute, Retry-After respected up to a day, and immediately when the browser comes back online) for as long as consent lasts. optOut() cancels that retry. |
| `identify({userId, accountId})` / `reset()` | Label the session with unverified browser identity, or clear it on logout and account switch. Both return promises. After start() or optIn(), reset() starts behaviour capture again on the fresh session, also when the first start was still failing. |
| `restart()` | Fully restarts capture on a fresh session: drops a pending start retry, queued evidence and the captureStatus() counters, and starts behaviour capture whether or not it ran before. The stored token is replaced in place, so other tabs sharing it move along instead of stopping; the page's last identify() input is applied to the new session. Needs consent (otherwise it does nothing); like optIn(), it never rejects for network trouble and keeps retrying in the background. |
| `flush()` | Sends the evidence gathered so far, including a current input snapshot when it changed, and resolves once the Telltale server has accepted it. Call `await client.flush()` right before your backend verifies the session at a protected action: the verdict then covers the click or key press that led there, and is the freshest one. It rejects when the upload fails (the evidence stays queued for the next try) and resolves without sending while collection is paused until consent. |
| `getSessionToken({timeoutMs})` | Returns a token for your backend to verify. It can start a token-only session, and rejects with NotAllowedError while collection is paused until consent, or withdrawn. With timeoutMs (1 to 120,000) it rejects with a TimeoutError whose code is network_unavailable once that time passes; the pending upload continues in the background. |
| `getSessionId()` | Returns the current session's ID as a string, the same `sessionId` that `GET /api/v1/sessions/{sessionId}` takes, or null before a session exists, while collection is withdrawn and while reset() replaces the session. It is synchronous and makes no request. The ID only names the session: it is never the session token, and no Telltale endpoint accepts it as one, so use getSessionToken() for verification. |
| `status()` | Telltale server reachability for this page: `{state, lastErrorCode, lastErrorAt, consent, session, retrying}`. state is ok, degraded (slow, rate limited or answering 5xx) or offline (unreachable). lastErrorCode session_reinit_required means the Telltale server stopped accepting the session (revoked, erased, expired, or out of browser contexts) and the SDK is starting a fresh one; it clears once the fresh session has started. http_<status> (for example http_409) means the Telltale server refused session or evidence traffic, so collection is not running. consent is granted (collection is permitted), held (waiting for a decision: the page holds the tag for its consent banner and nobody has answered yet) or withdrawn (optOut() was called, in this tab or another, or a stored refusal applies). Compare with granted when you only need to know whether collecting is permitted. It never starts anything and sends no request. |
| `captureStatus()` | Capture health, for a page that shows it: `{behaviorStarted, eventsSent, lastSentAt, network, session, consent}`. behaviorStarted is true while behaviour capture runs; eventsSent counts the events the Telltale server accepted for the current session (it restarts at 0 with a new session); lastSentAt is the time (ms since the epoch) of the last accepted upload, or null. network is ok, retrying (requests are failing and the SDK keeps trying) or failed (still failing after at least two attempts over 8 seconds or more, or refused in a way retrying cannot fix). The SDK keeps retrying after failed while consent lasts, and network returns to ok on the next answer. A first request that is still waiting reports ok with session false. consent has the same values as in status(). Withdrawal resets it. It sends no request. |
| `getConsent()` | Returns `allowed` and the storage status, `persistent` or `memory`. |
| `onConsentChange(callback)` | Calls `callback({allowed, storage})` synchronously with the current permission, then again only when `allowed` changes. Returns an unsubscribe function. |
| `onVerdict(listener)` | Calls `listener(verdict, evidence)` when the verdict or evidence scope changes. Returns an unsubscribe function. |
| `onAgent(handler, options)` | Opt-in: runs your handler while the stricter action verdict is AI agent, so your page can show its own notice. Returns an unsubscribe function. |
| `checkAgent(action)` | Asks whether a named action should be interrupted now. Resolves `{interrupted, handled}`. |
| `invokeTool(name, input, {signal, timeoutMs, executionId})` | Runs a reviewed agent tool in the page. timeoutMs is the run deadline (1,000 to 1,800,000; default 300,000); executionId labels the run in webhooks and the audit trail. |
| `readFile({id, offset, length})` | Reads an over-bound file result in slices, like the `observatory_read_file` WebMCP companion. |
| `getDownloads()` / `releaseDownload(id)` | Lists the files tool runs on this page returned (`{id, url, name, mimeType, size, expiresAt}`), or revokes one early. |
| `flushDiscovery()` | Waits until pending discovery observations (and, with persistDiscovery, the continuation journal) have been sent. |

Capture asked for with start() or optIn() survives a failed first start. If the first session request times out and another call (a getSessionToken() poll, identify()) establishes the session before the start retry runs, behaviour capture starts right after it.

onConsentChange passes the same object getConsent() returns, not a boolean: allowed is true or false, and storage is persistent or memory (memory when browser storage failed and the choice lasts only until reload). Read state.allowed; a listener written as if (state) runs on withdrawal too, because the object is always truthy. Example: client.onConsentChange(({ allowed }) => { banner.hidden = allowed }). It reports permission only, not whether a session has started, and it is not legal evidence of consent. An exception in your listener is ignored.

The onVerdict listener receives the verdict string (human, agent or unknown) or null, plus an isolated object with the session ID, verdict, actionVerdict and analyticsOnly. `analyticsOnly` is true when the session reads AI agent for analytics (it shows in the dashboard's counts) but that evidence is not strong enough to select the AI agent rule or count as chargeable, so `actionVerdict`, the stricter verdict used for protection and chargeable use, is not Agent. The page never receives reasons, scores or the detector version: an agent running in the page could read them and learn what gave it away. Your server gets the reasons from verify, and the dashboard shows them. The first call is asynchronous, so registering never runs your code synchronously and does not start the SDK. Later calls happen when the verdict or evidence scope changes, not on every batch. null means this page has no verdict for its active session; reset() and withdrawal publish null and keep the subscription. unknown is a detector verdict, not a human one. Example: client.onVerdict(verdict => { popover.hidden = verdict !== 'agent' }).

To learn which reviewed agent tools the page offers, pass onTools in the init() options. It is called when the tool list, WebMCP support or the number of failed registrations changes, with {tools, revision, supported, registrationErrors}:

- tools lists the published manifest entries (name, title, description, inputSchema, annotations, revision, webmcpName). Each is registered under the SDK's tool prefix and its versioned name, `<prefix><name>__<revision>` (see Versioned names in the [WebMCP guide](/docs/webmcp)).
- revision is the catalogue revision: it changes whenever a tool is added, removed or revised.
- supported is true when the page exposes a WebMCP registerTool (document.modelContext or navigator.modelContext).
- registrationErrors is the number of tools whose registration currently fails (for example a name another script already registered).

Withdrawal and stop() call it with an empty list, and an error thrown by your callback is ignored. It is an init() option only: getClient() and pre-loader configuration take no callback. Example: Telltale.init({ endpoint, key, waitForConsent: true, onTools: ({ tools, supported }) => { pill.hidden = !supported || !tools.length } }).

Consent denial rejects token retrieval with NotAllowedError. Superseded work may reject with AbortError. When the Telltale server cannot be reached or does not answer in time, SDK calls reject with an error whose code is network_unavailable (name NetworkError, or TimeoutError for a deadline; reason is unreachable, offline or timeout) instead of a bare TypeError. Every collector request has a deadline of 10 seconds (init option requestTimeoutMs, 1,000 to 60,000). Handle other failures according to the protected action; do not swallow them and proceed. The SDK handles one recovery attempt for unusable sessions. When a background upload or start finds the session revoked, erased or expired, or the session has reached its 100 browser contexts (HTTP 409 session_reinit_required), the SDK discards that session's pending evidence and starts a fresh session (at once the first time, then with the start backoff), and status() reports session_reinit_required meanwhile. The fresh token replaces the stored one in place, so other open tabs of the site move to the new session instead of stopping, and the page's last identify() input is applied to it. HTTP 429 defers collector requests until Retry-After, for at most a day. Session, evidence, identity and tool-list requests share that cooldown; request and discovery telemetry (/api/sdk/request, /api/sdk/discover, /api/sdk/workflow) has its own per route, so throttled telemetry never delays evidence uploads or getSessionToken(). Application writes are never automatically replayed.

Evidence is uploaded straight after start (the first batch carries the environment snapshot), then checked every 2 seconds, and also as soon as 100 events are queued (at most once a second, never during a 429 cooldown), so a burst such as scripted typing leaves in one upload (up to 200 events) before the 200-event queue drops its oldest events: an upload happens only when something changed (input, a new environment snapshot, a probe result, or page request shapes waiting). While the page is idle, an unchanged environment snapshot is re-sent at most every 20 seconds, which keeps the session inside the Telltale server's 60-second evidence window; while the tab is hidden it is not re-sent at all, and the tool list is not refreshed (input in a hidden tab is still reported). getSessionToken() sends a current snapshot first when nothing was uploaded in the last 2 seconds, and a changed one when other evidence is waiting, so verification never sees idle-tab staleness. For the freshest verdict at a protected action, call flush() first. Collector requests use a text/plain JSON body, so browsers send no CORS preflight, and the tool list is revalidated with its ETag (an unchanged list is a bodiless 304). Measured on an idle visible tab: about 9 requests and 3.5 KB a minute (it was 48 requests and 49 KB); a hidden idle tab sends nothing. An early upload follows about 150 ms after a strong signal class changes: a controller marker appears or disappears, an automation artifact or input flag is first observed, trusted input arrives while the tab is hidden, the page visibility changes, or a click is untrusted or lands exactly at a target's centre. Only fixed labels are compared and sent. Withdrawal cancels a pending early upload. When the page is being left (pagehide), queued evidence, together with any upload still in flight, is sent with a keepalive request so it survives the navigation (the Telltale server ignores events it already has); that request stays under the browser's 64 KiB keepalive limit, less the SDK's other keepalive requests still in flight, and sends the newest events when the queue is larger. It is not sent while evidence uploads are in a 429 cooldown, nor after withdrawal.

Nested scrolling containers retain bounded numeric diagnostics separately from window scrolling. The first observed scroll establishes that container’s baseline and does not invent a movement distance. Later observations retain direction and distance only, with no element names, identifiers or content. They supply no positive classification evidence; unchanged repeated events are not counted twice. Collection withdrawal clears their baselines. At most eight nested-scroll observations are sampled per batch, using only spare payload capacity after core evidence. Missing samples are not zero activity or lifetime totals; they never evict core evidence or count as lost classification evidence.

Paths leave the page as templates. Page paths (navigation events and the page a discovered request was made from) have identifiers and free text templated: search phrases, names, hyphenated phrases and every non-route word after a search, user, people, tag or message word become `{name}`, and the Telltale server re-templates them whatever the client sends. Endpoint paths keep single lowercase words literal (`/search/repositories`), and a request whose path holds free text is not observed at all.

With capture 'requests', one observation is sent per method, path template, page and status in any 10 seconds, so polling does not repeat it. With either requests or schema capture, at most 32 observations wait to be sent, and none are taken while that route is in a 429 cooldown (form and download observations included). Beyond 32, a route not yet observed on the page replaces a waiting repeat of another route; otherwise each observation of the burst has an equal chance to be kept, so a page that always issues more requests at once does not lose the same routes on every load. The rest are dropped rather than buffered.

## Init options and re-initialization

Besides endpoint, key and waitForConsent, init() takes these options:

| Option | Values | What it does |
| --- | --- | --- |
| `capture` | `'behavior'` (default), `'requests'`, `'schema'` | How much the SDK observes on this page. behavior sends interaction and environment signals only. requests also reports same-origin request metadata (method, path template, status). schema also sends request and response shapes for action discovery: field names and types, never values. The Site's own capture setting in the dashboard is a ceiling: the effective level is the lower of the two. |
| `discover` | boolean | Shorthand: true is capture 'schema', false is capture 'behavior'. Passing both discover and a capture that disagree throws. |
| `linkExclusions` | up to 100 JSON pointers starting /request or /response | Fields the SDK must not use when it links values across requests to propose workflow steps (for example /request/body/token or /response/items). The values are compared in the page only. |
| `captureChoices` | boolean | Records the choices a page declares for a field (select options, radio values) as an enum. Schema capture only. |
| `captureLabels` | boolean (default true) | Links discovered requests to the button, link, menu item, option or tab that triggered them, and reads field descriptions. Without the Site's label reveal setting, only the role and owner-marked text (`data-telltale-label`, `toolparamdescription`) are sent; with it, other labels are sent as pseudonymous tokens first. See **Control and field labels** below and [Agent tools](/docs/webmcp). Schema capture only. false turns it off. |
| `persistDiscovery` | boolean | Continues workflow discovery across full page loads in the same tab (sessionStorage). |
| `toolPrefix` | '' or a name ending in _ | Prefix for registered tool names, for pages with more than one client. |
| `behaviourStream` | boolean (default true) | Sends the compact behaviour stream with event uploads, for the shadow behaviour model and the behaviour combination (two or more families of clues agreeing; see /docs/verdicts): input timing and geometry as small integers. Version 2 adds sub-pixel pointer positions, every pointer sample from shortly before to shortly after each press, the pointer's polling-interval bucket, the pressed control's size, role and form position (never its id, name or text), raw wheel steps, resize deltas, selection and page lifecycle events, and yes/no key-provenance bits. Pressure and contact size are sent only for touch and pen. It also sends the **shapes of the page's own requests**: for each same-origin fetch, XHR, form submission and sendBeacon, the method, the path template, the status class, the initiator kind and the milliseconds since the last press or key; never a query string, body, header value or another origin's address. Never key values, typed text or anything from password fields or `data-telltale-ignore` elements. Since heuristic-v4.27 a Human verdict needs it (the input-pipeline delay it measures shows the input came from a physical device), so with false a session can read AI agent or Unknown, never Human. false turns both off. See [How it works](/docs/how-it-works). |
| `requestCorrelation` | boolean (default false) | Adds an `X-Observatory-Request` header (a historical name, stable on purpose; see [Renamed names](#renamed-names)) with 16 random characters to each same-origin fetch and XHR the page makes while collection runs, and sends the same value with the request shapes, so your backend adapter can tell whether a protected request came from this page. Off by default because a backend that rejects unknown headers would refuse those requests. Never added to other origins, form submissions or beacons. |
| `cspForbidsEval` | boolean (default false) | Declares that your Content-Security-Policy forbids eval. The SDK then tries `new Function` once per start: if it runs, the policy was rewritten on the way (a page diagnostic, never a verdict). Off by default because the blocked attempt sends your CSP report endpoint a violation report. |
| `requestTimeoutMs` | 1,000 to 60,000 (default 10,000) | Deadline for each request to the Telltale server. A request that misses it fails with code network_unavailable. |
| `sessionScope` | `'browser'` (default), `'tab'` | Where the session token is kept. browser keeps it in localStorage, so every tab of the site in this browser continues one session. tab keeps it in this tab's sessionStorage, so each tab has its own session and verdict (reloads and same-tab navigation continue it; a duplicated tab starts from a copy of it). Use tab only where tabs must be told apart, such as a demo comparing a person's tab with an agent working in another tab of the same browser. The consent decision stays shared in localStorage either way, so withdrawal in any tab stops every tab. |

linkExclusions does not redact anything. It only stops those fields from being used to link one request's values to another's. With schema capture, the excluded fields' names and types are still sent as part of each action's shape, like every other field. To keep requests out of discovery, use capture 'behavior' or 'requests' on that page, or turn discovery off for the Site; sensitive routes (sign-in, tokens, payment and similar) are never observed. Request values are never sent either way.

Calling init() again with the same endpoint and key returns the same client; it never creates a second one or restarts collection. Options you pass again must match the first call: waitForConsent, discover, capture, persistDiscovery, captureChoices, captureLabels, behaviourStream, requestCorrelation, cspForbidsEval, linkExclusions, toolPrefix and sessionScope cannot change on a live client, and a different value throws (Conflicting initialization option). Options you leave out are ignored. To change one of them, reload the page with the new configuration. A different key or endpoint creates a separate client, which then needs its own tool prefix.

## Where the SDK stores state

The SDK keeps its state in the page origin's storage, under keys that start with observatory:, followed by the collector origin and the publishable key. That prefix is a historical name, stable and kept on purpose so existing installs keep working (see [Renamed names](#renamed-names)):

| Key | Storage | Holds | Cleared by |
| --- | --- | --- | --- |
| `observatory:<collector>:<key>:consent` | localStorage | The consent decision from optIn() or optOut(): {version, allowed, revision}. Written only when your code calls one of them, never on a held start. | Never expires. reset() keeps it. Remove it yourself to forget the decision. |
| `observatory:<collector>:<key>:session` | localStorage (sessionStorage, per tab, with sessionScope 'tab') | The current session token (expires after 24 hours; the SDK counts them on the browser's own clock from when init answered, so a device clock that is hours or days off neither stops collection nor stretches the session). | reset(), optOut(), a token that can no longer be verified. |
| `observatory:<collector>:<key>:identity` | Beside the session token | Short non-reversible hashes of the session token and the identified IDs, so a different user never continues the session. Never the IDs themselves. | Cleared with the session. |
| `observatory:<collector>:<key>:discovery` | sessionStorage, per tab | Workflow discovery in progress, only with persistDiscovery. | Withdrawal and reset discard it; it never outlives the tab. |

No cookies are set. Pending evidence lives only in page memory. When storage is unavailable, the SDK keeps everything in memory until reload and getConsent() reports storage: 'memory'.

## Interrupt agents in the page (optional)

Nothing happens unless your page registers a handler. onAgent lets your site show its own notice, or stop one of its own actions, while the stricter action verdict is AI agent. Analytics-only Agent does not interrupt. Handlers never run for Unknown or Human, and they stop after reset() or withdrawal.

```js
const resolveAttribution = async ({ sessionToken }) => {
  // Your backend calls POST /api/v1/verify and returns { strength, controller } from evidence.attribution.
  const r = await fetch('/api/agent-attribution', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionToken }) });
  return r.ok ? (await r.json()).attribution : null;
};

// Page notice: once per page per agent episode, on chosen pages only.
client.onAgent(({ attribution }) => showNotice(attribution), { pages: ['/reports', '/admin/*'], resolveAttribution });

// Action check: only when your code asks.
client.onAgent(() => showStopDialog(), { actions: ['delete_report'] });
deleteButton.onclick = async () => {
  if ((await client.checkAgent('delete_report')).interrupted) return;
  submitDelete();
};

// Only for signed or weak-marker agents (for example an upsell), never for unattributed automation.
client.onAgent(offerAgentPlan, { attribution: ['signed', 'weak-marker'], resolveAttribution });
```

| Option | Meaning |
| --- | --- |
| `pages` | Exact paths, or a prefix ending in `*`. Without it, every page. |
| `actions` | Action names your code passes to `checkAgent()`. With it, the handler runs only from those checks, never from a verdict change. |
| `attribution` | Strengths to match: `signed`, `weak-marker`, `declared` (self-declared user agent; spoofable), `unattributed-automation`. It needs `resolveAttribution`. |
| `resolveAttribution` | `async ({ sessionToken }) => ({ strength, controller })`, normally your backend calling verify. It runs once per agent episode. A failure means no attribution, and a handler with an attribution filter then does not run. |

The handler receives a frozen object: `verdict` (always `agent`), `page`, `action` (or null) and `attribution` (`{ strength, controller }` or null). The browser verdict carries no attribution strength, so without a resolver `attribution` is null: describe the session as automation and do not name a controller or claim that an AI model is involved. A controller is passed on only with signed or weak-marker strength. There is no confidence value.

**This is user experience, not security.** An agent can ignore, close or remove anything in the page. Keep the decision on your server: verify the session token and apply your policy before every protected action. The [live demo](/docs/demo) shows what a page can say to an agent, and the local playground has three scenario pages (/demo/booking, /demo/tickets, /demo/reviews) with a live Session panel. See the [Agent monetization guide](/docs/agent-monetization) for the upsell pattern.

## Storage and withdrawal

Preferences are scoped to page origin, collector and publishable key. Malformed records hold collection. Changed preference revisions invalidate work at SDK checkpoints, even when storage events were missed. Calling optIn() when the stored decision is already a grant keeps that record and its revision, so re-applying an unchanged CMP choice on each navigation does not hold the page's other open tabs; a refusal, a withdrawal or a new grant after one is a change, and every other tab holds. Peer approval cannot restart a blocked page. Storage failures keep the page in memory mode until reload; getConsent() reports that state. An ordinary non-held client with unreadable storage retains its allowed default: consent-managed installations must always start held (data-telltale-wait-for-consent or waitForConsent: true).

While request capture is installed, the SDK also keeps one number in page memory: the time of the last trusted pointerdown or keydown (window capture listeners, never the key, target or position). A request-shape or tool-call upload carries only `inputAgeMs`, the whole milliseconds since then, or null when there was none. Withdrawal removes those listeners and forgets the time. The Telltale server itself derives a connection-origin label from the address it already sees and never stores the address; see [How it works](/docs/how-it-works#network-and-timing-diagnostics).

Withdrawal stops owned collection/tools and discards pending evidence, including the pending behaviour-stream batch and the page-request shapes not yet sent; the revoke also deletes the session's raw behaviour batches on the Telltale server. It cannot recall already transmitted data, application-held tokens, accepted writes or server history. Withdrawal also revokes the SDK session on the Telltale server with one keepalive POST to /api/sdk/revoke that carries only the publishable key and the session token, never evidence. It is the only request a withdrawal makes, and nothing follows it; afterwards verify answers session_reinit_required for that token, so a token captured before withdrawal stops verifying. If the beacon cannot be delivered (offline, closed tab), the token stays verifiable until it expires. Storage failures cannot guarantee durable refusal or physical deletion. Upgrade every installer and reload old SDK pages.

## Hosted loader and pre-loader configuration

A single tag can initialize the SDK. Attributes: data-telltale-key (required), data-telltale-endpoint (defaults to the script origin), data-telltale-discover, data-telltale-persist-discovery, data-telltale-wait-for-consent, data-telltale-consent, data-telltale-user-id and data-telltale-account-id. The tag starts recording on page load:

```html
<script src="https://app.trytelltale.dev/sdk/v1.js" data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY"></script>
```

If your site shows a cookie or consent banner, hold the tag until the banner allows measurement, then call Telltale.getClient().optIn() (see the [Consent guide](/docs/consent)):

```html
<script src="https://app.trytelltale.dev/sdk/v1.js" data-telltale-key="YOUR_SITE_PUBLISHABLE_KEY" data-telltale-wait-for-consent></script>
```

data-telltale-wait-for-consent accepts a bare attribute, true or false; data-telltale-wait-for-consent="false" records on page load, as the default does. data-telltale-consent="granted" also records on page load; any other data-telltale-consent value holds. When both are present, data-telltale-wait-for-consent wins. Never remove the hold merely to bypass a CMP.

When attributes are impractical (for example a tag manager that cannot add them), define window.TelltaleConfig before the SDK tag, in a script your CSP already trusts:

```javascript
window.TelltaleConfig = {
  key: 'YOUR_SITE_PUBLISHABLE_KEY',
  endpoint: 'https://app.trytelltale.dev',
  discover: true
};
```

Accepted fields: key, endpoint, waitForConsent, consent ('granted' only), discover, persistDiscovery, userId and accountId. Booleans must be real booleans. Unknown fields, wrong types or an invalid key or endpoint stop automatic initialization, as malformed attributes do. Like the tag, the object records on page load by default; set waitForConsent: true when a consent banner decides.

When the tag has data-telltale-key, the SDK uses the attributes only and ignores window.TelltaleConfig entirely; the two sources are never merged. Identity values are applied once per script evaluation and are not replayed on later reapproval.

## Renamed names

The product is Telltale; names from before the rename (2026-11-03) keep working, so nothing installed needs to change. Use the new names for anything new. When a page uses both forms of one name, the new one wins.

| Use now | Older name, still accepted |
| --- | --- |
| `window.Telltale` | `window.AgentObservatory` (the same object) |
| `window.TelltaleConfig` | `window.AgentObservatoryConfig` (read only when TelltaleConfig is absent; the two are never merged) |
| `data-telltale-key`, `data-telltale-endpoint`, `data-telltale-wait-for-consent`, `data-telltale-consent`, `data-telltale-discover`, `data-telltale-persist-discovery`, `data-telltale-user-id`, `data-telltale-account-id`, `data-telltale-page-templates`, `data-telltale-agent-route-banner` on the script tag | the unprefixed `data-key`, `data-endpoint`, `data-wait-for-consent` and so on. Each attribute is read on its own: a `data-telltale-*` value wins over the unprefixed one, and an attribute present only under its old name still applies (an old `data-wait-for-consent` still holds a tag that uses `data-telltale-key`). |
| `data-telltale` on a native form | `data-observatory` |
| `telltaleSessionToken` form field | `observatorySessionToken`. The SDK sends both, with the same token; the Cloudflare Worker reads either, the new name first. |
| `data-telltale-ignore`, `data-telltale-label`, `data-telltale-field` | `data-observatory-ignore`, `data-observatory-label`, `data-observatory-field` |
| `createTelltaleServer`, `createUseTelltale`, `useTelltaleVue`, and the `telltale` option of `createBetterAuthAssociationHandler` | `createObservatoryServer`, `createUseObservatory`, `useObservatoryVue`, and the `observatory` option (the same functions) |
| `TelltaleClient`, `TelltaleServer`, `TelltaleGlobal`, `TelltaleConfig` and the other `Telltale*` types | `ObservatoryClient`, `ObservatoryServer`, `AgentObservatoryGlobal`, `AgentObservatoryConfig` and the other `Observatory*` types (deprecated aliases) |
| `Telltale.installManaged` (called by newly downloaded GTM templates) | `AgentObservatory.installManaged` (called by templates downloaded earlier) |
| GTM dataLayer events `telltale_consent_decision`, `telltale_identity_ready` | `observatory_consent_decision`, `observatory_identity_ready`. These are your container's own events, so containers that already use the old names keep working unchanged. |

The server's own environment variables are now named `TELLTALE_*` (Server › Configuration); the older `OBSERVATORY_*` names still work as aliases and log one deprecation line at start. These names did not change, because deployments and stored data depend on them: API paths (including the Better Auth default route `/api/observatory/associate`), HTTP headers (`X-Observatory-Request`, `X-Observatory-SDK`, `X-Observatory-Tools-Revision`, `Observatory-Attribution`, `Observatory-Warning`, `Observatory-Signature`, `Observatory-Event-Id`), the browser storage keys (`observatory:<collector>:<key>:…`), the WebMCP file companion `observatory_read_file`, the default tool prefix of a second client (`observatory2_`), the `observatory/error` and `observatory/file` result metadata keys, the confirmation dialog `#observatory-confirm`, the OpenAPI extensions `x-observatory-*`, the Zaraz track events `observatory.identify` and `observatory.reset`, the evaluation export file name and the adapters package name `@agent-observatory/adapters`. The docs MCP server is now `telltale-docs`; a client you already added as `agent-observatory-docs` keeps working, because only the local label changed, not its address.

## Discovery and deployment

With discovery enabled for the Site, supported same-origin request/response shapes can become proposed actions and workflows. Raw typed content, cookies and request-body values are not telemetry. Review actions before enabling; drift disables approval. File uploads and cross-origin actions are not supported.

**Optional fields and drift.** Each action keeps a union of the request shapes it has seen, with a `required` set. A field missing from a later request becomes optional and is not drift. A never-seen field, a changed type, a new custom header, or a different method, path, encoding or GraphQL document is drift: the action is disabled until you review it again and an update draft opens for workflows that were enabled. A request shape never takes a new field or type without review. Only label-level widening refines silently: a shape never observed (`{}`, an empty array or a nested empty object) gaining its first shape, an integer field later seen with a fraction (it becomes `number`), and a format label dropped after a value that does not fit it. A new type keeps both as `anyOf`, so a field that alternates between two types drifts once, not on every switch. A query or form field once seen as an array (repeated, or `name[]`) stays an array; a single value later is not drift. An all-digit identifier seen where a hex or opaque identifier was reviewed keeps the reviewed format (uuid and ulid stay distinct). `null` is a type, so a field seen only as `null` that later carries a value is drift; a typed field later sent as `null` keeps its reviewed type and is not drift. A field the API genuinely removed cannot be told apart from an omitted optional one; repeated agent failures surface it instead (see below).

**Responses.** Response shapes are merged into a superset rather than replaced: an empty body or `null` fits any type (a nullable field becomes `anyOf` with `null`), fields seen in some responses and not others are all kept, an empty list keeps its item type, and an integer field later seen with a fraction becomes `number`. A response that only gets wider never disables an enabled tool. The response stays part of the stored contract and fingerprint, because workflow bindings and polling read it, but only a new concrete type where a typed value was seen (a string field turning into a number, an object into a list) is drift. A response that is not JSON is recorded as its media type only (`text/html`, or none for an empty answer such as 204), never as a JSON object.

**Recorded shapes.** Shapes carry names, types and format labels only. The page computes the labels: `integer` versus `number`, and `uuid`, `date-time`, `date`, `email` or `uri` for strings; the value never leaves the page. Lists are typed from up to 20 items, merged (mixed item types become `anyOf`). Object keys are sorted before the 30-key limit, so key order never changes a shape. Keys that cannot be recorded (secret-looking names, names that are not plain identifiers such as `_id` or `first name`, keys past the limit) are left out, and that object then omits `additionalProperties: false`, so the application's own request still validates; unlisted keys pass, except secret-looking and prototype names. Past five levels of nesting the shape is unobserved (`{}`), not a guessed type. Any of these, and query keys skipped because they are not plain identifiers (a jQuery `_` cache-buster, `a.b`), marks the action *incomplete*; Tools shows which parts. Request lists are bounded at 100 items for tool input; published response lists carry no bound.

**GraphQL and JSON-RPC.** A POST whose JSON body is a GraphQL request (`query`, `operationName`, `variables`, or a persisted-query hash) or a JSON-RPC 2.0 call (`jsonrpc`, `method`, `params`) becomes one action per operation: the operation name, or a signature of the literal-stripped document, or the RPC method is part of its identity. A GraphQL mutation or subscription also carries its operation type in that identity, so a query and a mutation with the same name are separate actions. Only the name, operation type, signature and the types of `variables`/`params` are stored. The GraphQL document stays in page memory (cleared on pause, reset or withdrawal); the runner rebuilds the request from it, so an agent can call a GraphQL tool after the page has issued that operation once, or at any time when the application uses persisted queries. Operations with sensitive names (login, sign-in, token, password, session, verification, billing, checkout, payment and similar) are never observed. Batched requests and GraphQL over GET are not supported.

**Custom request headers.** Names (never values) of non-standard request headers, such as `X-CSRF-Token`, are part of an action's shape. `Authorization`, `Cookie` and browser-controlled headers are never recorded. The tool drawer in Tools shows "Needs x-csrf-token"; choose where the runner reads the value at call time: a `<meta name>` tag, a double-submit cookie name, or your `beforeRequest(request, action, context)` hook. The value is read in the page and sent only in the request to your API. A call refuses before sending when no value is available. Your backend still authorizes every request.

**Multi-page flows.** Besides fetch and XMLHttpRequest, the SDK observes uncancelled same-origin native form submissions (GET query fields, or URL-encoded and text-only multipart POST fields) and clicks on `<a download>` links, as field names only. Forms with password or file inputs (including controls associated with the form through a form attribute), anything inside `data-telltale-ignore`, and sensitive routes are skipped. Sensitive routes, never observed by the SDK and refused by the server, are paths containing auth, login, logout, sign-in (signin, sign_in, sign-in), token, password, secret, session, verify or verification, billing, checkout or payment, or the whole path words otp, mfa, 2fa, card or credit-card. The proposed actions start disabled. When a form endpoint answers with a redirect, the runner does not follow it and reports the submission. Navigations to attachments without a `download` attribute cannot be seen from page script. Path segments become parameters by shape: digits, UUID, ULID, hex, opaque and prefixed IDs (`cus_NffrFeUfNVxHib`) are named after the resource (`{customerId}`); dates and date-times become `{date}`, dotted versions `{version}`, and `@name` segments a handle parameter. A segment right after a search, user, people, profile or tag-style word becomes a text parameter (`/search/{query}`, `/people/{personId}`), as does a capitalised multi-word name (`Jane-Doe`) anywhere, unless it is a common route word such as `me`, `new` or `settings`; ordinary words elsewhere in a path still leave as written. `@me`, camelCase names (`darkModeToggleV2`) and words stay literal. A known file extension stays literal text after a word or an identifier (`openapi.json`, `/items/{itemId}.json`); other dotted segments (`john.doe`) are not templated and the request is not observed.

**Polling.** A repeated read becomes a polled step when a `status`, `state` or `phase` field reaches a completion word (complete, done, ready, succeeded, finished and similar), a `ready`/`done` flag turns true, or a `percent`/`progress` field reaches 100. Learned polls back off exponentially (500 ms doubling to 10 s, up to 30 attempts) within the run's deadline (five minutes by default), honour `Retry-After` only on 429 or 503 answers (a successful pending answer keeps the learned schedule), and stop with an error rather than wait past the deadline.

**Workflows.** A chain proposal that is a strict prefix of a longer unreviewed proposal folds into the longest one. A value an agent would pick from a list response (`/reports/0/id`) is never bound to a fixed index: it stays a workflow input and the list read remains its own tool, and the server refuses a discovered workflow with such a binding. A workflow you have enabled is marked reviewed: discovery never folds it into a longer proposal, even after drift disables it, and neither does it fold a workflow that has an update draft. Applying an update draft never re-enables an action you disabled yourself; enable it again in Control › Tool access › Reviewed first. Discovered workflows get a readable name and description from their method and path templates, such as `export_report` "Export a report: start export → poll job → download"; you can edit them, and model-assisted wording stays optional.

**Which response feeds which request.** The page compares a request's values with earlier responses in memory (or, with discovery continuation, as per-journal equality tokens) and uploads only the pointers. A response value its own request already sent is an echo, never a source: an identifier a status read repeats is followed back to the step that created it, and a search term echoed in a search response never links an unrelated write. When several earlier responses originate the value, the one with the fewest dependencies of its own wins (ties go to the most recent). Several equal fields in that response are resolved only when they mirror one name, such as `/id` and `/job/id`, and the request field name picks exactly one (`jobId` takes `/job/id`); otherwise the value stays an input. These rules are adapted from Integuru (with its owner's written permission).

**Input names.** Workflows saved since this release name their inputs from field names and path templates: snake_case (`workspace_id`, `notify_email`), common abbreviations expanded (`fname` becomes `first_name`), and generic words qualified by their parent field or the step's resource (`name` on an export becomes `export_name`). Field names add a `format` for email, URL, date and date-time strings, and every input gets a description naming its step, where it is sent, its path format and the page-declared choices. Types stay as observed, nothing reads values, and no model is involved. Your input labels always win. Workflows saved before keep their original input names across saves and applied updates, so a reviewed interface never changes without review. The naming rules are adapted from browser-use workflow-use (with its owner's written permission).

**Category choices (opt-in).** Discovery is shapes-only by default. With `captureChoices: true` in `init` (schema capture only), a top-level string field also records the choices the page itself declares for a control with the same name (`<select>` options or radio values) as a JSON-schema `enum`. Controls are read only from the form whose submission sent (or immediately preceded) the request, or from a control you bind explicitly with `data-telltale-field="fieldName"`; same-named controls elsewhere on the page are ignored. Only 2–20 identifier-like labels are kept; ID formats, secret-looking values and anything inside `data-telltale-ignore` are dropped, and typed or observed values are never used. A page offering fewer choices, or none, keeps the reviewed list. A new choice is drift, and so is a list that grows past 20 labels, which drops the enum to free text.

**Control and field labels.** With schema capture and `captureLabels` on, a discovered request that a trusted click, tap or Enter caused is linked to that control's role (button, link, menu item, option or tab). The control is linked only to requests it caused: those started in the click's own task, and those started while a request it caused is in flight or within 250 ms after one settles. A background poll that happens to start soon after a click is not linked. Requests the page made before the SDK started (read from the browser's Resource Timing list) and requests first seen with no click are treated as background and never linked on that page, also after `reset()`.

- **Owner-marked text is always used.** Text in `data-telltale-label` on the control or an ancestor (an empty attribute allows the computed name), and `toolparamdescription` on a field, leaves the page in clear.
- **Other labels need the Site's label reveal setting** (Settings › Data & privacy › Learn button names from use, off by default). The Telltale server's session-start answer says whether it is on (`labelReveal`). While it is off, the SDK computes no token and sends only the role and owner-marked text.
- **While it is on**, other names are sent as a token: HMAC-SHA256 of the reduced text, keyed from your publishable key. The publishable key is public, so a token is **pseudonymous, not secret**: anyone with the key, the Telltale server included, can test guesses, and a short label or a person's name is easy to guess. The Telltale server asks for the text only after it has seen the same token from enough distinct **sessions**. Sessions are not people: one person who signs out and in, or calls `reset()`, counts again. The page then sends the text it still holds in memory once. If no later request carries it within about 1.5 seconds, the SDK sends it on its own (re-sending the shape of the request it belongs to, with no values), or with keepalive when the tab is hidden or closed. Nothing is sent after withdrawal.
- **Never sent:** text read from inputs, textareas, selects or editable regions; anything inside `data-telltale-ignore`; computed text that equals or contains the current value of an input, textarea, select or editable region on the page (a button that echoes what someone typed); and any label on a sensitive page.
- **Cost:** a click records only the control. The name (at most 200 nodes and 500 characters) and the token are computed only when a request follows.

**Write confirmation.** Tools with any write step ask in the page, not with `window.confirm`: a dialog (`#observatory-confirm`) whose Allow button accepts only trusted clicks (`isTrusted`), so page scripts cannot approve. Cancel, Escape, withdrawal and WebMCP `toolcancel` dismiss it. Browser automation that sends trusted input can still click Allow, as it could accept a native dialog; the `consequentialHint` annotation stays set for clients to ask their own user.

**Published manifest.** `GET /api/sdk/tools` (publishable key) returns only what a client registers and the runner executes: name, title, description, input schema, annotations, version and fingerprint, and the reviewed request contract (method, path template, parameter formats, body and response shapes, encoding, operation, custom header names with their chosen source kind). It never includes session evidence, verdicts, counts, timestamps, review or failure state. The SDK re-registers tools only when a tool's approval (identity, version, fingerprint, description) or registered schema changes.

**Native registration.** Tools register with a `title` on one context: the one whose `registerTool` is native browser code, so a page-script polyfill on `document.modelContext` cannot capture tools that a native `navigator.modelContext` (older Chromium, such as agent-browser 0.39.2's bundled Chromium 149) would show to browser agents. When neither is native, `document.modelContext` is used. Each refresh compares tools by registered name and registration fingerprint and unregisters or registers only the tools that changed, so unchanged tools stay callable throughout. A failed registration is retried for that name alone, with backoff (about 9 s doubling to 5 minutes); a name clash (`InvalidStateError`, duplicate name) or a permission refusal (`NotAllowedError`) is not retried until the tool list or the context changes. Cancellation follows the execution `AbortSignal`; a `toolcancel` event aborts that tool's runs only when the browser dispatched it (`isTrusted`), because page scripts can construct one. If `registerTool` returns an object with `unregister()` (an early explainer shape), the SDK calls it when the tool is removed, as well as aborting the registration signal. The SDK does not install a polyfill: a page-script polyfill is invisible to native clients, and the manual runner (`invokeTool`) already covers in-page callers. The SDK's tests use the W3C webmcp-polyfill as a test double only.

**Agent failures.** A native WebMCP call that fails resolves with a result, not a rejection, because the browser turns a rejected callback into an opaque error: `{content: [{type: 'text', text}], isError: true, _meta: {'observatory/error': {code, httpStatus?, step?}}}`. The text is fixed for each code and never contains a URL, an identifier, a response body or an input value; HTTP failures add only the status and step. Codes: `http`, `invalid_input`, `disabled`, `approval_changed`, `expired`, `busy`, `consent`, `cancelled`, `timeout`, `policy_refused`, `rate_limited`, `missing_header`, `polling`, `replayed`, `result_limit`, `file_missing`, `failed`. Only the caller's own abort still rejects (with its AbortError). `invokeTool()` keeps rejecting with an Error for in-page code. The runner reports the HTTP status and step of a failed call (never its content). After three consecutive 4xx answers, excluding 401, 403 and 429, reported from at least two different sessions against the current tool version, the tool is marked suspect, a `tool.suspect` webhook is sent and a review appears under Control › Tool access › Changes. Nothing is disabled or republished automatically: you keep the tool enabled or disable it. A success or a new tool version resets the count, and failures reported for an older version are ignored.

## File results

The WebMCP draft serializes a tool result to a JSON string and has no file type. Native clients cannot fetch the page-local `blob:` download links the runner creates. Reviewed tools that return files therefore include each file in the result itself:

- **Text-like files** (`text/*` including CSV, `application/json`, `+json`) that decode cleanly are returned as a `text` content item containing the file text.
- **Binary files** and undecodable text are returned as an embedded `resource` item with base64 `blob` content.
- Each item carries `_meta["observatory/file"]` with `name`, `mimeType`, `size` (bytes) and `sha256` before the content, so a consumer can verify bytes and detect a cut made by its own client.
- **Over the bound**, the file is omitted and a `Truncation notice` item names the file, its size, hash and file `id`. No partial content is ever included in that result. Native clients read the complete file with the read-only **`observatory_read_file`** companion tool the SDK registers beside your reviewed tools: `observatory_read_file({id, offset, length})` returns up to 20,000 bytes per call (text when the slice is text-like and decodes cleanly, otherwise base64), with `_meta["observatory/file"]` giving `offset`, `length`, `sliceSha256`, the whole file's `sha256`, `done` and `nextOffset`. Reassemble the slices and compare the SHA-256. Text keeps any byte-order mark, so it re-encodes to exactly the described bytes. The bytes come from the in-page store only: nothing is sent to Telltale, and the file expires after five minutes (checked on every read, not only by a timer) or on pause, reset or withdrawal. If a reviewed tool is itself named `observatory_read_file`, the companion is `observatory_read_tool_file`. The notice names the reader only once its registration has succeeded; otherwise (for example when another script on the page already registered that name) it carries `reason: "no-reader-tool"`, names no tool, and the page-local link is the only copy. The page-local `resource_link` remains for people.
- JSON step results longer than 8,000 characters become `tool-result.json`, delivered by the same rules, so the leading summary stays short.

The bound is **32,000 characters for the whole serialized result**, including the summary, metadata and links. Reasons: agent-browser caps WebMCP output at 2 MiB and chrome-devtools-mcp has no cap of its own, but both pass the result to a model whose tool-output budget is far smaller; 32,000 characters stays inside the 25,000-token default MCP output limit of Claude Code (the A11 client behind chrome-devtools-mcp) even for base64. Google's example WebMCP extension cuts results at 8,000 characters; for that client only small files arrive whole, and the leading size and hash show the cut. Base64 grows binary data by a third, so binary files of about 23 KB are the practical maximum.

Results are announced with `untrustedContentHint`. File contents come from your application's API and may contain text that looks like instructions; clients may fence, spotlight or re-encode the whole result, and agents should treat it as data. Inline delivery does not change approval, confirmation or consent: files are only produced by reviewed tools, nothing is sent to Telltale, and withdrawal before delivery discards the result. Already returned content cannot be recalled from the agent.

Allow the exact collector in CSP script-src/connect-src; the CSP guide covers nonce/strict-dynamic and host allowlist policies. Production HTTPS applications need a reachable HTTPS collector; localhost is development-only. Validate actual network, storage and consent behavior in your target browser. Named recipes cover OneTrust, Cookiebot and Sourcepoint, and placement guides cover Next.js and Nuxt; the bundled adapters provide additional examples in the repository.


## Optional model-assisted drafts

A workspace administrator can opt a Site into model refinement after the server has TELLTALE_OPENAI_API_KEY and TELLTALE_REFINEMENT_MODEL configured. The AI-written descriptions setting at the top of Tool access controls the opt-in. Saved workflows can request descriptions and input-name suggestions. Structural HTTP paths, field names/types and binding pointers are sent to the configured OpenAI model; request values, polling equality values and session identities are excluded. Each suggestion is an unsaved draft requiring review, save and approval. Input suggestions support 1–40 exposed inputs and cannot introduce parameters, change types or add accepted-value constraints. All modes share a 20-attempt daily Site limit. Live model quality and exhaustive browser compatibility are not certified.

## Approved page templates

Conservative page redaction can group distinct pages under `/{id}`. For clearer analytics, an owner/admin can register up to 100 static shapes with `PUT /api/admin/page-templates` in the local dashboard API, scoped by `siteId`. Send `pageTemplates: [{id: "checkins", version: 1, template: "/check-ins/{recordId}"}]`. `GET /api/admin/page-templates?site=…` reads the configuration. Increase the version when changing a shape. Use application route names only: never customer names, account IDs, query strings or typed values.

Pass the same `pageTemplates` array to `Telltale.init`. The SDK matches the current pathname locally and sends only the ID/version alongside the existing conservatively redacted path. The collector resolves the reference against that site's approved configuration. Unknown or outdated references use ordinary redaction. Stored navigation keeps the approved shape, configuration source and version; no parameter values are stored. These are page-reported navigation observations, not authenticated business outcomes or authorization evidence. Older redacted paths cannot be reconstructed. Request discovery continues using its existing independent privacy filtering.

Hosted tags can pass the same array as JSON in `data-telltale-page-templates`; the pre-loader `TelltaleConfig` object accepts `pageTemplates` too. Both use identical validation and still require collector-side approval. Malformed configuration does not start collection.

### Correlate a reviewed tool request with completed work

The optional third `beforeRequest` argument is a read-only object containing `executionId` (the collector’s stored execution ID), `toolName`, and one-based `step` and `attempt`. The execution ID stays the same across steps and polling attempts in one execution. Existing two-argument hooks continue to work. No session token or input values are included, and the runner adds no correlation header automatically.

Your application may explicitly attach the execution ID to its own request headers and persist it alongside the authorized business operation. Treat this browser-supplied value only as a correlation claim: it is not authorization, proof of completion, or proof of an agent. Do not trust a caller-supplied action name as the operation your backend performed. After your server confirms that operation completed, it can include the correlated ID in `recordUsage`; the collector checks site, session, trusted account and tool name. See [Usage metering](/docs/usage-metering). A workflow step or polling response alone is not completion of the whole workflow. Use a stable application operation ID for receipt retries so polling cannot create duplicate charges.

### Analytics-only observations

`onVerdict` also notifies when the evidence scope changes, even if the categorical verdict stays AI agent. The accompanying evidence exposes `analyticsOnly` and `actionVerdict`; identical verdict and scope do not repeatedly notify. Analytics-only means automation is shown in analytics but that evidence does not select the AI agent rule or make agent use chargeable. The optional client gate interrupts only when the stricter action verdict is AI agent. It is a user-interface aid, never backend authorization.
