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 |
numbers in allowFrom, or anybody when unset |
a number registered with Meta | a route your web server passes on | |
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 withrunand 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.askasks 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.stateis shared by every conversation: keep anything per person underwork.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:
- Nobody outside
allowFromis answered. - 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.
- In a group, a message not meant for the agent is left alone.
/<job id>runs that job, and/clear,/compact,/modelsand/modelare answered.- 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.