Chloe

Docs

Channels

Putting an agent on Telegram, Slack, Discord, WhatsApp or email, in a chat box on your website, or opening it to another system over HTTP. What to set for each, and every option.


A channel is how people reach an agent, and how a job's question reaches a person. An agent is on one because its agent.ts names it in channels, keyed by its name like a tool and made by its function from @chloejs/core/channels, like channels: { telegram: telegramChannel({ allowFrom: [123456789] }), api: apiChannel() }. A channel you write yourself goes in the same way, so nothing about a channel chloe ships is built in. The dashboard's Channels page shows every channel chloe ships with a switch, the options that are plain values as a form, and a place for its tokens; it adds or removes the call and its import in agent.ts, and checks the agent still loads. An option that needs code, like tools or job, is written in the file and shown there read only. Each channel has a page of its own.

Channel Who may write What it needs Opens a port?
Telegram Telegram user ids in allowFrom a bot token no
Slack Slack member ids in allowFrom two app tokens no
Discord Discord user ids in allowFrom a bot token no
WhatsApp numbers in allowFrom, or anybody when unset a number registered with Meta a route your web server passes on
Email addresses in allowFrom, and only once the agent wrote first the Google sign-in, or a mailbox of your own no
A web page visitors to the sites in origins a token on your site's server a few routes your web server passes on
HTTP holders of a token a token no

A channel's tokens are secrets, so they go in .env, and chloe.config.ts hands them over under agents and the agent's id:

// Every agent this copy runs, and how the runtime behaves. An agent is on this
// list or it does not exist.
//
// `settings` is every choice, as deep as it goes, and what it leaves out is the
// default. This file is in source control, so a key goes in .env beside it and
// is named here as `process.env.` and its name in .env. chloe reads no key it
// is not handed here.
import { defineConfig } from "@chloejs/core";

import shop from "./agent.ts";

export default defineConfig({
  agents: [shop],
  settings: {
    // What an agent asks when its own agent.ts names none, and the gateway's key.
    model: { defaultModel: "anthropic/claude-sonnet-5.5", key: process.env.CHLOE_MODEL_KEY },
    // The shop's own channels: its Telegram bot and its WhatsApp number.
    agents: {
      shop: {
        telegram: process.env.CHLOE_AGENTS_SHOP_TELEGRAM,
        whatsapp: {
          phone_number_id: process.env.CHLOE_AGENTS_SHOP_WHATSAPP_PHONE_NUMBER_ID,
          token: process.env.CHLOE_AGENTS_SHOP_WHATSAPP_TOKEN,
          app_secret: process.env.CHLOE_AGENTS_SHOP_WHATSAPP_APP_SECRET,
        },
      },
    },
  },
});

Saving the config or .env starts the channel, with no restart.

Options they share

Telegram, Slack, Discord, WhatsApp and email take these. A web page and HTTP have their own, on their pages.

Option Default What it controls
allowFrom nobody (anybody on WhatsApp; required on email) Who may reach the agent, as the platform names them. While the list is empty, a private message is answered with the sender's id, which is what goes in it. On Telegram, Slack, Discord and WhatsApp, the first entry is who the agent's jobs ask when they name nobody.
name the kind, like "telegram" Only needed when an agent has two of one kind. It is the start of every address on the channel: telegram:12345.
chatHistory { messages: 10 } How much of a conversation each turn is shown: the last messages, none older than days. Nothing is deleted.
sendWhileWorking off Sends what the model writes on its way to an answer, not only the answer. Not on email.
tools every tool the agent has The only tools a turn on this channel gets. See below.
job none A job every message goes to, instead of a turn. See below.

What a message gets: tools or a job

Without either option, a message is a turn with the agent and every tool it has.

tools keeps a turn to the tools named, for when the person writing should not reach the rest. Name each by the tool itself, so a typo is an error in your editor, or by its name in the agent's tools. The agent's memory and skills come with them, and for its owner the tools that read its own files and runs; selfWriteFile only when named. On a web page, only what is named, and chatAsk.

