LotsAgentEarly access
Developer docs

Put your agent inside your app

Add it to your web app or site with one line. Your users stay signed in, and their roles carry over.

1. Quick start

Two lines in your page and one small endpoint on your server.
<script src="https://embed.lotsagent.com/v1/a/AGENT_ID/widget.js" async></script>
<lots-agent token-url="/lotsagent-token"></lots-agent>
  1. 1Open your agent in LotsAgent, then Embed. Copy the agent id and put it in place of AGENT_ID. Use the id, not the handle. The id never changes.
  2. 2Under Allowed sites, add every site that will show the agent, like https://app.example.com. Exact https://host (with a port if you use one), or https://*.example.com for any one-level subdomain. Up to 20. http://localhost works only while Test mode is on.
  3. 3Under Sign-in, save your issuer (your app's address) and make a key, or save a JWKS URL. See section 4.
  4. 4Add the token endpoint to your server, at the path you put in token-url. Examples in Node, Python and PHP are in section 4.
  5. 5Turn on Show the agent on your site.

The token endpoint

  • token-url is a path on your own site. It must be the same origin as the page. A URL on another origin is ignored, and the person is treated as not signed in.
  • The sidebar calls it with a plain GET, with the person's cookies, and Accept: text/plain.
  • Answer 200 with the token as the whole body, as plain text, and Cache-Control: no-store.
  • Answer 401 (or any status that is not 2xx) when nobody is signed in. The sidebar then says "You are signed out of the app. Sign in again to keep chatting."
  • It is called when the sidebar starts, then every 4 minutes while it is on the page. Sign a new token each time: each token's jti works once.
  • Do not add CORS headers to it. Only your own page calls it.
A page with only the script line works too. The loader adds <lots-agent> by itself and uses the Token URL saved on the Embed page. Leave out token-url for an agent where anyone can chat (section 4).

2. Ways to open the agent

A floating button is one way, not the only one. Every way goes through the same loader, so sign-in, allowed sites, limits and the off switch work the same.

Floating button (the default)

Nothing extra to do. Side, colour, title and button text are set on the Embed page. Use side to change the side for one page. On screens narrower than 640 px the sidebar opens as a full-screen sheet.

<!-- The default: a round button in the corner opens a sidebar -->
<lots-agent token-url="/lotsagent-token" side="left"></lots-agent>

No floating button

Use launcher="none", or pick "No floating button" on the Embed page. The agent then opens only from your own triggers below.

<!-- No floating button. Only your own triggers open it. -->
<lots-agent token-url="/lotsagent-token" launcher="none"></lots-agent>

Your own button, link or menu item

Add an attribute to any element: in your navbar, sidebar, header or help menu. It also works for elements you add later, because one listener on the page handles every click.

<!-- Any element with data-lotsagent-open opens the agent -->
<a href="#" data-lotsagent-open>Ask AI</a>

<!-- Open with a question typed in for them (not sent) -->
<button type="button" data-lotsagent-open data-lotsagent-prompt="How do I export my data?">Help with export</button>

<!-- Open and send the question at once -->
<button type="button" data-lotsagent-open data-lotsagent-prompt="Show my last invoice" data-lotsagent-send>Last invoice</button>

<!-- Open if closed, close if open -->
<button type="button" data-lotsagent-toggle>AI</button>
  • data-lotsagent-open opens it. data-lotsagent-toggle opens it, or closes it if open.
  • data-lotsagent-prompt types a question into the box. Add data-lotsagent-send to send it at once.
  • The link does not navigate: the click's default action is stopped.
  • When the sidebar closes, focus goes back to the element that opened it.

From your own code

// LotsAgent exists once widget.js has run. Use ?. in case it has not.
window.LotsAgent?.open();                                         // open it
window.LotsAgent?.open({ prompt: "Summarise my week" });          // open with a question typed in
window.LotsAgent?.open({ prompt: "Show my orders", send: true }); // open and send it
window.LotsAgent?.close();
window.LotsAgent?.toggle();

Events fire on the <lots-agent> element and bubble up, so you can listen on document. For example, a dot on your own menu item:

// Events bubble up from <lots-agent>, so you can listen on document.
const help = document.querySelector("#help-menu-item"); // your own menu item

document.addEventListener("lotsagent:unread", (event) => {
  // A reply arrived while the sidebar was closed. Show a dot on your own menu item.
  help.dataset.unread = String(event.detail.count);
});
document.addEventListener("lotsagent:open", () => {
  delete help.dataset.unread;
});
document.addEventListener("lotsagent:disabled", () => {
  // The owner switched the agent off. Hide your own entry point.
  help.hidden = true;
});
EventWhenevent.detail
lotsagent:loadedThe element started and read its settings.{ agentId }
lotsagent:readyThe chat inside the sidebar has loaded. This happens on the first open, or at page load for inline, full screen and "Load the chat early".{ agentId }
lotsagent:openIt opened.{ agentId }
lotsagent:closeIt closed (sidebar only).{ agentId }
lotsagent:unreadA reply arrived while the sidebar was closed.{ agentId, count }
lotsagent:disabledThe owner switched the agent off while the page was open, and again on each open call after that.{ agentId }
If the agent was already off when the page loaded, widget.js is an empty script. Nothing appears, window.LotsAgent is not defined, and data-lotsagent-open elements do nothing. To hide your own menu item in that case, check window.LotsAgent after the page has loaded. close() does nothing in inline and full-screen modes.

Open on page load

<!-- Open by itself once per visit, 5 seconds after load, only on /help and /billing pages -->
<lots-agent
  token-url="/lotsagent-token"
  auto-open="once-per-session"
  auto-open-delay="5"
  auto-open-paths="/help,/billing"
></lots-agent>
auto-openOpens
offNever. The default.
alwaysOn every page load.
once-per-sessionOnce per visit (until the browser tab is closed).
once-per-personOnce per person, remembered in that browser.
  • auto-open-delay: seconds to wait after load, 0 to 600.
  • auto-open-paths: opens only when the page path starts with one of them. Empty means every page.
  • It never takes focus. If the person is typing in your page, they keep typing.
  • The open attribute opens it at once instead, and then auto-open is ignored.

Inline in the page

For a full help page or a dashboard panel. The chat fills the element, with no close button. Give the element a height. It is at least 420 px tall.

<!-- The chat fills this element. Give it a height in your own CSS. -->
<lots-agent token-url="/lotsagent-token" mode="inline" class="help-chat"></lots-agent>
/* In your own stylesheet file */
.help-chat {
  height: 600px;
}

Full screen

For a page of its own, like "Ask AI". The chat covers the window at once, with no close button.

<!-- A dedicated "Ask AI" page: the chat covers the whole window -->
<lots-agent token-url="/lotsagent-token" mode="fullscreen"></lots-agent>

Keyboard shortcut

Off by default. Up to three of mod, ctrl, alt, shift, meta, then one key: a letter, a digit, /, ., ,, ;, enter or space. For example mod+k or ctrl+/. The shortcut toggles the sidebar.

<!-- mod is Cmd on a Mac and Ctrl elsewhere. Write it in lower case. -->
<lots-agent token-url="/lotsagent-token" shortcut="mod+k"></lots-agent>

Esc closes the sidebar, unless the person is typing in a field on your page. Focus goes back to what opened it.

Apps that already hold a token

Set getToken on the element instead of token-url. It wins over token-url. Set it before the chat loads: as soon as your page starts. If it throws, the person is treated as signed out.

// In your app's own code. The page has <lots-agent></lots-agent> with no token-url.
// Called each time the sidebar needs a token: when it starts, and every 4 minutes.
// Return a new token every time. Return null for a visitor who is not signed in.
document.querySelector("lots-agent").getToken = async () => {
  return await myAuth.getLotsAgentToken(); // your own code
};

Several agents on one page

Load each agent's script. Name the agent with agent on the element, data-lotsagent-agent on a trigger, or LotsAgent.open({ agent: "AGENT_ID_2" }) in code. Without a name, the first agent loaded on the page is used.

<script src="https://embed.lotsagent.com/v1/a/AGENT_ID_1/widget.js" async></script>
<script src="https://embed.lotsagent.com/v1/a/AGENT_ID_2/widget.js" async></script>

<lots-agent agent="AGENT_ID_1" token-url="/lotsagent-token"></lots-agent>
<lots-agent agent="AGENT_ID_2" token-url="/lotsagent-token" launcher="none"></lots-agent>

<button type="button" data-lotsagent-open data-lotsagent-agent="AGENT_ID_2">Billing help</button>

All element attributes

The Embed page sets these for every page. An attribute changes them for one element. Attributes are read once, when the element is added to the page.

AttributeValuesDefault
agentThe agent id.The first agent loaded on the page
token-urlA path on your own site, like /lotsagent-token. Same origin only.Token URL on the Embed page
sideright or leftright
launcherbutton or nonebutton
modesidebar, inline or fullscreensidebar
openNo value. Starts open, without taking focus.Closed
promptText typed into the box the first time it opens. Not sent.None
auto-openoff, always, once-per-session or once-per-personoff
auto-open-delaySeconds, 0 to 600.0
auto-open-pathsComma-separated paths, like /help,/billing.Every page
shortcutLike mod+k, in lower case.None
Trigger attributeWhat it does
data-lotsagent-openOpens the agent.
data-lotsagent-toggleOpens it, or closes it if open.
data-lotsagent-promptTypes this question into the box.
data-lotsagent-sendWith a prompt: sends it at once.
data-lotsagent-agentWhich agent, when the page has more than one.

Set only on the Embed page: colour (text colour is picked for contrast), title, button text (empty for an icon only), up to 4 starter questions, and "Load the chat early", which loads the chat when the browser is idle so the first click opens at once.

3. Content Security Policy

If your site sends a Content-Security-Policy, add these two sources. That is all.
script-src https://embed.lotsagent.com
frame-src https://embed.lotsagent.com

A full example header

Content-Security-Policy: default-src 'self'; script-src 'self' https://embed.lotsagent.com; frame-src https://embed.lotsagent.com; style-src 'self'; require-trusted-types-for 'script'
  • No connect-src entry. Your page never calls LotsAgent: the loader has its settings built in, and the token request goes to your own server.
  • No style-src entry. The loader styles itself with constructed stylesheets inside a closed shadow root. It creates no HTML from strings, so pages that require Trusted Types work.
  • Do not add CORS headers to your token URL. It is called by your own page, on your own origin.
  • The chat frame sends its own policy. Its frame-ancestors lists only your allowed sites, so no other site can frame your agent.
  • A style="" attribute on <lots-agent> is blocked by a strict style-src. Set its height in your own stylesheet (see inline mode).

Tested browsers

BrowserResult
Chrome (headless), 27 Oct 2026Page with only the two sources above, plus style-src 'self' and require-trusted-types-for 'script'. The button, sidebar and chat work. No violations.
SafariNot tested yet.
FirefoxNot tested yet.

A browser too old for constructed stylesheets falls back to a style element, which a strict style-src blocks.

4. The identity token

Your server signs a short JWT for the person who is signed in. About ten lines of code. They never log in again.

The person becomes a record inside this agent only, keyed by your issuer and their sub. They never get a LotsAgent account, and an email in the token is never matched to one.

ClaimRule
Header algHS256 with a key from the Embed page, or ES256, RS256 or EdDSA with your JWKS URL. Anything else is refused, none too. The algorithm must match the kind of key.
Header kidRequired. Picks the key: the key id from the Embed page (it starts with k_), or a kid in your JWKS.
issRequired. Exactly the issuer saved on the Embed page, like https://app.example.com.
audRequired. Exactly lotsagent:AGENT_ID. A token for one agent never works on another.
subRequired. Your stable user id, 1 to 255 characters. Use an id that never changes. Not an email, unless emails never change in your app.
email, nameOptional, recommended. Shown to the agent and in your activity view.
workspaceOptional. { "id": "...", "name": "..." }, the workspace the person is in. id is required when it is there.
roleOptional. Shown to the model only, so it does not offer what the person cannot do. Never used to allow anything. Your own tools decide (section 5).
localeOptional, like ta-IN. A hint for the reply language when the message does not show one.
iat, expRequired. At most 600 seconds apart. We recommend 300. Clock skew allowed: 60 seconds.
jtiRequired, up to 128 characters, unique per token. Each one is accepted once.
Whole tokenAt most 8 KB. Unknown claims are ignored.

An example payload

{
  "iss": "https://app.example.com",
  "aud": "lotsagent:AGENT_ID",
  "sub": "user_8412",
  "email": "priya@example.com",
  "name": "Priya",
  "workspace": { "id": "ws_31", "name": "Acme Exports" },
  "role": "viewer",
  "locale": "ta-IN",
  "iat": 1793080800,
  "exp": 1793081100,
  "jti": "5f0c6c1e-2b7a-4f7e-9d0a-3c1b8e6f4a21"
}

Your server

Each example answers GET /lotsagent-token for the signed-in person with a 5-minute token and Cache-Control: no-store. Put the key id and secret from the Embed page in LOTSAGENT_EMBED_KID and LOTSAGENT_EMBED_SECRET. Use the secret exactly as shown. Never put it in a page.

Node (jose)

import { SignJWT } from "jose";
import { randomUUID } from "node:crypto";

const key = new TextEncoder().encode(process.env.LOTSAGENT_EMBED_SECRET);

// Express. requireLogin is your own sign-in check; req.user is the signed-in person.
app.get("/lotsagent-token", requireLogin, async (req, res) => {
  const token = await new SignJWT({ email: req.user.email, name: req.user.name })
    .setProtectedHeader({ alg: "HS256", kid: process.env.LOTSAGENT_EMBED_KID })
    .setIssuer("https://app.example.com")
    .setAudience("lotsagent:AGENT_ID")
    .setSubject(String(req.user.id))
    .setJti(randomUUID())
    .setIssuedAt()
    .setExpirationTime("5m")
    .sign(key);
  res.set("Cache-Control", "no-store").type("text/plain").send(token);
});

Python (PyJWT)

import os, time, uuid
import jwt  # pip install PyJWT
from flask import Response
from flask_login import current_user, login_required

# Flask. app is your Flask app; current_user is the signed-in person.
@app.get("/lotsagent-token")
@login_required
def lotsagent_token():
    now = int(time.time())
    token = jwt.encode(
        {
            "iss": "https://app.example.com",
            "aud": "lotsagent:AGENT_ID",
            "sub": str(current_user.id),
            "email": current_user.email,
            "name": current_user.name,
            "iat": now,
            "exp": now + 300,
            "jti": str(uuid.uuid4()),
        },
        os.environ["LOTSAGENT_EMBED_SECRET"],
        algorithm="HS256",
        headers={"kid": os.environ["LOTSAGENT_EMBED_KID"]},
    )
    return Response(token, mimetype="text/plain", headers={"Cache-Control": "no-store"})

PHP (firebase/php-jwt)

<?php
// composer require firebase/php-jwt
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;

// Run this only for a signed-in person. $user is your own signed-in user.
$now = time();
$token = JWT::encode([
    'iss' => 'https://app.example.com',
    'aud' => 'lotsagent:AGENT_ID',
    'sub' => (string) $user->id,
    'email' => $user->email,
    'name' => $user->name,
    'iat' => $now,
    'exp' => $now + 300,
    'jti' => bin2hex(random_bytes(16)),
], getenv('LOTSAGENT_EMBED_SECRET'), 'HS256', getenv('LOTSAGENT_EMBED_KID'));

header('Content-Type: text/plain');
header('Cache-Control: no-store');
echo $token;

Key rotation

  1. 1Click New key on the Embed page. The secret is shown once, with its key id. Up to 2 keys can be active.
  2. 2Put the new key id and secret on your server and deploy.
  3. 3Watch Last used on the old key. When it stops changing, click Revoke. A revoked key stops working at once.

Your own key pair (JWKS)

Save a JWKS URL on the Embed page instead of making a key. Sign with ES256, RS256 or EdDSA. Use this when your own OAuth server must check the same token (section 5).

Node (jose), ES256

import { SignJWT, importPKCS8 } from "jose";
import { randomUUID } from "node:crypto";

// Your private key (PEM, PKCS#8). The public half is in your JWKS under kid "2026-10".
// user is the signed-in person.
const key = await importPKCS8(process.env.LOTSAGENT_EMBED_PRIVATE_KEY, "ES256");

const token = await new SignJWT({ email: user.email })
  .setProtectedHeader({ alg: "ES256", kid: "2026-10" })
  .setIssuer("https://app.example.com")
  .setAudience("lotsagent:AGENT_ID")
  .setSubject(String(user.id))
  .setJti(randomUUID())
  .setIssuedAt()
  .setExpirationTime("5m")
  .sign(key);

Your JWKS

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "kid": "2026-10",
      "use": "sig",
      "alg": "ES256",
      "x": "...",
      "y": "..."
    }
  ]
}
  • https only, on a public address, answered directly (no redirects), within 5 seconds, at most 64 KB.
  • LotsAgent keeps it for 10 minutes. A token with an unknown kid makes it fetch again, at most once a minute.
  • To rotate: publish the new key, wait a few minutes, then sign with it. Keep the old key until its last tokens have expired.
  • Keys with a use other than sig, and symmetric (oct) keys, are ignored.

