Docs
A web page
A chat box on your own site, talking to an agent: the pass your server hands out, what a visitor gets, the limits, and every option.
A chat box on your own site, talking to the same agent, with the same model and run record. A visitor is a stranger, so they get only what you name.
- Add the channel to the agent. It names the sites that may show the box and the tools a visitor's turn gets:
// This agent in a chat box on the shop's own site, for the people who visit it.
// A visitor is a stranger, so their turn gets the one tool named here, the tool
// itself, and nothing else the agent has: no notes, no jobs, no picking the model.
//
// The shop's own server holds a token made for this agent and gives each
// signed-in customer a pass from /api/chat-pass, with their customer id as the
// visitor, which is how orderStatus knows whose orders to look at. The page loads the box with one tag, from
// agent.myshop.com, which the shop's proxy sends on to this runtime:
//
// <script src="https://agent.myshop.com/api/web/chat.js" data-agent="shop" data-pass="/api/chat-pass" async></script>
import { webChannel } from "@chloejs/core/channels";
import { orderStatus } from "../tools/orderStatus.ts";
export default webChannel({
origins: ["https://myshop.com"],
tools: [orderStatus],
greeting: "Hi, I can tell you where an order is. What is its number?",
limits: { perVisitor: { messages: 20, dollars: 0.25 }, perDay: { dollars: 3 } },
});- Make a token for that agent alone. It lives on your site's server, never in a page, and is printed once:
npx chloe tokens make "shop site" --agent shop- Give your site one route that hands its page a pass, and put the box on the page. This is a whole site that does both, in plain Node. Who the visitor is (here, a signed-in customer's id) comes from your site's own sign-in, never from anything the visitor can write, because it decides what the agent's tools show them:
// The shop's own web server: one page with the chat box on it, and the one route
// that hands that page a pass. The token lives here and never reaches a page.
//
// npx chloe tokens make "shop site" --agent shop prints the token, once
// CHLOE_TOKEN=chloe_... node server.ts
//
// Two addresses, because two different things reach the runtime. The page loads
// the box from BOX, which is public (the shop's proxy passes the web routes to
// the runtime and nothing else), and this server asks for passes at CHLOE,
// which is the runtime itself. On one machine, both are http://127.0.0.1:3067,
// and the channel's `origins` has to include http://localhost:8080.
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage } from "node:http";
const BOX = "https://agent.myshop.com";
const CHLOE = "http://127.0.0.1:3067";
const AGENT = "shop";
const PAGE = `<!doctype html>
<meta charset="utf-8">
<title>My shop</title>
<h1>My shop</h1>
<script src="${BOX}/api/web/chat.js" data-agent="${AGENT}" data-pass="/api/chat-pass" async></script>
`;
/**
* The customer id of whoever sent this, from the shop's own sign-in, or nothing
* for somebody not signed in. The shop's sessions go here. It is the only place
* a customer id may come from: the agent's order tool shows that customer's
* orders to whoever holds a pass made for them.
*/
function customerOf(request: IncomingMessage): string | undefined {
return undefined;
}
/** An id for somebody not signed in, which can never be a customer's. */
const ANONYMOUS = /^anon-[0-9a-f-]{36}$/;
createServer(async (request, response) => {
if (request.method === "POST" && request.url === "/api/chat-pass") {
// A signed-in customer is their customer id. Anybody else gets a random id
// in a cookie, so they find the same conversation tomorrow, and a cookie is
// believed only when it holds one of those: whoever writes a customer's id
// into it gets a new anonymous one instead, never that customer's orders.
const kept = /(?:^|; )visitor=([^;]+)/.exec(request.headers.cookie ?? "")?.[1] ?? "";
const visitor = customerOf(request) ?? (ANONYMOUS.test(kept) ? kept : `anon-${randomUUID()}`);
const asked = await fetch(`${CHLOE}/api/agents/${AGENT}/web/pass`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.CHLOE_TOKEN}`, "content-type": "application/json" },
// Whatever the agent should know about them: their name, their plan.
body: JSON.stringify({ visitor, facts: {} }),
});
// Handed on as it came: the pass, when it runs out, and the agent's greeting.
response.writeHead(asked.ok ? 200 : 502, {
"content-type": "application/json",
...(ANONYMOUS.test(visitor) && { "set-cookie": `visitor=${visitor}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000` }),
});
return void response.end(await asked.text());
}
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(PAGE);
}).listen(8080, () => console.log("http://localhost:8080")); In any framework it is the same route: POST /api/agents/<id>/web/pass
with the token and { "visitor": "...", "facts": {...} }. facts is
whatever the agent should know about them: their name, their plan.
To try it on one machine, set both addresses in it to
http://127.0.0.1:3067, add http://localhost:8080 to origins, run
npx chloe, and open http://localhost:8080.
| Option | Default | What it controls |
|---|---|---|
origins |
required | The sites that may show the box, like "https://myshop.com". |
tools |
none | The only tools a visitor's turn has. No memory, skills or self tools unless named, and naming the memory or self tools is refused. |
job |
none | A job every visitor's message goes to, instead of a turn. |
greeting |
none | What the box shows before anybody has written. |
limits |
30 messages and $0.50 per visitor, $5 for everybody, per 24 hours | { perVisitor: { messages, dollars }, perDay: { dollars } }. A turn past one is refused politely. |
model |
the agent's | The model visitors' turns use. |
pictures |
off | Whether a visitor may send pictures. |
chatHistory |
{ messages: 10 } |
As on every channel. |
Besides limits, a visitor may send six messages a minute, of up to 4,000
characters each. A visitor never gets a /command but /clear, a model pick
or a sign-in.
Reaching chloe. It stays on loopback. Your own web server gives it an address, here agent.myshop.com, and passes the box's routes and nothing else. With Caddy:
agent.myshop.com {
@web path_regexp ^/api/(agents/shop/web/(turn|history|clear)|web/(chat|client)\.js)$
handle @web {
reverse_proxy 127.0.0.1:3067 { flush_interval -1 }
}
respond 404
}The pass route is not in it: your site's server reaches chloe directly.
The box. It sits in a corner, shows a tool's title while the agent works,
and writes the answer as it arrives. It keeps the conversation for the next
visit. Set --chloe-accent, --chloe-font and --chloe-radius on the page to
match your site, and data-title, data-note, data-position="left" and
data-open on the tag. For a look of your own, build on the client served
beside it:
import { chloeChat } from "https://agent.myshop.com/api/web/client.js";
const chat = chloeChat({
url: "https://agent.myshop.com",
agent: "shop",
pass: () => fetch("/api/chat-pass", { method: "POST" }).then((answer) => answer.json()),
});
const { messages } = await chat.history();
const { text } = await chat.send("Where is my order?", {
onText: (soFar) => show(soFar),
onStep: ({ text }) => status(text),
});What the agent knows about a visitor. Each message reaches the model with
their id, the site, when they first came, how many messages before, their
country and browser, and your facts, never their IP address. With
features: { memoryPerUser: true } it keeps a note on each visitor.
GET /api/agents/<id>/web/visitors lists them.
What you see. Each visitor's conversation is in the agent's chat list,
marked web, and each turn is a run with its cost. A job asks a visitor with
who: "web:<visitor>", and the question waits for the next time the box loads.
A pass is one agent, one site, one visitor, for an hour. Passes are signed with
web-pass.key in the state folder: delete it and restart, and every pass stops
working.