job sends every message to one job instead of a turn, so code goes first: look up who wrote, read their orders, and decide whether a model is needed at all. The job reads the message from work.input, where userId is the sender's id on that channel, and what it returns is the reply:

// A customer's message, answered with their own orders at hand. The shop's
// WhatsApp number (channels/whatsapp.ts) hands it every message, in place of a
// turn.
//
// Code goes first. The number they wrote from is looked up in the customer
// system, and a number on no account is answered without asking a model at
// all. Only then is one model step given that customer's orders and their
// message, and asked for the reply.
import { z } from "zod";

import { defineJob } from "@chloejs/core";

import { customerByPhone } from "../services/customersService.ts";
import { ordersBy } from "../services/ordersService.ts";

export default defineJob({
  id: "answer-whatsapp-customer",
  description: "A customer who wrote on WhatsApp has an answer that knows their orders.",
  run: async (work) => {
    // Their id on the channel it came in on, which on WhatsApp is their number.
    const who = await work.step("look them up", () => customerByPhone(work.input.userId));
    if (!who) return "This number is not on any of our accounts. Write from the number you order with, and we will find you.";
    const orders = await work.step("their orders", () => ordersBy(who.id));
    const { reply } = await work.model("write the reply", {
      instructions:
        "You answer a shop's customer, briefly and warmly, from their orders alone. " +
        "When the answer is not in them, say somebody from the shop will get back to them today.",
      output: z.object({ reply: z.string().min(1).max(1000) }),
      prompt: `${who.name} wrote: ${work.input.text}\n\nTheir orders: ${JSON.stringify(orders)}`,
    });
    return reply;
  },
});
  • Name the job on the channel only, not in the agent's jobs. It must be code with run and no cron line. Only a message there starts it.
  • One conversation runs it once at a time, and two conversations run it side by side.
  • A job that asks with work.ask asks in that conversation, and the next message is the answer.
  • A slash is only text there, except /clear.
  • A run that fails tells the person only that something went wrong. The error is in the run.
  • work.state is shared by every conversation: keep anything per person under work.input.userId.

An agent that should be a different agent for some people is a second agent, with its own channel.

A message while it is answering

One reply at a time in a conversation, on every channel. A message from the same person while a reply is going is read by that reply before its next step, and the reply answers both; nothing is sent for the second message on its own. A message from anybody else, or one with files, waits for the reply to end and then gets its own.

Commands in a chat

A message starting with /<job id> runs that job, and the words after it fill the job's args: see A job is a workflow. Four commands work on every channel: /clear starts a new conversation and keeps the old one on the page, /compact has the agent's quick model sum up the conversation so far, after which the agent is shown that summary and what came after it (the messages it sums up stay on the page), and /models and /model pick the model, see Models. Any other slash message is an ordinary message.

Writing one

A channel chloe does not ship is a file in the agent's channels/ folder, written with defineChannel(kind, options, start) from @chloejs/core/channels. Its options extend Shared, so it takes the shared options above, and defineChannel handles them. start does only the platform: it listens, turns each message into an Incoming, calls receive(agent(), incoming, rulesOf({ ...options, bound })), and sends back the text that returns.

receive() decides what happens to a message, the same for every channel, in this order:

  1. Nobody outside allowFrom is answered.
  2. The answer to a sign-in goes to the connection that asked, and the answer to a job waiting on that chat goes to the job.
  3. In a group, a message not meant for the agent is left alone.
  4. /<job id> runs that job, and /clear, /compact, /models and /model are answered.
  5. Anything else is a turn, or goes to the channel's job. A reply that reads /<job id> starts nothing, because the model may have read a page written to ask for it.

Hand receive() a send to allow sendWhileWorking, calling to be told as each tool starts, and writing for the words as they are written. A reply can carry buttons, and multiple when several may be picked: send the ticked ones as one message with together(). commands(agent) is the list to show in the platform's own command menu.