Visitors who are not signed in

Turn on Anyone can chat on the Embed page and leave out token-url. Each visitor gets a random id inside the chat frame. Use it only for an agent with no tools that need a person's sign-in, like a help agent that answers from your docs. It cannot know who anyone is. The Embed page refuses it while the agent has tools that act for a signed-in person. When a token is sent, it is always used.

5. Sign in as the person in your toolsRolling out

The agent acts as the person using it. When it calls your app, your own permission checks run. A viewer can only view. An admin can do admin work.
Rolling out. LotsAgent's side is built, with the Sign in as the person card on the Embed page. Our own gateway's side is being built now. In an embedded chat the agent only uses tools from apps linked this way. Without one, it can search your docs, search and read the web, and remember things.

For an embedded chat, LotsAgent never uses your own connection, a stored connection, a site key or any shared key. It uses a token for that person, issued by your server, through OAuth token exchange (RFC 8693).

Setup

  1. 1Sign your identity tokens with your own key pair (JWKS, section 4). Your server must check the token with its own key. A shared secret cannot prove anything to a third party.
  2. 2Register one OAuth client per agent, bound to aud = lotsagent:AGENT_ID. It authenticates with private_key_jwt using LotsAgent's keys at https://lotsagent.com/.well-known/jwks.json.
  3. 3On the Embed page, under Sign in as the person, link each app whose tools the agent has: token endpoint (https), client id, resource (your MCP server's address) and scopes. The card shows LotsAgent's JWKS URL to register.

The request

LotsAgent sends this to your token endpoint when the agent calls one of your tools:

POST {your token endpoint}          for example https://api.example.com/api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the identity token your server signed, as received>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
resource=https://api.example.com/mcp
scope=mcp:read mcp:write mcp:send mcp:money mcp:delete
client_id=<the client id you registered for this agent>
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<a JWT signed by LotsAgent with ES256>

client_assertion is a JWT signed by LotsAgent with ES256: iss and sub are the client id, aud is your token endpoint, a unique jti, and exp 60 seconds away. Answer within 10 seconds.

What your server checks

  • The client assertion, against LotsAgent's JWKS.
  • The subject token, with your own key: iss is you, aud is the agent bound to this client_id, not expired, jti used once.
  • The person is still active and, if workspace is present, still a member.

The answer

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "mcp:read mcp:write"
}
  • At most 15 minutes (expires_in 900). No refresh token: LotsAgent exchanges the person's latest identity token when it runs out.
  • Grant only what the person's role allows. LotsAgent asks for all five scopes. A viewer gets mcp:read.
  • Put act in the access token, so your audit log shows the call came through LotsAgent:
{
  "sub": "user_8412",
  "scope": "mcp:read mcp:write",
  "act": { "sub": "<client id>" }
}
ScopeLets the agent
mcp:readRead.
mcp:writeCreate and change.
mcp:sendSend to other people, like an email or a message.
mcp:moneyMove money.
mcp:deleteDelete.

