ChloeGet Started

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.

  1. 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 } },
});
  1. 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
  1. 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.