LotsAgent keeps the access token, encrypted, until 30 seconds before it expires. Calls to your MCP server carry only Authorization: Bearer with that token. No other header names anyone. If your MCP server answers 401, LotsAgent exchanges once more and tries again once.

Errors

HTTP/1.1 400 Bad Request
Content-Type: application/json

{ "error": "invalid_grant", "error_description": "The person no longer has access." }
Your token endpoint answersThe tool fails with
invalid_grant: the subject token is bad or expired, or the person lost accessidentity_rejected
HTTP 429rate_limited
HTTP 500 or higherupstream_error
No answer within 10 secondstimeout
Anything else, like invalid_client, unauthorized_client, invalid_target or invalid_scopetool_auth_misconfigured: your setup needs fixing

The sidebar sends a fresh identity token every 4 minutes. If the person's latest one has run out (the sidebar was closed for a while), the tool fails with identity_expired and the person sees "Reconnecting to the app. Try again in a moment."

6. ConfirmationsRolling out

Anything that sends, deletes or moves money waits for the person to click Confirm.
Built on LotsAgent's side. It applies to your tools in embedded chats, which arrive with token exchange (section 5). Docs, web and memory tools never ask.

Which tools ask

LotsAgent readsRule
tools.json at your MCP server's originEach tool's permission_max, or permission when there is no max. send, money or delete asks. read and write do not. The highest the tool can need wins. Our own gateway publishes this file.
MCP tool annotationsdestructiveHint: true asks as delete. readOnlyHint: true never asks. openWorldHint: true asks as send.
NeitherAsks. Safe by default.

tools.json (optional)

{
  "tools": [
    { "name": "list_invoices", "permission": "read" },
    { "name": "update_invoice", "permission": "write", "permission_max": "delete" },
    { "name": "send_invoice", "permission": "send" }
  ]
}

When a tool has both, the one that asks more wins: a write tool marked destructiveHint: true asks as delete.

Both are read when the integration's tools are synced. Tools synced before 27 Oct 2026 ask until you refresh them. If you run an MCP server, mark read-only tools with readOnlyHint: true.

One call at a time (optional)

A tool that can delete may be harmless for a given call. Before showing a card, LotsAgent asks POST /api/tools/classify on your token endpoint's origin, with the person's access token. If it answers within 3 seconds, that class is used for this call, but never below what the tool's annotations ask: a destructive tool always asks. Any other answer keeps the stored class.

POST /api/tools/classify
Authorization: Bearer <the person's access token>
Content-Type: application/json

{ "tool": "update_invoice", "arguments": { "id": "inv_204", "note": "Paid" } }

200 OK
{ "class": "write" }

Changing it

The owner can set each tool to Default, Always ask or Never ask. Never ask is allowed for send tools and tools that do not ask. It is never allowed for money or delete. Always ask also wins over the call-time answer. This setting is not on the Embed page yet.

What the person sees

  • The agent says in one sentence what it is about to do, and waits.
  • A card titled "Confirm: " and the tool's name, a line on what it does (sends something, moves money, deletes something), the exact details that will run, and Confirm and Cancel.
  • Focus goes to the card's heading, never straight to Confirm.
  • The card shows every detail in full: up to 20 fields, field names up to 64 characters, values up to 1,000. A call with more than that gets no card and does not run; the agent asks the person to do it in your app.
  • Confirm runs exactly those stored details, once. Nothing the model writes can change them, and they are checked against the card before they run. The result goes back into the chat, marked as your app's data, and it does not count against the person's daily messages.
  • Cancel tells the agent the person said no.
  • A card nobody answers expires after 15 minutes. The model can never click for the person.

Text inside an email or a document is read as data, never as an order.

7. Limits, kill switch, notice, retention

You stay in charge. You pay for embedded chats from your credits. Your users never see a price.
Setting on the Embed pageDefaultRange
Messages per person per day501 to 1000
Spend cap per day (USD)$20$0.01 to $10,000
Email me at (percent of the cap)80%1 to 100
Time zoneUTCAny time zone name, like Asia/Kolkata
Keep chats for (days)301 to 365
NoticeNoneUp to 1,000 characters

Messages per person per day

Only the person's own messages count. Over the limit, the agent replies "You have used today's 50 messages. They reset at midnight." (with your number). That reply calls no model and costs nothing. Limits reset at midnight in the time zone you pick.

Visitors who are not signed in

With Anyone can chat on, a visitor's id is chosen by their browser, so it cannot stand for one person. On top of the per-person limit, each address that opens chats gets twice that number of messages a day, and all anonymous visitors together get 500 messages a day per agent. Signed-in people are never counted against these. Over them, the agent replies "This agent has answered as many visitors as it can today. Sign in, or try again tomorrow."

Spend cap

The cap counts what you are charged. At 80% (or your percent) you get one email that day from notifications@lotsagent.com. At the cap the agent replies "This agent is resting until tomorrow." until the day turns. Replies already running can finish, so a day can end slightly over.

Kill switch

Turn off Show the agent on your site. New chats are refused at once, and the next message in an open chat within about 10 seconds. Within 60 seconds, pages that load show no button, and an open sidebar says "This agent is resting. Try again later." and removes the button. Your page gets lotsagent:disabled. On a page that was already loaded and never opened the sidebar, the button goes the next time someone clicks it.

Notice

Shown before the first message. The person clicks OK once. Changing the text raises its version, and everyone sees it again. It is shown as plain text, with your line breaks.

Retention

Chats are kept 30 days by default, then deleted. Every night, chats with no activity for longer than your setting are deleted: the conversation, its tool logs, confirmations and stored tokens. When a person has no chat left, their memory and their name, email and workspace go too. Billing records and events are kept. They hold no message text. Events are kept 13 months.

Model

Embedded chats run on Gemini, through Google Vertex AI. There is no fallback to another provider. If Vertex cannot serve, the chat does not start, and the person sees "This agent is not available right now." Chats are not used to train AI.

8. Events and error codes

What LotsAgent records for you, and the codes you may meet. Events never hold message text.

Events

EventWritten when
conversation_startedA new conversation starts.
messageThe person sends a message that is let through.
limit_reachedA message is refused by the daily message limit.
spend_cappedA message is refused by the spend cap.
tool_call, tool_errorThe agent calls a tool. tool_error carries an error code (below).
confirm_shown, confirm_accepted, confirm_cancelledA confirmation card is shown, confirmed or cancelled (section 6).
confirm_expiredNightly: a card nobody answered in 15 minutes.
feedback_up, feedback_downThumbs on a reply.
gave_upNightly: a conversation idle for 30 minutes where tools were tried and none worked, or the agent has tools and the person sent 3 or more messages with no tool working.

The Activity tab on the Embed page shows these per day, with spend against your cap, and the 50 latest conversations as event timelines. It never shows message text.

Tool error codes

CodeMeaningThe person sees
identity_requiredThe chat has no identity token: an anonymous visitor.Sign in to the app to let the agent work in it.
identity_expiredThe person's latest identity token ran out. The sidebar sends a new one within 4 minutes.Reconnecting to the app. Try again in a moment.
identity_rejectedYour token endpoint answered invalid_grant, or your MCP server answered 401 twice.The app did not accept your sign-in.
permission_deniedYour MCP server answered 403 for this person.Your role in the app does not allow this.
not_foundYour MCP server answered 404.The app could not find that.
invalid_inputYour tool returned an error result.Your tool's own error text.
rate_limitedYour server answered 429.The app is busy. Try again in a minute.
timeoutYour server did not answer in time.The app's tool did not answer in time.
upstream_errorYour server failed.The app's tool failed.
tool_auth_misconfiguredYour token endpoint refused LotsAgent's client, resource or scopes (section 5).Token exchange refused (and the error).
tool_not_readyThe integration is not linked to token exchange for this agent, or the tool is gone.This tool is not available in the sidebar.

Sidebar codes

When the sidebar cannot start, the chat frame's request to embed.lotsagent.com/api/v1/embed/sessions answers with a code, and for sign-in problems a detail. To see them, open your browser's developer tools, then Network, and find the sessions request.

codeHTTPMeaning
not_found404No agent with this id, or its Embed page was never saved.
disabled403The agent is switched off.
origin_not_allowed403This site is not in Allowed sites.
identity_required401 or 400No token was sent, and Anyone can chat is off.
identity_invalid401The token failed a check. See detail below.
identity_replayed401This token's jti was already used.
identity_mismatch401A fresh token names a different person than the open chat.
session_not_found404The chat being resumed no longer exists.
rate_limited429Too many chats are starting right now. Try again in a few minutes.
unavailable503The chat could not start. Often the owner is out of credits.
detail (with identity_invalid)Fix
no_issuerSave an issuer on the Embed page.
malformedThe body of your token URL must be the token alone: a compact JWT, nothing else.
too_largeKeep the token under 8 KB. Drop large claims.
bad_algUse HS256 with an Embed page key, or ES256, RS256 or EdDSA with a saved JWKS URL.
unknown_kidSet kid to an active key id from the Embed page, or a kid in your JWKS.
bad_signatureSign with the secret that belongs to that kid, exactly as shown.
wrong_issuerMake iss exactly equal to the saved issuer. Watch for a trailing slash.
wrong_audienceSet aud to lotsagent:AGENT_ID, with the agent id, not the handle.
expiredSign a new token for each request. Check your server's clock.
not_yet_validYour server's clock is ahead. Fix the clock.
too_long_livedKeep exp at most 600 seconds after iat.
bad_claimsA claim is missing or too long: sub, jti, iat, exp, or workspace.id.
jwks_unavailableLotsAgent could not read your JWKS URL. Check it answers over https.

Message refusals

codeThe agent replies
limit_reachedYou have used today's 50 messages. They reset at midnight.
spend_cappedThis agent is resting until tomorrow.
disabledThis agent is resting. Try again later.
anonymous_limitThis agent has answered as many visitors as it can today. Sign in, or try again tomorrow.
unavailableSomething went wrong. Try again in a minute.

9. Troubleshooting

Every message the sidebar can show, why it shows, and what to do.
The sidebar showsWhyFix
Connecting...The chat is waiting for your token URL or for LotsAgent.If it stays, check your token URL answers quickly.
Open this agent from the app it belongs to.The chat address was opened on its own, not inside your page.Use the script tag. The chat only runs inside your page.
This agent is not set up for this site.Your page's origin is not in Allowed sites.Add the exact origin. http://localhost needs Test mode.
This agent is resting. Try again later.The agent is switched off.Turn on Show the agent on your site.
You are signed out of the app. Sign in again to keep chatting.Your token URL answered with a status that is not 2xx, or getToken threw.Expected when signed out. Otherwise check the token URL answers 200.
Sign in to the app to use this agent.No token was sent, and Anyone can chat is off.Set token-url (same origin), or the Token URL on the Embed page. Make sure it returns a token.
Your sign-in could not be checked. Reload the page.The token failed a check, or its jti was used before.Read detail in the sessions response (section 8). Sign a new token for every request.
This agent is not available here.Wrong agent id, or the Embed page was never saved.Copy the id from the Embed page.
This agent is not available right now.The chat could not start: the owner is out of credits, the model cannot serve, too many starts in 10 minutes, or a network error.Check your credits. Then wait a minute and click Try again.
You are signed in as someone else now. Reload the page.A fresh token names a different person than the open chat.Reload the page. A new chat starts for the new person.
That chat has ended.The chat being resumed was deleted.Click Try again. A new chat starts.
The notice changed. Reload to read it.The owner changed the notice while it was open.Reload the page.
Bad requestThe chat frame's request was changed or cut.Reload the page.

Inside the chat

The chat showsWhyFix
You have used today's 50 messages. They reset at midnight.The daily message limit (your number).Wait for midnight in your time zone, or raise the limit.
This agent is resting until tomorrow.The daily spend cap was reached.Raise the cap, or wait for the day to turn.
This agent is resting. Try again later.The agent was switched off.Turn it back on.
Something went wrong. Try again in a minute.LotsAgent could not check the limits, so the message was refused.Send it again in a minute.
The model could not complete this turn.The reply failed partway.Send the message again.
This reply didn't finish loading. Reload the conversation.The reply could not be shown.Reload the page.
A red Test badgeTest mode is on.Turn off Test mode before you go live.

When one of your tools fails, the agent tells the person in its own words, based on the text in the tool error table (section 8).

On a confirmation card

The card showsWhyFix
Done.Confirmed, and it ran.Nothing to fix.
Cancelled.The person clicked Cancel.Nothing to fix.
Working...Confirmed, and it is running.Wait.
It did not work.Confirmed, but your tool failed. The reason shows under it.See the tool error table in section 8.
This request expired. Ask again.Nobody answered in 15 minutes.Ask the agent again. A new card appears.
This request has expired. Ask again.Confirm or Cancel was clicked after 15 minutes.Ask the agent again.
This request was already answered.It was confirmed or cancelled already, for example in another tab.Nothing to fix. Nothing ran twice.
Already answered.Same as above.Nothing to fix.
This request is not available.The card belongs to another chat, or was deleted.Ask the agent again.

Nothing appears

  • The agent is switched off. widget.js is then an empty script.
  • Your CSP blocks the script. Add the two sources from section 3. The browser console names the blocked source.
  • launcher="none" is set, and your page has no trigger.
  • The agent attribute does not match the agent id in the script URL.

The sidebar shows the browser's own "refused to connect" page

The chat frame only lets your allowed sites frame it. Add your page's origin to Allowed sites, check the agent is on, and wait up to 30 seconds.

All docsAPI referenceSections marked Rolling out describe work that is being built now.