Putting an agent where people already are¶
An agent that only answers inside this dashboard is a demo. The same published agent answers in eight places, and every one of them runs the same frozen version through the same budget, the same approval gate and the same tenant checks — the surface changes, the agent does not.
| Where | What it needs | Who the visitor is |
|---|---|---|
| Dashboard | nothing | a signed-in member |
| Website widget | a <script> tag |
anonymous, or a user your backend vouches for |
| Raw WebSocket | an embed key | whatever your integration says |
| A hosted page | a link | anonymous, and nothing else |
| The API | a session | whoever holds the credential |
| Slack | a bot token | a Slack account, optionally linked to a member |
| Telegram | a bot token | a Telegram account, optionally linked |
| Mattermost | a bot token and your server URL | a Mattermost account, optionally linked |
The dashboard also opens as a desktop app: a window around the same console, loaded from the same server, with nothing bundled.
Three rules hold on every surface, enforced in the runner
- A run always belongs to exactly one organization.
- A spending limit is checked before each model request, never after.
- A run that failed is still in history with what it spent — the tokens were spent before it broke, and a budget that ignores that is not a budget.
Three of those eight are one table, and its rows differ by a kind
A widget, a raw socket and a hosted page are each an embed — and a kind is fixed at creation, because a tag already pasted, a client already written and a link already sent all name the same row.
One public key, one rate bucket, one budget, one pause switch, and one set of refusals. What differs is what there is to configure and what admits a visitor — which is why the Builder asks which one you want before it asks anything else, and why a page has no allowed-origins list rather than an ignored one.
Every run records the surface that admitted it — web, embed, api, slack,
telegram or mattermost — which is what the dashboard's by-surface chart
aggregates. All three embed kinds record embed. Two historical wrinkles:
widget runs recorded before the embed value existed are stored as web, and
Mattermost runs from the same era as api. Neither is backfilled — rewriting
history would be a guess — so charts over old periods fold those runs into the
surface they were recorded under.
What a stranger may do, they may do at a rate.
The surfaces reachable without a session carry a limit counted in the deployment's Redis, so it holds across workers:
- the run API, per caller;
- the widget's script and its admission, per address, on a counter each;
- a hosted page's config, per page — that one is fetched by the frontend server rather than by the browser, so an address there names a container and would put every visitor in the deployment in one bucket.
The script is counted apart from the admission it precedes because a page load spends both, and one bucket for both made the number an operator sets mean a third of itself.
RATE_LIMIT_RUN_PER_MINUTE, RATE_LIMIT_EMBED_PER_MINUTE and
RATE_LIMIT_HOSTED_PAGE_PER_MINUTE set them, and
configuration has the one caveat worth reading
before production — behind a proxy, every visitor arrives as the proxy unless you
say otherwise.
What rations spend on a hosted page is the socket the page opens, and that is counted per address like the widget's.
Integrating rather than configuring?
The HTTP API covers authenticating, the organization header, running an agent over HTTP, the two WebSocket endpoints and the error envelope.
The website widget¶
The shortest path. Publish the agent, create an embed, paste two lines.
1. Create the embed¶
In the Builder, open the agent → Availability → Website widget. You choose:
- Allowed sites — the sites this widget may be opened from. An empty list allows nothing, so publishing without one is refused rather than producing a widget that answers nowhere. The key in the script tag is public by construction, so the origin list is what actually stops somebody else running your agent on your bill. The same rule holds for a socket, whose handshake is checked against the same list.
- Auth —
public(anonymous visitors) orjwt(your backend vouches for each visitor; see below). - Look — the header and the line under it, the greeting, what the empty box says, what the launcher button says, the accent colour and which corner it sits in. All seven, and the greeting is drawn by the widget rather than sent to the model: a greeting in the model's history is a turn the agent thinks it took.
- Suggested messages — up to four first messages offered as buttons under the greeting; a click sends one as the visitor.
- Sources — off by default. On, an answer that drew on the knowledge base
names those documents under it: the file names only, never the passages, sent as
their own
sourcesframe. - Context — a note appended to the visitor's first message: "You are on the pricing page", "Answer in German". It never replaces the agent's own instructions, which belong to the published version.
- Rate limit — messages per visitor per minute.
- Voice input —
off,browserorserver, which decides where a visitor's voice goes. See dictation on a widget or a hosted page.
An empty origin list allows nothing, and that is on purpose
The key in the script tag is public by construction, so the origin list is the only thing stopping somebody else running your agent on your bill. Publishing without one is refused rather than producing a widget that answers nowhere.
2. Paste the snippet¶
That is the whole integration. The script has no dependencies, no build step and no framework — it runs on a page that already loads React, jQuery or nothing at all.
3. (Optional) tell it about the visitor¶
A widget can declare variables - a name, whether it is required, and a line saying what it is for - and the page supplies them:
<script>window.AgenticOSContext = { plan: "pro", locale: "pl" };</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
The snippet the Builder hands you already carries that line, with your own keys in it, once you have declared any.
They are appended to the agent's instructions as a marked block of data, under a
line saying they are information about the visitor rather than instructions, and
that they cannot be verified. That last part is true of every one of them,
including on a jwt widget: the widget reads window.AgenticOSContext, and a
token authenticates who the visitor is rather than what the page said about
them. So nothing here may decide what the agent is allowed to do.
Three rules follow from that:
- A key nobody declared is dropped. The page is something a visitor can edit; without a declaration, any key they invented would become a line inside an agent's instructions.
- A missing required value omits its line and is logged.
requiredis a promise between an integrator and themselves - enforcing it would cost a visitor their answer over somebody else's deployment mistake. - Sent once per conversation, ahead of the first question, and read from every frame rather than at connect time: a single-page application learns who somebody is without reconnecting.
4. (Optional) tell it who the visitor is¶
For a widget inside your own logged-in product, set a token before the script loads. Your backend signs it; we verify it and never see your user database:
<script>window.AgenticOSToken = "<%= agenticos_token_for(current_user) %>";</script>
<script src="https://your-api.example.com/api/v1/embed/PUBLIC_KEY/widget.js" async></script>
Minting one, in any language that can sign a JWT:
import time, jwt # PyJWT
token = jwt.encode(
{"sub": str(user.id), "iat": int(time.time())},
EMBED_SIGNING_SECRET, # the secret you set on the embed
algorithm="HS256",
)
subis required. It identifies the visitor for rate limiting, and a token without one is refused — otherwise a single leaked token becomes the whole widget's budget.iatis required and must be within the last 12 hours — it is not checked only when present. A token with noiat, or a stale one, is refused, so one that leaks out of a browser cannot work forever. Anexpyou set is honoured too, but only to shorten that window (an expired token is rejected); it cannot extend a token past the 12-hour ceiling.- Mint it per page load, server-side.
Never ship the signing secret to a browser
It signs any visitor. The token it mints is the thing the browser is
allowed to hold, and only for the twelve hours iat gives it.
The raw WebSocket¶
The widget is a client of a documented protocol, not a black box. If you want your own UI — a mobile app, a kiosk, a component in your design system — talk to the same socket:
You do not have to assemble that yourself. Publish one from the Builder —
the agent → Availability → Raw WebSocket — and its row prints the URL,
built from the deployment's own base URL, with a copy button. A widget prints
the same thing beside its script tag, because a widget is a client of this
protocol: moving to an interface of your own is a step rather than a rewrite. The ?token= is not printed
there: in jwt mode the token is minted per visitor by your backend, and a real
one on a dashboard screen is a working credential somebody can read over a
shoulder.
The first thing that goes wrong: a missing Origin
The handshake must carry one on the embed's allow-list. A browser sends it
for you; a client of your own sends nothing unless you set it - a mobile
app, a kiosk, a server-side relay. What it looks like when it does not is
4003 in the table below, not an error message.
sequenceDiagram
participant C as Your client
participant E as /embed/{key}/ws
participant R as run_stream
C->>E: handshake (Origin, optional ?token=)
alt origin not allowed, token bad, embed paused
E-->>C: close 4003 - do not retry
else admitted
E-->>C: ready { visitor }
C->>E: message { text }
E->>R: the same loop /chat drives
R-->>C: model_request_start
R-->>C: text_delta … (a word at a time)
R-->>C: final_result, then complete
end
Frames you send
That is the whole inbound vocabulary, plus context (what the page says about the
visitor) and file_ids (what they attached, on a page that takes files).
Every message frame counts toward a per-connection limit, including empty or
malformed messages. Valid messages also spend the existing visitor allowance;
malformed input cannot exhaust a bucket shared with other anonymous visitors.
A frame may name at most three file IDs; oversized lists are refused before
parsing. Invalid IDs in a bounded list are dropped and recorded as one aggregate
log entry without their values.
It deliberately does not include the three fields the dashboard's own frame carries: the agent, the model profile and the environment.
A frame that could choose a model is a visitor choosing one on the operator's bill, and one that could choose an agent is a visitor talking to something nobody published on this key. All three come off the embed row.
An unknown field is ignored rather than refused. A client cached in somebody's browser may be older than this server, and closing the socket over it would take the conversation with it.
Frames you receive
This is the dashboard's own frame vocabulary, not a second one
/chat and this socket drive one loop (app/services/run_stream.py), so an
answer arrives here a word at a time the same way it does there. A hosted
page used to show one lump of text after thirty seconds of nothing, and that
was the loop rather than the transport.
Every frame carries { "type": …, "data": { … } }.
type |
data |
Meaning |
|---|---|---|
ready |
visitor |
Connected. visitor: true when a token identified the person. |
history |
messages |
What was said in the thread this visitor is resuming — a hosted page whose visitor is anonymous, which is the only connection that carries a continuity key: a widget's conversation lasts as long as its socket, and a jwt visitor is named by their token. Each entry is role, text and at, so a replayed turn keeps the time under it. |
model_request_start |
— | The agent has gone to the model. Show an indicator. |
part_start |
index, part_type; tool_name, tool_call_id for a ToolCallPart |
A block of the answer is starting. A tool call names itself here, so a client can show it while its arguments stream. Sent only for a block this surface will actually carry — a page showing no reasoning does not announce a ThinkingPart, since the announcement alone says the agent reasoned. |
text_delta |
index, content |
Words of the answer. Append them. |
thinking_delta |
index, content |
The model's reasoning. Only if the operator turned it on. |
call_tools_start |
— | The agent is about to use tools. |
tool_call |
tool_call_id, tool_name, args |
A step. args only when the operator shows results. |
tool_call_delta |
index, args_delta |
A call's arguments as they stream. |
tool_result |
tool_call_id, content |
What the step returned. |
sources |
names |
The documents a knowledge search returned, by name. Only on a widget whose operator turned sources on, and never the passages. |
final_result_start |
tool_name |
The answer is being produced by an output tool. |
final_result |
output |
What the run ended with. Empty on a turn that parked. |
complete |
— | The turn is over. It carries no usage: what a run cost is the operator's business, not the visitor's. |
error |
message |
Something the visitor should see: rate limit, budget reached, a refusal, a turn that produced nothing. |
compaction_started, compaction_finished, compaction_impossible |
per frame | The agent is tidying its own notes because the thread outgrew the model's window. Always sent, whatever the operator's switches: a summary takes tens of seconds, and a surface that streams and says nothing simply stops for the length of it (#936). |
Some dashboard frames never reach a public socket, and they are refusals rather
than settings. user_prompt_processed carries the prompt as assembled —
the placement note and the supplied block above what the visitor typed — which is
the operator's text and not the visitor's to read back.
ask_user and tool_approval_required have nobody here to answer them, but
they fail differently. A visitor cannot approve a side effect on somebody
else's organization, so tool_approval_required parks the run exactly as it
does on a channel, and the turn ends with error saying a person has to decide —
unlike a channel, without the /runs link, because the reader there is a member
who can open it and here they are a stranger holding a link. ask_user does
not park: AgentDeps.ask_user is None on this surface, so the tool
refuses when the model calls it (app/agents/ask_user.py) and the model carries
on to answer without the input — a degraded answer, not a parked run.
A client ignores what it does not draw, and widget.js is the worked example:
it reads model_request_start, text_delta, final_result, complete and
error for the answer, tool_call and tool_result to show each step as a chip
(Searching the knowledge base), and sources for the documents an answer named.
It ignores the reasoning on purpose. It wears the agent's face, renders the answer
as Markdown into nodes rather than HTML, follows the visitor's light or dark
setting, and fills a phone's screen.
Close codes
| Code | Meaning |
|---|---|
4003 |
Refused. The origin is not allowed, the token failed, or the widget is paused. Do not retry — the answer will not change. |
4029 |
Too many connections from this address in the last minute. Back off and retry. |
1011 |
This client was not reading. A frame took longer than 30 seconds to reach it, so the server stopped writing rather than hold the turn's database session and the open provider stream once per frame. Reconnect; a hosted page resumes its thread. |
The refusal is deliberately one code with one message. A page that is not on the allow-list learns that it is not allowed and nothing about whether a token would have helped.
4029 is separate from it for the opposite reason: "not allowed" and "allowed
but too fast" ask a client for opposite things — stop for ever, and try again
later — so a client that cannot tell them apart either hammers a refusal or
abandons a limit. How many connections an address gets is
RATE_LIMIT_EMBED_PER_MINUTE; how many messages a visitor gets once connected
is the widget's own rate limit, set in the Builder.
A minimal client:
const socket = new WebSocket(`${BASE}/api/v1/embed/${KEY}/ws`);
let answer = "";
socket.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
if (type === "text_delta") render((answer += data.content));
if (type === "final_result" && data.output) render((answer = data.output));
if (type === "complete") answer = "";
if (type === "error") render(data.message);
};
socket.send(JSON.stringify({ type: "message", text: "hello" }));
final_result is assigned rather than appended: it is what the run ended with,
and a provider that streamed no deltas leaves it as the only copy of the answer.
What each surface offers, and why the differences are differences¶
Three surfaces run the same agent through the same runner: the dashboard's chat, this socket, and the public API. They do not offer the same run, and until #936 nothing said which of the differences were decisions.
They are now. Every "no" below has a reason, and a reason is either "this would be wrong here" or "this is not built yet" — never silence.
The last column is Teams (#1985), standing for the chat platforms beside them.
/chat (dashboard) |
Raw WebSocket | The public API | Teams | |
|---|---|---|---|---|
| Streaming | yes | yes, filtered by what the operator shows | no — the POST is the non-streaming path; an SSE variant is a separate question | a message rewritten as the answer arrives |
| Attachments | yes | yes (file_ids) on a hosted page — the upload endpoint resolves the key through find_page, so a widget or a raw socket has no route that would produce an id |
yes (file_ids), and they route the way they do everywhere else |
yes, from a personal chat; a file goes back through Teams' consent card |
| Conversation continuity | yes | yes (continuity_key), on a hosted page with an anonymous visitor |
yes (conversation_id) |
a chat, or a channel thread |
environment_id |
yes | no, deliberately — see below | yes | no — the binding decides, as on every chat platform |
| Model override | yes | no, deliberately: a public surface must not let its visitor choose what they are spending | no: the request carries no model_profile_id. Which version runs is still the caller's through environment_id, and an environment can pin one that was never the default |
no, as on every chat platform |
ask_user |
yes | no, not yet — see below | no, correctly: nobody is waiting on an HTTP request to answer a question | yes, as an Adaptive Card |
| Compaction notice | yes | yes | n/a — nothing is streaming to tell | no — the answer arrives when it is done |
| Delegation frames | yes | no, deliberately: attaching a sink makes the library open a streamed request per child, so a delegate whose provider cannot stream breaks the moment somebody watches it | n/a — nothing is streaming to draw them in | no — the answer arrives when it is done, its steps undrawn |
| Approvals: parks the run | yes | yes | yes | yes |
| Approvals: a path to the decision | yes (/runs) |
one sentence, deliberately: a stranger holding a link is not shown a URL into somebody's console | yes — the result carries parked, naming what is waiting and the approval to post to |
yes — an Adaptive Card, decided by the member who presses it |
@-mentions |
yes (mentions) |
no, not yet: a widget has no menu to pick from, and a visitor's mention would need the same as-them check | no, not yet: a script names what to use in the message itself | no, as on every chat platform |
environment_id on the socket, and why it is the publisher's¶
The API has it because the caller is the organization: exercising a dev environment before promoting it is their own work on their own agent. A socket's caller is whoever holds the embed key, and on a widget that is a stranger's browser. A frame that chose the environment would let the visitor pick which version of the agent answers them — including one the organization has not promoted, which is the opposite of what publishing a version means.
So it is declined in that shape. If a first-party integration needs to run a named environment, the place for it is the embed's own configuration, where the publisher chooses once and every visitor gets the same answer.
ask_user on the socket, and what it would need¶
ask_user's module docstring says the pause and resume live "in the WebSocket
session", and it means the chat's session. This socket is equally live, with a
person equally sitting there — so a delegate that would ask is told nobody could
be reached, which is the right answer for a scheduled run and the wrong one here.
It is not closed here because the missing half is not the server's. The frame
vocabulary has no ask_user and no ask_user_response, and the widget has no
form to render one — so attaching the callback would park runs on a question
nothing can answer, which is worse than declining. What it needs: the two frames,
the widget's form, and a decision about a hosted page, where the person being
asked is a stranger and the question may carry anything the agent chose to say.
Approvals on the socket: decided, and deliberately not widened¶
A parked run answers a visitor with "That needs somebody to approve it before it
can run", and that stays. The raw socket is sold as an interface you build
yourself — a mobile app, a kiosk — and a first-party client with a signed-in user
could in principle be handed the parked call and a way to decide it. It is not,
because the embed key does not identify a member: it identifies a deployment's
published surface, and the person on the other end may be anybody the integration
let in. Deciding an approval is a member's act, gated on approvals:decide, and
it belongs on a surface that knows who is asking.
A hosted page¶
The shortest integration there is: send somebody a link. No site of your
own, no <script> tag, no client to write, no sign-in.
In the Builder, open the agent → Availability → Hosted page. There is no site to name and nothing to paste — the form asks for a title, a welcome, an accent and a logo, all optional, and publishes:
It is an embed like the other two: the same kind of key, the same rate limit, the same budget and the same pause switch. Pausing it stops the page at once, and every link already sent with it.
What protects it¶
Say this part out loud before publishing one, because it is the whole security model:
A hosted link in
publicmode is protected by the key being unguessable, plus the embed's rate limit, its budget and its pause switch. Nothing else.
Whoever has the link can talk to the agent. That is the point of a link, and it is why the key is 24 random bytes rather than something readable.
There is no allowed-origins list here, deliberately, and the form does not offer
one: an allow-list is a rule about other people's sites, and this page is one we
serve. A page is admitted from the deployment's own origin — derived from
FRONTEND_URL, never hardcoded — and nowhere else. A CHECK constraint refuses
a page that carries a list at all, because a stored one reads as the thing
protecting the link, and it is not.
Two things a hosted page refuses¶
Both are refused at publish, with a message, rather than silently falling back to a widget:
- A page cannot use
jwtmode, and the form does not offer it. The token would have to travel in the URL, and so into browser history,Refererheaders and every chat client the link is pasted into — and the fragment trick that avoids some of that stops the link being "send it and it works". Use a widget or a socket for a per-user integration;jwtthere is unaffected. ACHECKconstraint holds the same rule in the database. - A required variable that is not URL-safe cannot be on a page — see below.
Variables from the address bar¶
A hosted page has no page of yours to read window.AgenticOSContext from. Its
only source for a declared variable is the visitor's own URL:
A query parameter is visitor-controlled input, so this is off per variable
and on only where somebody decided it: tick URL-safe on the variable in the
Builder. Without it, ?var_user_tier=premium typed into the address bar is
dropped — which is the point. Anything not declared at all is dropped as it is on
the widget.
That is also why a required variable has to be marked: on this surface the URL is the only way to supply one, so a required-and-not-URL-safe variable is a promise the page structurally cannot keep.
Coming back to it¶
A widget's conversation lasts as long as its socket. A bookmarked link is a
stronger promise, so the page keeps a random visitor key in localStorage — one
per public key — and the server maps it to a conversation. Reopening the link
replays the thread and the agent is reminded of the same window the visitor is
reading.
The key is a bearer credential for that conversation: whoever holds it resumes the thread, including what is already in it. It is 128 random bits and nothing about the person. Clearing site data starts a new thread.
That key is the whole of what the page stores, which is why a hosted page and a shared conversation show no cookie prompt — the two surfaces served to somebody who is not a member are the two with no optional cookie to consent to. The product's banner used to appear here, asking permission for analytics this deployment does not run while sitting over the composer and covering Send (#644). A consent prompt for one essential key is a prompt whose only effect is the overlap.
That shape is enforced, not assumed — the socket accepts 32 to 64 lower-case hex
characters as a visitor and drops anything else, opening a fresh thread
instead. It matters for a client of your own (below): keying continuity on a
customer id, an email or a counter would hand each of your users a conversation
the next person can walk into by guessing. A dropped key costs continuity and
never the conversation, so a stale value in somebody's browser is not a page that
will not load.
What it offers¶
Three choices, and every one is the operator's rather than the page's — a capability a page turned on for itself would be one nobody could turn off.
- A button to start a fresh thread, on by default. It mints a new continuity key, so the old thread is not deleted: it stops being the one that browser resumes.
- Voice input, off by default:
browserdictates with the visitor's own browser, andserverwith the organization's speech-to-text provider. Which one decides where a visitor's voice goes - see dictation on a widget or a hosted page. A page published before the choice existed keeps what it had: its old microphone in the composer becamebrowser, and no microphone becameoff. - A way to attach a file, off by default. See below: it is the only thing on this surface that lets a stranger store something.
What a stranger holding the link can write to¶
Everything else on a public surface reads. This one writes, so it is worth stating exactly what a visitor can put where.
They can store a file, and only if the operator ticked the switch. The bytes
go through the same path a member's upload does — the MIME allowlist,
CHAT_MAX_UPLOAD_SIZE_MB (10MB by default — the chat surface's own ceiling,
not the knowledge base's larger MAX_UPLOAD_SIZE_MB), the parser, the storage
backend, a ChatFile row — with three narrowings in front of it:
| A cap of this surface's own | EMBED_MAX_UPLOAD_SIZE_MB, 5MB by default. A member uploading a fifty-megabyte export is somebody the organization employs; the same allowance on a public link is a way to fill a disk from an address nobody knows. It is a ceiling on top of CHAT_MAX_UPLOAD_SIZE_MB, never a way past it |
| A limit per address and per visitor | RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE, in the shared Redis, and both have to allow it. Counting only the continuity key bounds nothing: the browser mints it and any 32 hex characters is a valid one, so a script varies it per file. Counting only the address lets one browser on a shared one spend everybody's |
| Three files to a message | Which bounds how much of one turn's prompt is somebody else's document |
Those three bound what gets stored. What bounds what a stranger can make this deployment receive is one layer above all of them, because the multipart body is parsed before the route runs: a request declaring more than the whole-request ceiling is answered 413 without being read. See configuration, including what it does not cover.
The row belongs to the member who published the page, because
chat_files.user_id is NOT NULL and a visitor has no account — the same answer
already given for who a public turn runs as. A page whose publisher's account is
gone therefore cannot take files at all, and says "not available" rather than
storing them against nobody.
Where the file then goes is the runner's decision, not this surface's. It is the routing in File processing, unchanged: into the agent's workspace where it has one, folded into the prompt where it does not, and an image both ways up to the inline ceiling. Nothing about a visitor's file is a special case.
A frame may only name a file that belongs to this page's owner and does not
already hang off a message, so an id cannot be replayed into a second turn or
into somebody else's thread. That is proportionate rather than complete, and what
makes it enough is the id itself: uuid4 is 122 random bits, so an id from
another visitor is a value nobody can produce without having been handed it.
They cannot write anything else. No knowledge base, no workspace path of their own, no config, no variable that is not declared and URL-safe. The conversation row and the turns in it are written about them, by the platform.
What the visitor sees of the work¶
Three more switches, and they are filters on what the server sends rather than on what the page draws. That distinction is the whole design: reasoning hidden in CSS is an agent's reasoning sitting in a stranger's devtools, and a page is exactly where a stranger has one open. A visitor who opens theirs sees what is ticked here and nothing else.
| Switch | Default | What it lets through |
|---|---|---|
| What the agent is doing | on | One line per step — Searching the documents, Ran a query. On, because a page that goes quiet for thirty seconds reads as broken |
| What each step returned | off | The arguments a step was called with and what came back. Written for the model, so this is where something internal turns up: an address, a row from a system, a passage nobody meant to publish |
| The agent's reasoning | off | What the model says to itself before answering. Not written for anybody to read, and not an answer an operator can stand behind |
What each step returned cannot be turned on alone — there is no step for it to open, and the server drops both regardless of what the config says.
A turn looks like a turn in web chat, down to the chrome around it: the agent's name above the answer, the avatar in the gutter — the page's logo where there is one, the agent's initial where there is not — the time under each turn on the side it is on, and one composer card with the field and its controls inside it.
Three things web chat draws there are deliberately absent, and all three are the same decision as the panels below: what the turn cost, what the month has cost, and which agent and model to run.
That last one because a frame that could pick a model is a visitor picking one on the operator's bill.
A turn is rendered by web chat's own components, not by a second set that looks like them.
TurnParts is what the dashboard renders and what the page renders. So the
reasoning is the same disclosure, the answer the same Markdown, and a run of tool
calls the same rail — the icon from src/lib/tool-catalog.ts, the wording from
toolStep, and the same renderers opening under a step for a knowledge search, a
web search, a chart, code that ran, a skill that was loaded, a file that was
written.
There is deliberately no second table of tool names and no second turn renderer (#144). What the page does not draw is everything about being a member — see below.
One thing reads differently by necessity: a call that came from an MCP server is named Linear · Create issue in the dashboard and by a humanized name here, because the mapping is the organization's list of connections and reading it needs a session.
A generated image, clip of speech or video plays on the page too (#2309).
The console reads one through /api/v1/generated/{filename}, which needs a
session, and a visitor has none. So as the socket sends a generated image,
speech or video result naming a file that this run's own tools stored, it adds
a token, and the page reads the file through
/api/embed/<key>/generated/<file>?token=…, which streams it and passes byte
ranges through for a player.
The token is an HMAC, under the deployment's SECRET_KEY, of the page's key, that
one file and an expiry six hours away. It opens nothing else: not another
conversation's file, not another organization's, not any other name. A bad,
expired or missing token gets the same 404 as a missing file. It is added as the
frame leaves, so the transcript stores none, and the card is that step's result —
it shows only where What each step returned is on. A socket integration finds
the same address in the payload's url, as an API path.
A result that names a file this run did not store gets no token, whatever the tool that returned it is called. One link may be fetched 120 times a minute, room for a player's byte ranges, and a link past that is answered 429.
A widget and a raw socket carry no switches and get these defaults, read off
PageConfig rather than repeated — a second copy of "off by default" is a copy
that can disagree with the one somebody reads in the Builder. What widget.js
then draws is narrower still, and says so above.
Neither of them takes files either, and that is the route rather than the client:
the upload endpoint resolves the key through find_page, so a widget key reaches
it and is answered "not available". A widget lives on a page the operator already
controls, which is where a file picker of their own belongs.
What is deliberately member-only¶
Web chat draws three panels a public surface does not, and each omission is a decision rather than a gap — recorded here so it is not re-litigated as one.
| Panel | On a public surface | Why |
|---|---|---|
| The usage strip | No, and it is not a switch | It reports the turn's tokens, its cost, the month against the organization's cap and how full the workspace is. A visitor is not the one paying, and the operator's remaining budget is a fact about the operator. complete carries no usage at all, so there is nothing to hide client-side |
| The file panel | No | It lists everything in the agent's workspace, which is shared across the conversations of everybody using that agent. A stranger who attached one file would be shown every file the agent has ever been given. Their own attachment is on their own turn, which is what they are owed |
| The delegation panel | No | It names the delegates by slug, what each was asked, and what each cost — the shape of the organization's agent graph. A page that showed it would publish an internal org chart to whoever has the link. A delegation still runs: it is one tool_call step named task, under the same switch as any other step |
The pattern behind all three: what a member sees is about the organization, and what a visitor sees is about their own turn. A panel that crosses that line is member-only whatever it would cost to render.
What it looks like¶
The answer renders as Markdown, the same as web chat: an agent told to answer in Markdown is answering in it whether or not the page reinterprets the asterisks. What a visitor typed is not reinterpreted — it is not a document.
Four fields, all optional:
| Field | Default |
|---|---|
| Page title | the agent's name |
| Welcome message | none. Markdown, written in the same editor the placement note uses and rendered as Markdown on the page. Shown before the first question and never sent to the model — a greeting in the model's history is a turn the agent thinks it took |
| Accent colour | #4f46e5. Light and dark still follow the visitor's system |
| Logo | the agent's avatar; or the organization's, one you upload, or none. Whichever you pick, the page shows nothing rather than a broken image when there is no file behind it — an agent with no avatar is the common case, and a browser cannot tell a 404 from a slow image |
Three of those four are images this platform already holds. The fourth takes a file — PNG, JPEG, WebP or GIF, up to 2MB — and it can only be added once the page exists, because an upload needs a row to attach to.
The page fetches it from its own origin, not from the API: img-src in
next.config.ts excludes an API on plain http, so a page pointing an <img> at
one rendered a broken glyph in dev and on any deployment terminating TLS elsewhere.
/api/embed/<key>/logo on the frontend proxies it.
What it does not take is a URL of your own. A page we serve fetching an operator-supplied image is one more thing to make safe. And the stored path is a column, written by the upload route and never by the config you submit: the path is read back and streamed by a public route, so one accepted from a request body would be a caller naming any file the process can open.
Nor does it take your filename, or your word for what the file is.
Because the page fetches the logo from its own origin, whatever type that response
carries is a type the browser trusts on that origin — and script-src there allows
inline script.
An upload is accepted on the Content-Type its client declared, which is not
evidence about the bytes. So the name on disk is minted from the type instead
(logo.png, logo.jpg, logo.webp, logo.gif), and both the API route and the
frontend proxy refuse to answer with anything that is not one of those four image
types.
A stored .html or .svg — from here, or from an avatar uploaded years ago through
another route — is served as nothing at all rather than as a script.
The page is noindex. A secret link is not a page to be indexed, and a crawler
that follows one has published it.
Dictation on a widget or a hosted page¶
A widget and a hosted page each have a dictation setting: off, browser or
server. browser uses the visitor's own browser to recognise speech, so
the audio goes to the browser's vendor - Google for Chrome, Apple for Safari -
and nothing reaches this deployment. A browser without a recogniser, Firefox
among them, is shown no microphone. server records in the page and sends
the audio here, where the organization's speech-to-text provider transcribes it:
the agent's override and its key if it has one, the organization's default and
its key otherwise - the same resolution as
dictation in chat.
The organization's engine for /chat has no say over either surface. An
organization that keeps its members on the browser's recogniser can still give
a public page server dictation, and the other way round.
Switching server on is refused while no provider with a usable key
resolves for the agent, and the embed form shows the choice disabled with the
reason. Only the switch is checked, so a page whose key was deleted later can
still be renamed. While nothing resolves, its public config says off and
visitors see no microphone rather than one that fails. The agent must also be
published: a recording for an agent nobody runs is refused before anything is
paid for.
The hosted page uses the console composer's own recorder: the recording bar with its waveform and timer, the hints under the box, and the voice glow. Its copy follows the visitor's language and names no administrator or setting. The widget has a plain-JavaScript recorder of its own: a microphone button, a level bar in the accent colour, a timer, Stop and Cancel, and one line under the box when something goes wrong. Its copy is English, like the rest of the widget, and it has no glow. On both, the text lands in the box and is never sent by itself; Enter stops a recording and Escape discards it.
Both record in the format the server advertises in the public config's
dictation_format: 16 kHz mono 16-bit PCM WAV, built from the microphone's
samples, unless the deployment
accepts compressed audio
and the provider takes what MediaRecorder makes - WebM, Ogg or MP4. Mistral,
which documents neither WebM nor Opus, gets WAV either way.
The endpoint¶
POST /api/v1/embed/{public_key}/dictation takes a multipart file and an
optional duration, and answers {"text": ...}. Admission follows the surface.
A widget is admitted as its socket is: the Origin has to be on its
allow-list, and in jwt mode the visitor's token travels as a form field named
token - never in the URL, which lands in access logs, and never in a header,
which would need a CORS preflight that somebody else's site does not pass. A
hosted page sends its continuity key in X-Visitor-Key, a custom header
that only this deployment's own origin may send. A raw socket has nothing
to dictate into and is answered 404.
Refusals carry the same details.reason as the
composer's route: 400 empty,
unsupported_type or too_large, 503 not_configured, provider_error or
timeout. Unlike the composer's route, a compressed recording whose length
cannot be read is refused here, as unsupported_type
(below).
Anything about the embed itself - unknown, paused, not on server,
an origin it does not allow - is 404. Once a widget is admitted, every answer
carries its origin in Access-Control-Allow-Origin, refusals included;
otherwise a browser hides the reason and a recording that was too long would
read to the visitor as being offline.
What it costs, and what bounds it¶
A transcription is outside every model budget: the embed's budget counts
the runs, not the dictations. What bounds a stranger's spend is two rate limits
and the organization's longest dictation. Each address may send
RATE_LIMIT_EMBED_DICTATION_PER_MINUTE recordings a minute (6 by default), and
so may each visitor of a jwt widget, by the token's subject. Each page or
widget accepts RATE_LIMIT_EMBED_DICTATION_PER_LINK_PER_MINUTE a minute (10 by
default) from everybody together, however many addresses a script rotates
through.
The address is counted before the link is looked up, so a refused request
spends its own address's minute. The link's ten are spent only once the visitor
is admitted, so a caller with no valid jwt token cannot use them up for the
visitors who have one. On a hosted page or a public widget everybody is
admitted, so anybody holding the link can still spend its minute; that costs
the organization nothing beyond those ten recordings.
While the deployment's Redis cannot be reached, these limits do not open: each worker counts the same allowances in its own memory until Redis is back. The ceiling is then the allowance times the number of API workers, rather than no ceiling at all. A voice call's start limits behave the same way.
The length is measured before the provider is called, and a recording
longer than dictation_max_seconds is refused as too_large, with three
seconds' grace for the browser's once-a-second stop. By default a recording is
raw PCM and its length is its size - the server counts the samples, so the
figure cannot be anything but true. Where the deployment accepts compressed
audio, a WebM, Ogg or MP4 recording's length is read from its own header - the
last Ogg page, the WebM block timestamps, the MP4 movie or fragment headers -
and one whose length cannot be read is refused as unsupported_type. A byte
cap alone would not do: Opus at a low bitrate fits hours into the bytes of a
five-minute recording.
The worst case for a leaked or public link
The link's limit times the longest dictation: 10 recordings a minute of up
to 300 seconds each is 50 minutes of audio every minute. At about
$0.006 a minute, which is whisper-1's list price, that is near $0.30 a
minute, $18 an hour, or $430 a day, billed to the organization's provider
account. Lower dictation_max_seconds or the link limit, pause the embed, or
keep it on browser where that is too much.
With the default, raw PCM, that is exact: no file can hold more seconds
than its size says. Where the deployment accepts compressed audio it holds
only for a file whose header states its true length, as every browser's
does. A file built by hand to understate its length is bounded by the byte
cap instead - dictation_max_seconds plus three seconds at 32,000 bytes a
second - which low-bitrate audio can fill with up to about forty times as
many seconds. That is the risk the administrator accepts when turning it on.
Every attempt that reaches the provider leaves a usage record with the surface
(hosted or widget) and the agent, and no person: a visitor is not a
member, and the call is not the publisher's. The audio is never stored.
The microphone prompt is the customer's site's. The browser asks on behalf
of the page the widget runs on, so a site that sends
Permissions-Policy: microphone=(), an <iframe> without allow="microphone",
or plain HTTP blocks it. The widget says so under its box rather than failing
silently.
Voice calls on a widget or a hosted page¶
A widget and a hosted page each have a Voice calls switch
(voice_conversation), off by default and separate from dictation. With it on,
visitors can hold a voice call with the agent: the same call a member
makes from /chat, with the same limits, run under the publisher's context and
recorded with the embed surface.
Switching it on is refused while the agent has no published Voice section a
call can be held with. Only the switch is
checked, so an embed whose agent loses its voice later can still be edited.
While a call would not start, the public config says voice_conversation: false
and visitors are offered no call; when it would, the config also says which
transport the call uses (voice_transport).
On a hosted page, a call button appears in the composer, and the call runs in the console's own call bar: the state, the time against the call's limit, the waveform, mute, push to talk and End, with captions inside the message being spoken. It is cut to what a visitor needs: no approval controls, no count of the organization's minutes, and endings and refusals in a visitor's words. Each saved turn joins the page's thread, so the visitor can carry on typing; New chat hangs the call up.
In the widget, the call is a separate script, voice.js, fetched the first
time a visitor opens the panel and only when the public config offers calls, so
the script every visitor downloads stays small. It is served at
/api/v1/embed/{public_key}/voice.js on the widget script's counter, and its URL
carries a hash of the script, so a cached copy never pairs with a newer widget.
The widget's input row becomes a cut-down call bar - a level meter in the widget's accent, the state, the time against the limit, Mute and End - with no push-to-talk and no approval card. Captions land in the message list and give way to the saved turns, and a refusal, a limit or a busy line shows as one line under the bar. End, closing the panel, leaving the page or a connection that fails to set up ends the call and releases the microphone, the audio and the connection; a connection that drops mid-call is retried once, within eight seconds. The widget's call copy is English, like the rest of it.
A widget call is a conversation of its own. The widget's typed chat keeps no visitor key - each page load starts afresh - so the turns of a call are saved to a conversation of the call's, and a message typed after it starts another. On the screen they share one list; to the agent, what was said in the call is not context for what is typed next. The hosted page has a visitor key, and there the call and the typing are one thread.
A visitor cannot approve anything. Tools that need an approval are left out of a visitor's call entirely, so the agent never reaches for one; it says plainly when it cannot do something there. A call also gets the embed's placement note and the page's declared variables, exactly as a typed message does, so the agent behaves the same in both.
A visitor is told only what a visitor needs. The switches under what the visitor sees of the work filter a call's frames as they filter typed replies: without What the agent is doing a visitor is not told a tool ran, and without What each step returned a step does not carry what a tool was called with or what came back. The limit a visitor sees is the call's own, and a refusal about the operator's setup, price or budget says only that calls are not available right now. Pausing the embed, switching its calls off or pointing it at another agent ends the calls on it within a heartbeat. The frames are in the API page.
The call socket¶
/api/v1/embed/{public_key}/voice/ws speaks the same frames as the console's
/api/v1/ws/voice (API). Admission follows the surface,
as the chat socket's does. A widget is admitted by its allow-list and, in
jwt mode, the customer's token in ?token=. A hosted page is admitted by
this deployment's own origin and the continuity key in ?visitor=, and the call
lands in the thread that key holds, so a dropped call can resume there. A raw
socket has nothing to call from.
The socket closes with 4029 past a rate limit and with 4003 for anything
about the embed - unknown, paused, a raw socket, calls switched off, an origin
it does not allow - without saying which. Everything after admission is the
call's own refusal, sent as an ended frame with its reason.
What a call costs a public link¶
Calls are inside the budgets: each is a run, metered on the agent's and the organization's budgets, and at most the agent's longest call. On top of that, a public link has limits of its own:
| Limit | Default | Counted |
|---|---|---|
RATE_LIMIT_EMBED_VOICE_PER_MINUTE |
3 | Call starts per address, and per visitor's token subject on a jwt widget |
RATE_LIMIT_EMBED_VOICE_PER_LINK_PER_MINUTE |
6 | Call starts on one page or widget, from everybody together |
VOICE_MAX_CALLS_PER_EMBED |
3 | Calls open on one embed at once; the next caller is refused as line_busy |
The embed's own rate_limit_per_minute counts messages, not call starts, so
it does not bound calls. The address is counted before the key is looked up, so
a refused start spends its address's minute; the link's starts are spent only
once the visitor is admitted. The jwt subject limit is a start rate, not a cap
on open calls; the open calls are bounded with everybody's by the embed's.
The worst case for a leaked link
Three calls open at once, each at most the agent's longest call - 10 minutes by default, 50 at most - spends at most three call-minutes a minute, on the organization's provider account. The agent's and the organization's budgets are what end it: each call is checked against what is left before it starts, and ended when the budget runs out, members' runs included. Give the agent a budget of its own, pause the embed, or switch its calls off if that is too much.
The public API¶
No frontend at all, and no browser. One request, one answer:
curl -X POST https://your-api.example.com/api/v1/agents/AGENT_ID/run \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d '{"prompt": "Summarise this week's refunds"}'
It goes through the same runner as every other surface, so the run is recorded,
the budget applies and the cost lands in the same dashboard — an API caller
cannot route around governance by not using the UI. Runs are stamped api.
The answer carries run_id, output, status and what it cost. A status of
awaiting_approval with an empty output means a tool call is parked: the run is
in the approvals queue, and it continues when somebody decides.
Limited per caller rather than per address — an office behind one NAT is not one
caller — at RATE_LIMIT_RUN_PER_MINUTE.
Other agents, over A2A¶
Another system's agent can send an agent tasks over the Agent2Agent protocol. It is off for every agent until somebody who may publish it turns on Agent2Agent (A2A) on its Availability tab, which shows the two addresses to give the other side:
- the agent card, at
/api/v1/a2a/{agent_id}/.well-known/agent-card.json, readable without a key — the agent's name, description, version, its one skill and how to authenticate; - the endpoint for tasks,
POST /api/v1/a2a/{agent_id}, JSON-RPC 2.0.
The caller sends an organization API key, or an OAuth access token this deployment
issued, as a bearer token, and it must be allowed to run the agent: one that is not
is refused with HTTP 403, or 404 when it may not see the agent at all, as A2A
leaves authorization to the transport. message/send takes text parts only, and
refuses a message with a data or file part with -32005, rather than run it on
its text alone.
message/send runs the published version as an ordinary run stamped a2a, so the budget, the
approval gate, the audit trail and Activity all apply. The task's id is the run's
id and its contextId is the conversation the run files under, so sending it again
continues that conversation. A run that stops for an approval answers
input-required; it is decided in the console, and tasks/get reads where it
stands since.
A task is read by whoever sent it, or by somebody who may read every
run (runs:view); to anyone else its id is unknown. A message naming a taskId
is refused rather than run beside the waiting task: send without it, in the same
contextId, to ask something new.
Answered synchronously: message/stream, push notifications and tasks/cancel are
refused by name, and the card says streaming is not offered. An agent that is not
served — off, unpublished, archived or another organization's — answers 404, card
and endpoint alike.
SMS¶
A text to a Twilio number reaches an agent the way a direct message does on any other platform: through the same router, linked identity, access policy and budgets (#2082). Only the transport is new.
Add the bot under Channels → SMS (Twilio). Its token is the Twilio account SID, the
auth token and the number it sends from, joined with colons:
ACxxxxxxxx:auth_token:+15551234567. An SMS bot is webhook-only; after saving, paste
the webhook URL shown for it into the number's incoming-message setting in the Twilio
console. The address Twilio signs is built from PUBLIC_BASE_URL, so that setting has to be the address Twilio actually calls: behind a proxy that rewrites it, every text is refused.
Who is texting. A request is accepted only with a valid Twilio signature - HMAC-SHA1
of the address Twilio called and its fields, under the auth token - and anything else is
a 403 before a word of it is read. A valid signature proves the request came from Twilio.
It does not prove who the sender is: the number is a claim, so a person acts with their
own permissions only once they have linked that number to their account like any other
channel identity. The link lapses after SMS_LINK_MAX_AGE_DAYS (90 by default, 0 keeps it for ever), because a number can be reassigned or taken over; the person links again.
What comes back. The webhook answers at once and the agent replies through Twilio's API when it has an answer. A reply is plain text - markdown markers are removed and a link is written out in full - and is cut at about 1,500 characters with a note that the rest is in AgenticOS, so one question costs a few segments, not a dozen. SMS cannot stream, edit a message or press a button, so an approval or a question arrives as numbered lines and is answered by texting the number. A lone number that names a choice of a prompt sent to that number is the answer. Prompts waiting at once never share numbers - the second carries on where the first stopped, so 3 and 4 follow 1 and 2 - and deciding one leaves the other open. A prompt stays open for a day, so a multi-select takes several numbers; any other text is an ordinary message.
Incoming MMS files, including messages with no text, use the ordinary channel
attachment pipeline after identity and access checks. Up to ten files are read;
their media URLs must name the bot's Twilio account and the incoming message.
Authenticated downloads reject redirects, stop at CHAT_MAX_UPLOAD_SIZE_MB and
have a 30-second total deadline. The shared upload path validates type and
content and stores each accepted file in the conversation. A number sent with a
file is a message, not a numbered approval.
Outbound replies can send explicit workspace files and charts as MMS, including media-only replies. The ordinary channel file tool still controls which file is sent and where. The adapter refuses unsupported types, empty files, more than ten attachments, or over 5 MB of text and media combined. JPEG/PNG/GIF images can use that total; other accepted types, including PDF and CSV, are capped at 500 KB. Carrier and destination support still applies; provider refusal remains an error.
MMS needs a publicly reachable HTTPS PUBLIC_BASE_URL and shared Redis. Only
copies explicitly handed to the outbound adapter are staged, each under a random
fetch credential for 15 minutes. Twilio's GET and HEAD requests use that credential;
the original authenticated file store is never exposed. Its outgoing queue is
limited to ten minutes, shorter than the copy's lifetime. These links are not
written to chat or returned to the model. The API accepting a message means it
was submitted, not that the carrier delivered it. See Twilio's accepted media
and limits.
Email¶
A mail to an address reaches an agent the way a direct message does on any other platform: through the same router, linked identity, access policy and budgets (#2082). Only the transport is new.
Add the bot under Channels → Email. Its token is the address the bot answers from, for example
agent@mail.example.com, and replies leave through the deployment's own e-mail provider, so
EMAIL_PROVIDER must be one that delivers - a bot on the log provider refuses to answer rather than
pretend it did. An e-mail bot is webhook-only and needs a secret: use Generate, then copy it into the relay.
The relay. Mail is not polled. Whatever receives it - a Cloudflare Email Worker, a Postmark or Mailgun inbound route, a script on a mail server - POSTs one JSON document per message to the URL shown for the bot after saving:
{"from": "ada@acme.com", "subject": "Invoice", "text": "Please send it",
"sender_authentication": {"dmarc": "pass", "from": "ada@acme.com"},
"message_id": "<1@acme>", "in_reply_to": "<0@acme>", "references": ["<0@acme>"],
"attachments": [{"filename": "a.pdf", "content_type": "application/pdf",
"content_base64": "..."}]}
and signs it with two headers: X-AgenticOS-Timestamp (unix seconds) and X-AgenticOS-Signature,
sha256= plus the hex HMAC-SHA256 of timestamp.body under the secret. A request with no valid
signature, or one more than five minutes off, is a 403 before a word of it is read, and a bot with no
secret refuses everything. The body is capped at 25 MB, up to ten attachments are read, and a message id
seen before is ignored, so a relay's retry runs the agent once.
Inbound attachments share a live cache limit of 64 MiB of encoded data and 1,024 files per bot. Each delivery is stored atomically; exhaustion returns 429 with Retry-After before any file or thread is cached. Reading or discarding files releases their capacity immediately. Unread files expire after 15 minutes, and the next cache operation reclaims their capacity. Processing failures also discard unused attachments. Replies and prompts retain the thread of their own inbound message even when another message arrives meanwhile.
The webhook commits a process-email-delivery job before returning 200. The job retains the validated message body, including attachments, so an API restart or an expired cache cannot erase accepted work. Message-ID deduplication commits with that job; a rolled-back request remains retriable. Workers recheck the bot and organization, serialize deliveries to the same correspondent, and record failed attempts in Admin → Background jobs. Finished job payloads follow BACKGROUND_JOB_RETENTION_DAYS; deployment administrators can inspect them.
SMS and iMessage webhooks commit a process-channel-delivery job before acknowledging a valid message. The payload and Message-ID receipt share one database transaction, so rollback permits retry and API restarts preserve accepted work. Workers recheck the bot’s organization, platform and active state, then resolve numbered replies against current prompts; expired SMS identities cannot approve old requests. Delivery payloads follow the background-job retention policy and are visible to deployment administrators.
Who is writing. The signature proves the relay sent the request, not who wrote the mail: an
address is a claim. A person acts with their own permissions only once they have linked that address to
their account like any other channel identity. Every message must include
sender_authentication: {"dmarc": "pass", "from": "ada@acme.com"}, attesting to an aligned DMARC
pass for that exact header From address. The relay must derive this verdict from its trusted receiving
mail server, never from authentication headers supplied by the sender. Missing, failed or mismatched
attestations are ignored before account lookup, attachment storage or approval processing. Existing
relays must add this field before upgrading; an HMAC signature alone no longer admits a message.
What comes back. The webhook answers at once and the agent replies by mail as the answer to the
sender's last message: Re: and the same subject, with In-Reply-To and References set so a mail client
files it in the thread, and any files the agent produced attached. History quoted under a reply is
cut before the agent reads it. An approval or a question arrives as numbered lines and is answered by
replying with just the number; the numbers behave as on SMS, and a prompt stays open for a day.
Inbound references must be a list of strings without CR or LF; retained entries must not exceed 998 characters. Malformed references are ignored before processing the message. Folded subjects are unfolded before reply headers are created.
Slack¶
- api.slack.com/apps → Create New App → From scratch. Name it and pick the workspace.
- OAuth & Permissions → Bot Token Scopes → Add an OAuth Scope, and add the thirteen below. Install to Workspace stays greyed out until at least one is added, which is what a blank app is waiting for.
- Install App → Install to Workspace → Allow. Copy the Bot User OAuth
Token (
xoxb-…). - Event Subscriptions → Enable Events → Subscribe to bot events, and add the five events below.
- Register the bot: Channels → Add channel, platform
slack, paste the bot token. - Pick a transport — Socket Mode needs nothing exposed and is the right choice
on a laptop:
- Socket Mode. Basic Information → App-Level Tokens → Generate, scope
connections:write; paste thexapp-token into the bot's settings here. No Request URL, no signing secret. - Events API. Paste the signing secret from Basic Information → App
Credentials into the bot's settings first, then set Slack's Request
URL to
https://your-api.example.com/api/v1/slack/BOT_ID/events— the bot id is on its row under Channels.
- Socket Mode. Basic Information → App-Level Tokens → Generate, scope
- App Home → Messages Tab: enable it and tick Allow users to send Slash commands and messages from the messages tab. Without it Slack hides the bot's message box and a direct message is impossible.
- Publish the agent, then bind it: Builder → the agent → Availability → the bot.
- Invite the bot to a channel —
/invite @your-bot— and mention it.
Or paste a manifest and skip steps 2, 4, 6 and 7
App Manifest in the left nav takes the whole configuration at once — scopes, events, Socket Mode and the message tab — which is less error-prone than eleven passes through a scope picker. Create the app from a manifest, or paste this over an existing app's:
display_information:
name: Support Copilot
features:
bot_user:
display_name: Support Copilot
always_online: true
app_home:
messages_tab_enabled: true
messages_tab_read_only_enabled: false
oauth_config:
scopes:
bot:
- chat:write
- files:write
- files:read
- app_mentions:read
- users:read
- channels:read
- groups:read
- channels:history
- groups:history
- im:history
- mpim:history
- im:read
- mpim:read
settings:
event_subscriptions:
bot_events:
- app_mention
- message.channels
- message.groups
- message.im
- message.mpim
socket_mode_enabled: true
token_rotation_enabled: false
For the Events API instead, set socket_mode_enabled: false and add
request_url under event_subscriptions — and read the next warning first,
because Slack validates that URL the moment the manifest is saved.
The signing secret goes in before the Request URL, not after
Slack verifies a new Request URL by posting a signed url_verification
challenge to it, and that challenge takes the same path as every other event.
A bot with no signing secret answers 500 to all of them, so Slack reports
"Your request URL didn't respond with the correct challenge value" — which
reads like a networking problem and is not one.
Do not opt into token rotation
Advanced token security via token rotation, at the top of OAuth &
Permissions, makes the xoxb- token expire. This deployment stores a
static bot token in the vault and does not refresh one, so the bot works and
then stops. Redirect URLs, PKCE, user token scopes, IP ranges and
Enterprise-Managed Authorization on that page are all unused too — leave them
alone.
The Slack app, from one manifest¶
The steps above build the app by hand. The bot's settings here also offer Copy Slack app manifest: in Slack choose Create New App → From a manifest, paste, install, and paste the bot token and signing secret back here. The manifest points every request at this deployment and switches on what makes the bot an app rather than a bot:
- The assistant pane. A new thread in Slack's AI split view is offered three suggested prompts, and the thread says is thinking… while the agent works.
- App Home. The bot's own tab names its agent, says how many of the person's runs wait for an approval, and links to the console and the Architect.
/agent <question>asks the agent where it was typed, and the message shortcut Ask the agent about this asks about a message in its own thread. Both run exactly as a mention does: the same link check, limits and audit.- Buttons on approvals and questions — see Approvals and questions as buttons.
- Unfurls. A console link to one of the organization's agents shows its name.
A bot serves exactly one agent, so there is no agent picker in a direct message; a second agent is a second bot.
The manifest is org-ready, so an Enterprise Grid administrator can install the app once for every workspace in the organization; an ordinary workspace installs it as before. Each answer names the asker's own workspace, which is what Slack needs to stream it under Grid.
Which way the connection runs¶
The bot's settings ask how Slack reaches AgenticOS:
- Slack calls this deployment. Slack sends every message to this deployment's public address, verified with the signing secret. The manifest names the URLs.
- AgenticOS connects to Slack. The deployment opens the connection itself
(Socket Mode) with the app-level
xapp-token, so it needs no public address - the choice for a deployment behind a firewall or on premises. The manifest turns Socket Mode on and names no URL, since nothing would answer at one.
The form asks only for the credential the chosen direction uses.
Answers Slack draws itself¶
Slack's AI apps draw an answer while it is written rather than having it edited into place. The answer streams under the question, and each thing the agent does on the way - a web search, a knowledge lookup, a calculation - is a row that goes from in progress to done or failed. A step that read web pages links them. The bot's settings show the steps as a timeline, or as one plan headed by the question, or turn streaming off, which falls back to a reply edited once a second. Streaming also falls back on its own where Slack refuses to start one.
A Markdown table in the answer is drawn as a Slack table, and a chart arrives as
an image, as it always did. So does a visual, drawn by
the deployment's browser when VISUAL_RENDER_CDP_URL names one. If the finished answer is not what was streamed - an
output guardrail redacted something - the streamed message is rewritten whole.
Scopes and events¶
Fourteen bot token scopes, each earning its place by a call the adapter makes:
| Scope | What needs it |
|---|---|
chat:write |
chat.postMessage, and chat.update for the reply rewritten as it is written |
files:write |
files.upload — a chart or a file the agent sends back |
files:read |
an attachment somebody posted, fetched from url_private_download |
app_mentions:read |
being named in a channel |
channels:history, groups:history, im:history, mpim:history |
the message events, and conversations.history for the channel transcript |
channels:read, groups:read |
conversations.info, .list and .members — the channel lookups an agent may call |
im:read, mpim:read |
conversations.members on a direct message. Easy to leave out and quiet when you do: the membership check fails closed, so a conversation nobody could be confirmed in disappears from the conversation list rather than erroring |
users:read |
users.info, which turns member ids into people |
users:read.email |
users.lookupByEmail, which finds a new member to welcome. A Slack app installed before it was added keeps working without it, and welcomes nobody until it is reinstalled from the new manifest |
Five bot events: app_mention, message.channels, message.groups,
message.im, message.mpim.
message.channels and message.groups deliver every message in every
channel the bot is in, not only the ones naming it — and that is deliberate, so
subscribe to both. What decides whether the bot speaks is the rule below, read
from the event: Slack substitutes <@U0123> for a real mention, so the adapter
knows which messages were addressed to it and stays out of the rest. Keeping the
whole conversation arriving is what an agent deciding for itself whether a
message deserves an answer will need; a subscription narrowed to app_mention
cannot be widened after the fact without every operator editing their Slack app.
| Where | When it answers |
|---|---|
| A direct message | Always. There is nobody else in the room, so requiring a mention would be asking somebody to address the only participant |
| A group direct message | Only when named. Several people share it, so it is a room rather than a conversation with one person |
| A channel | Only when named — @the-bot, or @agent-slug for the agent behind it |
A mention anywhere in the message counts, not only at the start. A handle typed
without letting Slack resolve it stays plain text and is not a mention — the
platform delivered none — and @channel, @all, @here and @everyone address
the room rather than an agent.
A message with a file attached is a message. Slack marks one with
subtype: file_share, and both the file and the caption sent with it reach the
agent — an image with "what do you see?" under it is one turn, not two. What
stays refused is the platform describing the channel rather than somebody
speaking in it: an edit, a deletion, a join, a topic change, and anything the bot
itself posted.
The bot sees what the app was installed with and nothing beyond it. Adding a
scope later means reinstalling the app, which mints a new xoxb- token — paste
that one in too, or the bot keeps the access it had.
A bot that answers nothing: what is reported, and what is not
The connection is. A polling bot whose stream did not open, or keeps failing, carries a Not connected badge on its row under Channels, with the reason on it - the supervisor knows, and until #1351 it wrote that to the container log and nowhere else.
The rest is still silent, and this is the order to check it in: no agent
bound to the bot (the row says so), a missing scope or event subscription,
then a wrong BOT_ID in the Request URL. That last one cannot report
itself: the events route answers 200 and does nothing for a bot id it
cannot find, on purpose, because a prober should learn only that the
endpoint exists - which the URL already said.
Works in channels and in DMs. A message from a linked account runs as that person — never as the bot; one from an account nobody has linked runs under the binding, and only in a channel. A direct message asks for the account first.
A linked account whose member has left the organization, or whose account has been deactivated, is treated as unlinked: refused in a direct message, run under the binding in a channel. Offboarding clears neither the membership row nor the chat account's link, so the role is read only off a membership that can still sign in.
One conversation per thread¶
The unit is the thread, and that now includes a direct message. Send the bot a message and it answers in a thread rooted at yours; that thread is one conversation and keeps its context for as long as people reply in it. Reply inside a thread that already exists and it joins that one instead.
So two people asking different things in the same channel get two conversations, and neither reads the other's context — and one person asking about two different things in a DM gets two, for the same reason.
Continuing a conversation means replying in its thread
A new message typed at the bottom of a chat is a new conversation with no memory of the last one. That is the trade, and it is deliberate: a chat keyed on itself never rolls over, so it walks past the context window in days and every turn pays for the whole history behind it. A thread per question is a context per topic instead of one long transcript somebody has to trim.
A DM used to key on the chat. Conversations from before are not migrated — they stop being reached, and nothing in them is lost.
This was wrong until recently
A mention at the top of a channel used to be keyed on the channel, while the thread the reply opened was keyed on the thread. They were two conversations, so the agent answered a question and then, one message later in the thread it had just created, had no memory of it — and every unrelated mention in that channel piled into one conversation besides.
Now the thread a reply will open is what the first message keys on, so both halves agree. Conversations from before the fix are not migrated; they simply stop being reached, and nothing in them is lost.
Telegram¶
- Create a bot with @BotFather, copy the token.
- Channels → Add channel, platform
telegram. - Register the webhook from the UI, or run polling in development — no public URL needed.
Registering the webhook is what hands Telegram the bot's secret, and a bot with no secret refuses every webhook call rather than trusting it. So a bot switched from polling to webhook mode has to have its webhook registered before it will answer anything: the secret is minted when the mode changes, and Telegram only learns it when the webhook is registered.
The bot sets its command menu - /agents says which agent answers and what it
does, beside /new, /link and /help - shows typing… while it works and
names the step it is on, sends answers as Telegram HTML converted from the
agent's Markdown so code blocks and links survive, and answers a forum group's
topic in that topic.
Mattermost¶
Mattermost is self-hosted, so a bot carries your server's URL as well as its token — there is no api.mattermost.com to fall back to. Registering one without it is refused rather than accepted and discovered later: a bot that does not know its server cannot reply, cannot open its event stream and cannot fetch a file somebody attached.
Two ways in; pick by whether your Mattermost can reach this deployment.
Event stream (nothing exposed). The right choice behind a VPN.
- In Mattermost, Integrations → Bot Accounts → Add Bot Account. Copy the token it shows once — that is the bot token.
- Register it: Channels → Add channel, platform
mattermost, paste the token, and set Server URL to your Mattermost, e.g.https://mattermost.acme.internalorhttp://mattermost:8065inside compose. Leave the webhook token empty. - Add the bot to the team, which Integrations → Bot Accounts does not do:
System Console → User Management → Users, find it, Manage Teams, add the
team — or
mmctl team users add <team> <bot>. Until then a channel refuses to admit it, saying it "is not a part of this team". - Invite the bot to a channel. The deployment opens an authenticated WebSocket
to your server and every
postedevent arrives on it.
Every post, which is the thing to know about this transport: the socket is not a subscription to messages aimed at the bot, it is the channel. So the rule is the one a colleague follows — and it belongs to the bot rather than to one way of reaching it, so the outgoing webhook below obeys the same table:
| Where | When it answers |
|---|---|
| A direct message | Always. There is nobody else in the room, so requiring a mention would be asking somebody to address the only participant |
| A channel | Only when it is named — @the-bot, or @agent-slug for one of the agents exposed on it |
How it knows it was named is the transport's, because the two payloads say different things:
| Transport | What it reads |
|---|---|
| Event stream | Mattermost's own mention list on each posted event, against the account the bot resolves once per session |
| Outgoing webhook | The trigger_word the integration fired on. The body carries no mention list, so @the-bot cannot be read here — set the trigger word to the bot's handle if that is how people should reach it |
An @agent-slug needs neither and works on both: it is read out of the text,
because a slug is a name in this product and so is never in a mention list.
The stream reads the list rather than matching text for the same reason: @ada is
somebody whose display name the bot cannot resolve, and a bot called bot should
not answer the word "robot". A handle that turns out to name neither the bot nor
one of its agents is answered in a direct message and passed over in a channel,
because there it was somebody's colleague.
@channel, @all, @here and @everyone address the room, not an agent.
They have the shape of a slug, and a channel-wide mention puts every member of the
channel — the bot included — in the platform's own mention list, so an announcement
read as a message naming an agent nobody has. Those four handles are never a
mention here, and an agent named after one gets -agent on the end of its handle so
it stays reachable.
If the bot's own account cannot be resolved, the stream answers everything, as it did before this rule existed: going quiet on a server that would not say who we are is the worse of the two failures. The webhook has no such fallback and does not need one — an integration with no trigger word is a channel filter, and a channel filter says nothing about who a post was for.
Outgoing webhook. For a Mattermost that can reach this API.
- Create the bot account and register it exactly as above.
- System Console → Integrations → Outgoing Webhooks → Add, with the callback
URL
https://your-api.example.com/api/v1/mattermost/BOT_ID/webhook— the bot id is on the row once it is registered, andchannel-webhook-registerprints the whole URL. Its trigger words are what addresses the bot on this transport, per the table above; leave them empty and only an@agent-slugreaches it in a channel. - Mattermost shows a token when the webhook is saved. Paste that into the bot's Webhook token field here.
The token is the one thing people get wrong twice, so it is worth being exact: Mattermost generates it, and you paste it into AgenticOS — the opposite direction from Telegram, where this deployment generates the secret and hands it over when the webhook is registered. Nothing is generated locally for Mattermost, because a locally generated value is one Mattermost will never send.
Mattermost does not sign webhook bodies the way Slack does — the token in the payload is the whole check — so a bot with no webhook token refuses every call rather than trusting it. The bot's row says so with a badge.
Either way can be done from the command line, which is the only way on a deployment with no browser pointed at it:
uv run agenticos cmd channel-add-bot \
--platform mattermost --name "Ops" --token <bot-token> \
--org <organization-id> \
--api-base-url https://mattermost.acme.internal \
--webhook-secret <token-from-mattermost> # omit for the event stream
uv run agenticos cmd channel-test-message --bot-id <uuid> --chat-id <channel-id>
What a server URL may be. Scheme and shape are checked — http or https, a
host, no user:pass@ — and a private or loopback address is deliberately
allowed, because a self-hosted Mattermost behind a VPN is the deployment this
exists for. Instance-metadata addresses are the exception and are refused. The
boundary that actually holds is the permission to manage channel bots, not this
check.
Approvals and questions arrive as buttons. Mattermost calls each button back at
https://your-api.example.com/api/v1/mattermost/BOT_ID/actions, so the
Mattermost server has to reach the API; on a private network, add the API's host
to System Console → Developer → Allow untrusted internal connections to.
/agent in Mattermost¶
A slash command asks the agent where it was typed, as Slack's /agent does. In
Mattermost, Integrations → Slash Commands → Add Slash Command: trigger word
agent, method POST, and the request URL the bot's settings show. Mattermost
then shows a token; paste it into the bot's Slash command token. Every
request carries it, and a bot without one refuses them all.
Microsoft Teams¶
A Teams bot is an Azure Bot resource, created single-tenant in your Entra tenant — Microsoft stopped creating multi-tenant bot resources in July 2025 — and reached over the Bot Connector's own protocol rather than an SDK (#1985).
- In the Azure portal, create an Azure Bot, type Single Tenant. Under Configuration, copy the Microsoft App ID and the tenant, and under the app registration's Certificates & secrets, create a client secret.
- Register it: Channels → Add channel, platform
teams, with the secret as the token and the app and tenant IDs beside it. - Paste the messaging endpoint the bot's settings show into the Azure Bot's configuration, and turn on its Microsoft Teams channel.
- Download the app package from the bot's settings — the manifest and its two icons, built for this bot — and upload it to your tenant's Teams app catalog. Add the app to a personal chat, a group chat or a team.
Every activity is verified before it is read. The Connector signs each one with a JWT: its signature is checked against the keys the issuer publishes, the issuer must be the Bot Framework or your own tenant, the audience must be this bot's app ID, it must not have expired, and the service it names must be the activity's own. A request that fails any of these is a 401. The bot is single-tenant, so an activity from another tenant is refused as well, and the bot's token is only ever sent to a Bot Connector address.
| Where | When it answers |
|---|---|
| A personal chat | Every message. The chat is one conversation |
| A group chat | When it is @mentioned |
| A channel | When it is @mentioned, in the thread it was asked in — each thread its own conversation |
Who is speaking is the member's Entra object ID, which is the same in every
chat of the tenant. Where this deployment signs people in with Entra in the same
tenant, an unlinked Teams account is linked to the member who signs in with the
address Teams' roster gives it, with no link to click; anywhere else the link
flow applies as on every platform, and require_link decides as it does there.
Approvals and questions are Adaptive Cards. A press is decided as the member who pressed it, and the card is replaced with what was chosen. A file the agent sends reaches a personal chat through Teams' consent card; in a group chat or a channel the answer names it instead, because Teams does not take a file from a bot there. A chart is drawn into the message.
A trigger can post its answer into Teams. Each conversation the bot is in is remembered, and a trigger can name one of the organization's team channels or group chats: every fire's answer is then posted there as a new message, under the trigger's name. A personal chat is never offered — it is somebody's own.
Discord¶
A Discord bot is an application in the Discord Developer Portal, spoken to over Discord's own Gateway and REST API with no library between (#2079). Messages, slash commands and button presses all arrive over the bot's one Gateway connection, so a deployment needs no public address for Discord, and a Discord bot has no webhook mode.
- In the Developer Portal, create an application. Under Bot, reset and copy the token, and switch on Message Content Intent. Without it Discord refuses the connection, and the bot's row says which switch to turn on. Under Installation, set Install Link to None, then switch off Public Bot under Bot. Anyone can add a public bot to their own server, so the bot refuses to connect while it is public. One made public while connected stops the next time it is added to a server.
- Under OAuth2 → URL Generator, tick the
botandapplications.commandsscopes and the permissions View Channels, Send Messages, Send Messages in Threads, Create Public Threads, Read Message History and Attach Files, then open the URL to add the bot to a server. Without Attach Files a file or chart the agent sends is refused, and the text posted with it is lost too. - Register it: Channels → Add channel, platform
discord, with the token.
Approve servers explicitly. In the add/edit bot form, enter Approved Discord server IDs. Enable Discord's Developer Mode, copy each server's ID and separate IDs with commas or spaces. For the CLI, pass --discord-guild 123456789012345678 to channel-add-bot; repeat the option for several servers. An empty list permits DMs only. Installations announced by READY are not approvals, and removal/re-add cannot bypass the list.
/ask acknowledges privately before channel lookups or database admission. Only an admitted sender's question is posted publicly; refusals and account-link invitations stay private. Link invitations consume the sender's rate limit. Server approval chooses locations; jwt_linked or require_link still controls whether an organization account is required. Console access to a stored transcript requires both View Channel and Read Message History, plus membership for a private thread.
The bot registers its commands when it connects: /ask, /agents, /new,
/link, /unlink and /help, and Ask the agent under Apps on any message.
Discord can take a while to show new commands in every server.
| Where | When it answers |
|---|---|
| A direct message | Every message. The direct message is one conversation, and /new starts it over |
| A server channel | When it is @mentioned or a message replies to it. The answer opens a thread on the question, and the thread is the conversation |
| A thread the bot opened | Every message in it |
/ask |
Anywhere: the question, up to 2,000 characters, is posted and the answer is threaded under it |
| Apps → Ask the agent | On any message: the agent is asked about that message, in a thread on it |
/agents, /link and the other commands answer privately, to whoever ran them.
/new starts a thread's conversation over; in a server channel, where every
mention already starts a new one, it says so instead.
In a channel where the bot may not open threads, the answer is a reply to the
question instead, and replying to the answer continues that conversation.
The answer is written in place. The bot posts one message and edits it as the
answer streams, running past 2,000 characters into further messages. Only text the
guardrails have already screened is written, so a redacted secret is never shown,
even for a moment. Nothing the bot posts can ping anybody: @everyone and role
mentions in an answer stay plain text.
Approvals and questions are buttons, or selects for a question with more than 25 options. Thumbs go under every answer, and a thumbs-down opens a Discord form asking what was wrong. Discord has no reaction on the question.
Who is speaking is the Discord user ID, linked to a member with /link as on
every platform. A conversation from a server channel is shown in the console to
those who can still see that channel, by the server's roles and the channel's
permission overwrites, which is what being in a channel means on Discord. A
private thread is invite-only, so it is a room of its own: a conversation from
it is shown only to those still in that thread, and the notes the agent keeps
there are not shared with the channel.
A thread the bot is brought into partway is read first. The last messages in
it, with who wrote them and when, and the files people posted there, reach the
agent before it answers, as on Slack and Mattermost; files the bot posted itself
are left out. It needs Read Message History, and read_channel_history reads
a thread or a channel the same way where the binding allows it.
A server's roles as groups¶
An administrator can map a role in one of the bot's servers to a group of the
organization (#2569). Open Channels, then the Discord roles action on the bot's
row. The server and the role are chosen from what the bot itself reads from Discord,
so @everyone and the roles Discord manages for a bot are not offered. Mapping one
takes members:manage, and the routes take channels:manage as well.
A linked member who holds the role is placed in the group before each message they
send to any of the organization's Discord bots, and again by the sync-discord-roles job
every 15 minutes. They leave the group when the role is taken away, and the job is
what notices that for somebody who has stopped writing to the bot. The bot asks
Discord for one member's roles at a time, so it needs no privileged intent, and a
new mapping places people at their next message rather than at once.
Those checks before a message count against the sender's rate_limit_rpm on an
allowance of their own, commands such as /help included. A sender past it is
asked to slow down, and Discord is not asked again until the minute is over.
A mapping can also name an organization role. A linked holder who is not a member
yet then joins the organization with that role, which spares inviting everybody in
a community server one at a time. Naming a role takes roles:manage, and only a
role the caller's own strictly outranks. The order between several matching roles,
and the rules about what the sync never touches, are the ones the
directory sync follows:
- Only a linked account counts. A Discord user who never ran
/linkis placed nowhere, whatever roles they hold. - A role matches only in the server the mapping names, read by the bot that holds the mapping. The same role ID in another bot's server matches nothing.
- The sync changes only rows it made, marked Discord on the members page and in a group. A group membership added by hand stays when the role is taken away, a membership an administrator made keeps its role, and an owner is never demoted or removed. Somebody the sync admitted to the organization leaves all of its groups when the sync removes them, as somebody removed by hand does.
- If a server cannot be read, its mapped groups remain. Its possible organization roles can retain only authority the person already held; an unread
viewermapping cannot preserveadmin. Confirmed grants still apply.
Each reconciliation shares a ten-second network deadline. REST lookups share the bot’s rate_limit_rpm per linked user across all their accounts, separately from the per-user message-check allowance. Removing a mapped group also requires channels:manage.
Server discovery permits one concurrent request per organization, shares a ten-second deadline across pagination and role reads, and reads roles for at most eight servers concurrently. Discovery stops above the supported 2,500-server limit; an incomplete response fails explicitly.
Unlinking the Discord account takes back what its roles gave, unless another linked
Discord account still earns it. Deleting a mapping takes back what it gave at each
person's next message or the next job run. Deleting the bot or a mapped group, or
giving the bot a new token, takes back the same, so it needs what deleting each of
those mappings needs. Every change is in the audit log as
discord.member_added, discord.role_changed, discord.member_removed,
discord.group_joined or discord.group_left, and making or deleting a mapping
as discord.mapping_created or discord.mapping_deleted.
Not yet: one connection serves a bot in at most 2,500 servers, past which Discord asks for sharding and the bot stops with that reason. The channel members, details and search tools are not offered on Discord.
Directory access takes priority when both sources grant membership. At sign-in, a directory grant takes over the Discord-managed row; when it ends, a remaining Discord grant takes over without revoking credentials. See membership handover.
A bot that cannot start stops, rather than retrying¶
Telegram polling, Slack Socket Mode, the Mattermost event stream and the Discord
Gateway all run under one supervisor that reconnects a dropped session. A missing or rejected
configuration value is not a dropped session, and the supervisor treats it
differently: it records the bot as down with the reason, logs once and stops.
Nothing it does would change the row — an operator has to add the Slack xapp-
token or the Mattermost server URL, or replace a bot token Telegram rejects.
This matters more than it sounds. Retrying a start that fails immediately never suspends, so the supervisor spins without yielding and every other task on the process — requests, health checks, chat WebSockets — stops being scheduled. The API stays up and answers nothing. Both trigger states are ordinary rows somebody has not filled in yet, so the failure was one restart away at any time.
If a bot is silent, check the log for not started before assuming a network
problem.
A dropped session is different: that one is retried, waiting five seconds and doubling to a minute, so a server down for an hour is not hammered 720 times by every bot on it. A session that ended cleanly starts the ladder over. The line logged before each wait names the delay it is about to wait, and the same loop serves every one of these platforms, so the policy cannot drift between them.
Approvals and questions as buttons¶
A run started in Slack, Telegram, Mattermost, Teams or Discord can stop for a person: a gated
tool call waits for a decision, or the agent asks an ask_user question. The
chat is offered the choices where it is — a message per approval with
Approve and Reject, a message per question with its options and Skip.
Pressing one decides or answers, the run continues, and its answer is posted in
the same chat; the pressed message keeps what was asked and shows what was
chosen.
A question that takes several answers stays open: each press adds or removes an option, the chat is told what is picked so far, and Done sends them. The run continues once everything it stopped for is settled - every approval decided, every question it asked answered, however many messages they took - and the last answer sends them all together.
A question that takes an answer in the person's own words — and one with no options at all — says so under it: their next message in that chat is the answer, rather than a new turn beside a run still waiting. Only a message from the person it was put to counts, and the oldest such question takes it.
A question can be put only to a linked member. A sender with no linked account runs as the binding's publisher, so a question the agent asks them is declined and the turn goes on without the answer, rather than waiting on buttons only the publisher could press.
An approval reads as the agent asking, in its own voice: what it wants to do and the one argument that says what it is about — I need your approval to search in Notion: “what's new in releases”. A tool from an MCP server is named by its service and action rather than by its prefixed id, and the rest of the arguments stay in the console's Approvals, where the same request opens with that one line and folds the others behind Details.
A press acts as the person who pressed it, through their linked account: deciding
needs approvals:decide, and only the person a question was put to can answer
it. A decision made somewhere else acts as the person who made it, an app admin
included (below). An unlinked presser is told to send /link. A run waits for an answer for
QUESTION_EXPIRY_HOURS and for a decision for APPROVAL_EXPIRY_HOURS, as
anywhere else. Slack signs its presses; Mattermost signs nothing, so each button
carries this deployment's own signature over its value and a press without it is
refused.
Who pressed is not taken from the press's body either: the trigger id the Mattermost server signs for each press names the person, and it is checked against the public key the server publishes, so a press nobody can prove is refused too.
A browse that needs a person. An agent with the web browser posts the link to the browse's live view in its step - Watch or take over - in Slack, Telegram and Mattermost alike. When the page needs somebody, a sign-in or a captcha, the step changes to Needs you and says what for, and names who has the browser while a person holds it. The link opens the console, so taking over needs a console account: the person the run is for, or someone who may edit the agent.
Decided outside the chat¶
A request put in a chat can also be decided in the console's Approvals, in the web chat or over the HTTP API. When a person does that and it settles the last thing the run waits for, the server continues the run as them and posts the answer in the chat it was asked in. The console says so instead of resuming the run itself, so the run is continued once. A question works the same way when the person it was put to answers it in the console.
A decision made with an API key is recorded and does not continue the run: a run
a person started is continued only by a person. Over the API, continues_in_chat
tells a client which case it is in (see Running an agent).
Three endings that happen outside the chat each post one fixed message there, so nobody is left waiting on buttons that no longer do anything. The chat's open buttons are settled at the same time.
| Ending | The chat is told |
|---|---|
The run expires, after APPROVAL_EXPIRY_HOURS for a decision or QUESTION_EXPIRY_HOURS for a question |
"This request expired without a decision." |
| Someone presses Stop in the console | "This run was stopped from the console." |
| A run was decided and nothing continued it within about an hour | "A decision was recorded, but the run could not continue." |
The last one is a safety net. It stays quiet in two cases: when the decision is more than 7 days old, and when the chat was already told that the run could not continue. A chat hears at most one notice each time a run parks.
A button pressed after the request was answered elsewhere replies "That has already been answered." and changes nothing.
A continuation can be refused: for example when the chat started over since the question, its access policy now refuses the person who asked, the person who decided can no longer decide approvals, the bot was removed from the agent, or the agent can no longer be built. The chat is told once: "That was decided outside the chat, but the run could not continue." Resume on the run's page still works, within about an hour of the last decision. It runs as before, without the room check, and posts the answer only in a chat that still admits the asker. A membership check the platform cannot answer does not stop a continuation.
A run parked before 1.29.0 has no chat recorded, so the console continues it as it always did. If nobody resumes it within about an hour of its last decision, the safety net ends it, without a message.
The worker sends required replies and ending notices from durable jobs saved in
the same transaction as the result or ending. A restart or transient send failure
retries delivery up to three times without repeating the agent or approved tools.
The bot, chat session and access policy are checked again before sending. A
revoked destination receives nothing. Platforms without send idempotency can
show a duplicate if a send succeeded but its acknowledgement was lost; this is
at-least-once delivery, not a promise of exactly-once messages. Exhausted attempts
are visible in Admin → Background jobs (deliver-channel-handback).
Next steps as buttons¶
An answer from an agent with Follow-ups that offered next steps is followed by a message of buttons, one per step - Make it a table, Do this every Monday. A press is the presser saying that step: it is answered as their next message in that chat, held to everything a typed one is - the link, the bot's access policy, the rate limit - and the pressed message then reads the step instead of its buttons.
Each message is pressed once; anyone in the room may press it, and the answer is theirs. An answer that stopped for an approval or a question offers those instead. The setup cards an agent can propose are drawn in the console only.
A welcome for a new member¶
When somebody joins the organization - by an invitation, by joining the default
organization, or through directory sync - each active Slack, Mattermost and
Teams bot of the organization looks them up by their email address and, where it
finds them, sends one direct message in their console's language: who it is,
that they can ask it right there, and the console's address. It is a background
job tried once; a bot that cannot find them, or a platform that refuses, costs
nothing else. Slack needs the
users:read.email scope for the lookup; Teams finds them in the tenant's roster.
Reactions and ratings¶
Two touches every chat platform here shares, both switched in the bot's settings:
- A reaction on the question. The bot reacts the moment a question it will
answer arrives, with an emoji named in its settings -
eyes, say - so the asker sees it was heard. Slack and Mattermost take any emoji's name; Telegram allows a fixed set, and the names it accepts areeyes,+1,ok_hand,fire,thinking_face,writing_hand,zapandhourglass. - Thumbs under an answer. A finished answer carries a thumbs-up and a thumbs-down. A press is the presser's rating of that run's answer, the same rating the console records, and pressing again changes it. A thumbs-down then asks what was wrong? in a Slack modal or a Mattermost dialog; the reply is kept as the rating's comment. Telegram takes the thumbs without the question.
Mattermost does not require an AgenticOS account to rate. The server-signed
participant identity owns the vote. Each participant can change their vote;
buttons stay available to everybody, and confirmation is private. A thumbs-down
comment belongs to the same participant. Slack and Telegram retain their
linked-member requirement; approving a tool call still requires membership.
Channel-authored ratings have a null user_id in API responses, rather than
being attributed to the bot owner. Upgrade the database before enabling this
version. A downgrade removes channel-authored ratings and keeps account votes.
The dashboard's Answer quality card includes these votes and splits them by surface, so a difference between the console and Mattermost remains visible.
What every channel shares¶
-
A message sent while the agent works joins its task. From the person whose turn it is, a plain message in the same chat reaches the running turn at its next step instead of waiting for a turn of its own; one that arrives after the agent's last step is answered as the next message.
/btwfollowed by a question asks on the side: answered at once from the thread so far, without tools, while the turn keeps going. A colleague's message in a group chat is always a turn of its own. -
The model is reminded of the most recent turns, not the first ones. A channel thread is keyed to the chat and never rolls over, so a support channel passes the window in days. Two hundred turns for a channel against forty for a widget, and the two numbers are different on purpose: a widget is a public URL with somebody else's budget behind it, and a channel is a room the operator's own colleagues work in. Bounded either way, because a prompt is not a transcript and one thread's whole history is a per-turn bill that grows for ever.
It read from the wrong end until #638: the repository orders oldest-first, so the bot was told how the conversation opened and nothing said since — and it answered plausibly, from a version of the thread that had stopped hundreds of turns ago. Nothing errored, which is why it needed a test rather than a fix.
The offset is
conversation_repo.get_recent_messagesnow, and every surface reads the window through it — the widget, the channels and web chat. Three copies of oneCOUNTand one offset is how it came to be wrong twice, from opposite ends. -
A thread it is brought into partway through is read first. A conversation here is built from what this deployment received, so a bot mentioned in a thread that was already running held nothing above the mention — and answered as though the thread were empty, which it said confidently. Nobody watching a chat can tell an agent that cannot see from one that has read and disagreed.
Once per conversation — recorded on the session, not inferred from its age, so a thread the bot was answering in before this existed is read on its next turn rather than never — up to fifty messages, and only where the platform has threads. After that the transcript is ours and nothing is fetched again. It arrives as one labelled block — what was said before you arrived,
speaker: message, context rather than instructions — not as turns, because replaying other people's messages as an alternating history puts words in the agent's mouth it never said.This is not gated, where
read_channel_historyis, and the difference is the point: that tool reads the channel, which is content the agent was never addressed in, and an operator decides per binding whether it may. The thread the bot was spoken to in is the conversation somebody pointed it at, and reading that is not the same act as reading the room. The bot still sees only what its own membership allows, because the call goes through its token.The files posted in it come too, up to four of them: the transcript alone let the bot answer, accurately, that it could see no image in a conversation whose first message was a screenshot.
They are fetched down the same path an attachment on the live message takes, so one set of size limits applies, and they are attributed to whoever mentioned the bot — that is the person who pointed it at the thread, and an unlinked sender gets none, exactly as they get none of their own. Four rather than fifty, because fifty downloads before an answer starts is a minute of silence. Slack reads them with
conversations.replies, Mattermost withGET /posts/{root}/thread; Telegram has no threads and reads neither.A read that fails costs the context and never the answer: no thread, a platform without them, a refusal or an error all produce an answer without the history above it, which is worse than one with and better than none.
-
One bot answers as one agent. A bot user is a single identity in the chat: the same avatar, the same name, whichever agent produced the reply. So a bot serves exactly one agent, and binding a second is refused — in the Builder's picker, which does not offer a bot somebody else's agent is already on, and in the database, which is what makes it true.
An agent goes the other way freely: one agent can answer on a Slack bot, a Telegram bot and two Mattermost servers at once, and each of those bindings carries its own instructions, its own channel lookups and its own workspace scope.
This replaced routing several agents behind one bot with
@slug. It worked and it read badly: somebody in a channel had to type a handle to pick between agents they could not see, and a message that named none was answered with a list of handles instead of an answer. A second bot costs an operator two minutes and makes the chat say which agent it is talking to, which no amount of routing can.@slugstill parses — as an alias for the agent behind this bot, refused when it names any other. - A voice message is transcribed, unless the bot is switched off. It is the one attachment an agent cannot be handed as a file: it reads a PDF and looks at a screenshot, and anaudio/oggblob is a byte count.The recording is fetched, transcribed, and woven into the same turn as a labelled quotation —
[Voice message, transcribed]— rather than sent as a second message. Labelled on purpose: speech recognition mishears names, numbers and anything said over traffic, so an agent told the source can hedge a figure it half-heard and ask, where one told nothing states it as fact. It is also what keeps a recording a quotation rather than instructions somebody typed. A voice note with a caption stays one message, because that is what somebody sent.Each bot's form under Channels offers three answers. Inherit, the default, uses the provider and model of the agent the bot serves, then the organization's, and the language of the agent, then the organization's (the order). Choose names the bot's own pair - a provider and one of its models, from
app/core/catalog/speech_to_text_models.json- and the vault key it is sent with. Off never transcribes, whatever the agent or the organization say. Inherit beside the choice names what it resolves to, which of the two chose it, and whether that level still needs a key.The bot's own pair needs its own key: an API key whose purpose is the provider, and one visible to the whole organization, because a bot's voice notes are paid for on behalf of whoever writes to it. A member's private key is refused when the bot is saved, and one made private later stops being used. The picker offers only keys that fit, says when a provider has none, and links to the vault to add one. An inheriting bot takes the inherited level's key together with its pair, never a key of its own. Deleting the key leaves the pair asking for another, and the bot transcribes nothing until somebody chooses one.
Inheriting costs nothing until somebody sets a provider up: with none on the agent or the organization, nothing is transcribed, so spending the organization's provider credit stays the organization's decision. A bot registered from the CLI, or by a client that sends no switch, inherits.
A voice note is held to the organization's longest dictation before any provider is paid: its length is read off the file where the container says (Ogg, WebM, MP4, WAV, MP3), else as the platform reported it. A longer note is refused with a line saying so, and so is one whose length neither states - a Mattermost MP3 with more than a tag after its last frame, say. Bytes alone do not bound it - 25 MB of low-bitrate audio is hours - and the sender's limit counts messages, not minutes. Its usage records the provider's length, else the one measured here.
A bot that cannot listen says so rather than dropping the recording, because a voice note that produces no reaction is indistinguishable from a broken bot - and it says which reason, because different people fix them: the bot is switched off, or nobody has set up a provider. A bot with no agent bound, or whose agent is archived or has no version to run, transcribes nothing at all, since the message is refused anyway.
Every recording sent to the provider leaves a usage record naming the bot, its agent and the person the sender is linked to - while that person is still a member of the organization - with the recording's length where Telegram, Slack or the provider states it.
Upgrading: a bot that did not transcribe still does not
Before this switch existed, a bot with no pair was a bot that did not transcribe, and the API cleared the pair to turn transcription off. Migration
0151_bot_transcription_switch.pyswitches every such bot off, so none of them starts sending audio to a provider on upgrade; a bot with a pair keeps it. From then on a client that clears the pair gets inherit, and off istranscribe_voice_notes: false, which also clears the pair. The organization's or the agent's language now reaches a bot with its own pair too.A bot with a pair used to transcribe on the first model profile, by label, holding a key for its provider. The same migration writes that key down as the bot's own, where it is an organization-visible API key for the provider and the profile has no
base_urlof its own. A bot with no such profile keeps its pair with no key: its form says "choose a key", and it transcribes nothing until somebody does.What counts as a voice message is read off the file's type, and each platform types its files differently. A Telegram voice note or audio file always counts. On Slack, an audio clip recorded in the composer counts whatever type Slack gives it - a clip from the phone app can arrive as
video/mp4- and so does any uploaded audio file. A file the platform left untyped counts when its extension names audio:.m4a,.mp3,.ogg,.oga,.opus,.webmor.wav. An untyped.mp4that Slack did not mark as a clip stays an ordinary file: it is far more often a video, and transcribing it would bill a soundtrack.On Mattermost any audio upload counts, by its type or its extension. An outgoing webhook sends only each file's id, so for a bot in webhook mode the router first looks the file up with
GET /api/v4/files/{id}/info; a lookup that fails leaves it an ordinary attachment. A Slack clip that arrives before Slack has finished processing it has no download URL yet and is skipped, since the bot does not subscribe to thefile_sharedevent that follows. The backend logs both, and logs every recording it routes to transcription with its type and file name - the line to read when one is not heard.The provider is sent the recording as
voice.<ext>, named from its type rather than from what somebody called the file: OpenAI and Groq choose the decoder from the name, and refuse.opusor a name with no extension for audio they can read. A Telegram voice note is Ogg/Opus and a clip recorded in Slack's desktop app is WebM, and Mistral lists neither as a format it accepts, so a Telegram or Slack bot reaching Mistral - chosen or inherited - may fail to transcribe; the form says so.Voice messages posted in a thread before the bot joined it are not transcribed. The first mention reads the thread's earlier files, and transcribing every recording among them would bill a whole thread's audio for one question. Each one is named in the reply instead: "Voice messages sent before I joined this thread are not transcribed - send it again here."
Three providers ship: OpenAI, Groq and Mistral, all serving OpenAI's
POST /audio/transcriptions. Adding a model is an entry in that file. Every failure — no credential, a recording over the endpoint's limit, a refusal, a timeout — is reported on the reply and the turn goes ahead without it. -
Access policy per bot — open, whitelist, group-only, or
jwt_linked: "must be linked to a member", in a channel as much as in a direct message, with no second setting to flip. -
A credential can be added or replaced after registration. The pencil on a bot's row opens it: rename it, paste a rotated token, or supply the credential that was not in hand when it was registered — which is the ordinary case on Slack, whose
xapp-token is generated on a different screen minutes later.Every credential here is sealed at rest and never read back, so each field starts empty and an empty field means keep what is stored rather than clear it. Only what somebody typed is sent. What the dialog does not offer is the platform, because it decides which credentials the row carries and how messages reach it, and the transport, because switching to webhook mode is only half a move — the webhook still has to be registered with the platform, and a bot that reports
webhookwithout one answers nothing.channel-webhook-registerdoes both halves. - Linking, and where it is required — every run belongs to somebody: the budget it spends, what it may read and the audit entry it writes are all attributed. Where that somebody comes from depends on whether the bot is being spoken to privately or standing in a room.A direct message asks for an account. It is a conversation with one person, so an unlinked chat account is refused until it names one.
A channel answers anybody in it. Whoever could invite the bot chose the audience, so a sender with no linked account is not refused: the turn runs under the binding that admitted it — the role of whoever bound the agent to this bot, dropping to
viewerif they have since left the organization — and the chat account that typed it is recorded on the run. What that widens is real and worth saying: anyone who can speak in the channel can spend the organization's budget and reach what the binding's creator can reach, which is the same trade a public widget makes. The ceilings are the rate limit per chat account, the access policy, and the organization's monthly cap. The rate limit (rate_limit_rpmon the bot's access policy, ten a minute by default) is counted in the deployment's shared Redis, so it holds across API workers rather than once per worker.Set
require_linkon the bot's access policy to refuse in channels too, which is the old behaviour. Thejwt_linkedmode refuses on its own: a mode named for a linked account asks for one, and it used to decide nothing unlessrequire_linkwas also set.Linking still matters in a channel, and it is worth doing: a linked sender runs as themselves rather than under the binding, and linking later makes their earlier channel turns attributable to them — the run points at the chat account, and the chat account gains a person. Only while that person is a member who can sign in: a linked sender who has left, or whose account was deactivated, is admitted the way a stranger is — under the binding in a channel, refused in a direct message.
It is also what lets an agent reach their tools. A binding to each person's own account speaks to Notion or Jira as whoever wrote the message, in a channel as much as in a direct message — and an unlinked sender has no account to speak through, so the agent tells them to
/linkfirst.A channel thread is one conversation with several people in it, and it appears in the conversation list of everybody whose linked chat account has written in it — not only whoever spoke first, and not nobody, which is what a thread with no linked speaker used to reach. Each turn records the account that wrote it, so a room reads as a room rather than as one person talking to themselves. Linking afterwards is what puts the earlier thread in front of somebody, with no backfill: the turn points at the chat account and the account gains a person.
The
/chatlist leaves channel threads out by default.GET /conversationstakeshide_channels, true by default, so the sidebar does not fill with threads a bot answers in; they still open from their link, andhide_channels=falselists them again.Speaking is a claim; the platform decides whether it still holds. The turn record says who spoke, and before #641 that was the whole check — somebody removed from the channel kept reading the thread, including everything said after they left. Now every participation claim is confirmed against the platform's current membership (
getChatMemberon Telegram,conversations.memberson Slack, the per-user member lookup on Mattermost) before the listing shows the thread and before it opens, behind a shared Redis cache of about a minute. The check fails closed: a platform that cannot answer, a bot that is gone, and a thread whose channel nothing names any more —/newre-points the session at a fresh conversation — all refuse participation rather than trust the claim. The thread's owner and anybody it was explicitly shared with keep their access regardless; the membership check gates participation and nothing else.And it opens a thread rather than owning one. Speaking in a room admits you to reading it; renaming it, archiving it, deleting it or appending a turn to it stays with the thread's owner and anybody it was explicitly shared with. Otherwise one person who said "thanks" in a channel could delete the room's whole transcript, or write a turn as the agent that everybody reads and the model is handed back as its own words on the next turn.
A thread whose first speaker never linked an account has no owner, and there the participants are who may change it — the same set that may open it. There is nobody the write would be taken from, and the alternative was the whole organization: any member could delete a transcript whose list entry they had never seen (#701). The write leans on the same confirmed participation as the read: a claim the platform no longer backs carries neither (#641).
The refusal carries the way out. Message the bot and it answers with a URL; open it, and the dashboard — where you are already signed in — names the chat account and asks you to confirm. Nothing is typed and no code is copied. Ask again any time by sending the bot
link(or/linkwhere the platform delivers a slash; Mattermost does not).What is connected, and disconnecting it, is under Settings → Profile → Chat accounts. Disconnecting clears the owner and keeps the row, so the conversations that hang off it survive - the person is still messaging the bot from the same account afterwards.
Only in a direct message. The URL is a bearer credential: whoever opens it claims that chat account. In a channel the bot says to message it directly instead, and mints nothing. A link lasts fifteen minutes, is good once, and asking again retires the one before it. - A reply you can watch being written. The bot posts a message the moment your question arrives and rewrites it as the answer appears — including what it is doing meanwhile ("Searching the web…", "Drawing a chart…"), which is when the silence used to be longest, because a tool call produces no text while it runs. Edited about once a second: per token would be hundreds of writes a second against a server that is often somebody's own. A platform that cannot edit a sent message simply gets the finished answer, as before. An agent with an output guardrail has its reply rewritten one step at a time instead, and each step is posted only after it has been checked. - Every binding carries its own extra instructions, added to the agent's on that surface alone. Current formatting defaults come from the deployment: Slack writes links as
<url|text>, Mattermost renders headings and tables, and Telegram rejects messages with unclosed formatting. A new binding stores no copy of those defaults. Add, edit or clear custom instructions without freezing the platform guidance at the version installed that day. This shapes delivery; the agent's purpose belongs to its published version. - A bot answers as soon as it is registered. A polling bot - Telegram long-polling, Slack Socket Mode, a Mattermost event stream - is reached over a connection the API process holds, and that connection is opened when the row is written rather than at the next restart. Pausing, deleting, changing the token or the server address, and switching between polling and webhooks all take effect immediately, for the same reason: the stream is reopened to match whatever the row now says. It is opened after the transaction commits, so a registration that fails leaves no connection behind. - One connection per bot, however many API workers. Telegram allows onegetUpdatesper bot and two Mattermost sockets would each receive every event, so the worker holding a bot's connection holds a lease on it in the shared Redis and renews it every 10 seconds. The others leave that bot alone. A worker that shuts down hands its bots to another within about 10 seconds, and one that dies within about 40. An edit served by another worker opens the new connection at once, and the old one closes at its holder's next renewal. With Redis unreachable, every worker connects, as before. - A second Telegram poller is noticed, not fought. When Telegram ends a bot's poll because another process polls the same token, a worker that does not hold the bot's lease closes its copy at once. The holder keeps polling and marks the bot down on the channels page: "another process is polling this bot's token". That process is outside the deployment, usually a second deployment or a script with the same token, and only its owner can stop it. - Channel context is supplied automatically on every run. Mattermost, Slack, Teams and Telegram runs receive the channel's name, purpose, topic, visibility, reported member count and a bounded visible roster, including on existing or cleared binding prompts.{channel_members}is the readable name for the roster;{member_list}remains an alias. The other placeholders are{channel_name},{channel_purpose},{channel_topic},{channel_visibility}and{member_count}.Context is resolved once per source per run, not cached between turns: at most 50 visible members and 2000 characters per value. Telegram returns administrators, not the whole membership. Each source has a five-second timeout; unavailable values stay unknown. Values are flattened and delivered as untrusted conversation data, outside system instructions. Slack supplies native member IDs without per-person profile requests. The binding's editable text adds guidance alongside this runtime context.
-
Choose whether group mentions start a run. In the bot's create or edit dialog, Respond to group mentions is off by default. Direct invocations continue to work. Enabling it also admits Mattermost
@all,@hereand@channel, Slack's native room mentions, and structured Teams channel/team mentions. Sender identity, access checks and rate limits still apply. Quoted code and unrelated users' mentions do not count.Teams requires the updated app package to be installed or upgraded in the target team or chat, with owner consent to
ChannelMessage.Read.GroupandChatMessage.Read.Chat. Those grants are requested only when this switch is on. Telegram uses explicit text aliases@all,@here,@channeland@everyone; these do not notify the group. Telegram must deliver group messages to the bot, so check BotFather privacy mode. Own mentions, commands and replies are resolved against the bot's actual Telegram identity. Discord counts@everyoneand@hereonly when the sender may ping them, which is when Discord notifies the room. -
Requested workspace-file delivery also requires approval. Granting
send_channel_filemakes it available; a request still waits for explicit confirmation before bytes are posted. - Linked senders' MCP reads use the binding's approval policy; writes still require approval. Read-only tools do not ask solely because a run is in a chat app. Tools without a
readOnlyHint: truestill ask, and an explicitallpolicy includes reads. This applies to personal, organization and platform connections, delegates and resumed calls. Personal connections still belong to the current sender, never the publisher or another participant.
Channel messages store only the sender’s text. Author labels are resolved from channel identities immediately before model dispatch, outside system instructions. The console and copy, pin and shortcut actions receive the plain body. Persisted histories and paused runs keep opaque identity references, and manifests omit the injected channel reference parts. Deleting an account removes its channel identities, so future requests cannot resolve that person’s platform labels; shared message bodies remain.
Editable channel names, topics, purposes and author labels pass the configured input checks before reaching the model. Redacted values replace the originals; blocked reference values are withheld while an otherwise allowed question can continue. Fixed envelope syntax, platform identifiers and chat-type enums are not screened as question text.
Author reference data is supplied separately from the current message, so input guardrails screen the actual question. Unlinked channel participants still need approval for every MCP call, including organization reads.
Both explicit @slug invocations and default replies receive the saved conversation history together with the initial channel thread context.
Published Apps can be revised from another conversation without the original sandbox. Use read_artifact to recover the stored source and publish_artifact with the same name and edits or replacement content. The app keeps its link and version history. Viewing an app does not grant editing access; the existing permission checks still apply.
- The agent is told where it is answering and whose account is available. Runtime guidance identifies the communicator and deployment console, explains that tools use a managed workspace, and distinguishes organization MCP connections from personal services. Personal services require both a linked sender identity and that person's authorized connection. Linking uses plain
linkin a direct message; the private connection URL never belongs in a channel. The agent reports actual tool results and gives one concrete next step when authentication or a capability is missing. -
A redelivered message is answered once. Every platform delivers at-least-once: the webhook routes answer 200 before any work so a slow handler never triggers a retry, but a 200 lost on the wire — a proxy drops it, the pod restarts — was never received, and the redelivery that follows is a valid, signed, brand-new request carrying the same message. The first delivery claims the message in Redis (one atomic
SET NX, keyed on what the platform calls the message, inside its chat) at the point every inbound path crosses — the three webhook routes and the three polling streams alike — so the retry is acknowledged and dropped, whichever API worker receives it. A claim lasts fifteen minutes, which outlives every platform's retry window.The claim is taken on receipt, so a run the process could not finish gives it back: a redelivery after a cancelled run, or one a restarting pod dropped, is answered rather than mistaken for a duplicate. That matters most for the polling streams, which re-read a message the process died on. An error the router itself catches is not that: it apologises to the sender once and keeps the claim, so the platform's redelivery does not re-run a failure that would only fail again.
The guarantee degrades open, never shut. A message that arrives with no platform message id, and a Redis that cannot be reached, are both processed rather than refused — a duplicated answer is the rarer, cheaper failure than a dropped question — and each writes a warning saying the guarantee was off for that delivery. Nothing is refused on a platform's retry header alone: Slack's
x-slack-retry-numsays a redelivery is happening, not that the first attempt got far enough to do anything, andreason=http_errormeans it explicitly did not. The header is logged; the claim decides. - Rate limits per chat, on the bot - who may talk to it and how often is the operator's, unlike everything above, which is the agent author's. - Charts render as images where the platform supports them, and fall back to a text table where it does not. - What a turn cost, said or only recorded — see below. - Files, both directions — see below. - Who shares a workspace, per surface. An agent's spec sets the default; each binding may override it, because a web chat and a Slack channel are not the same sharing question.
What each surface records¶
Every surface reaches the same runner, so every run gets its row — its cost, its status, its tokens, and the budget enforced against it. It also gets its transcript: the question, the answer, and every tool call with the arguments it was made with and what came back. That matters because a run's drill-down is read from those rows — what nothing wrote, no page can show.
For everything except web chat the transcript is written by the runner, not by the surface. It used to be the surface's job, and four of them did not do it: the widget, a mention, the API and every resumed run recorded nothing at all, so an organization was billed for an answer with no row saying what was asked. A thing every surface has to remember is a thing the next surface will not.
Web chat still writes its own, because it has events to attach and a socket to answer on — and it writes on both endings. A turn that does not finish is recorded as far as it got, from the same text the client was streamed, so what is stored is what its reader actually saw.
| Surface | What reaches messages and tool_calls |
|---|---|
| Web chat, run finished | Everything — prompt, reasoning, tool arguments and results, model and version, and the order it all happened in |
| Web chat, run interrupted | The same, as far as it got. A run that failed, hit its budget, was stopped or lost its socket keeps the words already streamed, attributed to the version that produced them, with no cost figure invented for it — the run row is where the accounting lives |
| A channel bot's default agent | Everything except the reasoning, which only a streamed run exposes |
@mention on a channel |
The same, with the handle stripped from the recorded prompt |
| Embedded widget | The same. The visitor is anonymous; the run and the turns belong to the widget's owner |
| HTTP API | The same when the call carries a conversation_id. Nothing without one — there is no thread to write a turn into, and the run row is still the record that it happened |
| A2A | The same. Every task files under a conversation, a new one or the one its contextId names |
| A run resumed after an approval | Its continuation — the answer and the calls it made, and the calls even when there is no answer, which is what a continuation that parks again on a second gated call has. No user turn: it picks up at the call it stopped on, and inventing a question would put words in somebody's mouth |
What is recorded is what the person wrote, not the prompt assembled around it.
Every surface builds something larger before the model sees it: AttachmentRouter
appends a briefing about each file, and an embedded widget prepends the operator's
placement note.
Recording that put the platform's own briefing in the transcript as somebody's
words. A file posted in Mattermost read back as co tu widzisz followed by
--- Attached file: … (/uploads/…, 43 KB, image), and the opening turn of every
widget conversation read as a visitor reciting the page they were on.
The file itself is a row on that turn, which is what the dashboard renders as a card — the same as an upload made there.
One thing is deliberately not recorded: a channel reply's delivery notes — this file was too large to send — stay out of the transcript, because they are about what the reply could not carry rather than about what the agent said.
What a turn looks like in web chat¶
The work is a narration, not a stack of cards. Each tool call is one line — Wrote
test1.md, Searched for TODO in app.py, Ran pytest -q, Linear · Create issue —
written in the tense it is true in: present while the call runs, past once it has. The
line names the subject rather than the function, because write_file is not what
anybody wants to read. Every line opens into what the call actually produced, and the
raw arguments and output stay one click further in for whoever is debugging one.
Consecutive calls hang from one rail, and only the last row stays visible. Earlier ones fold into "4 earlier steps", which says work happened without pushing the answer off the screen.
Three kinds of run are never folded: one holding a failure, one holding a call parked for approval, and one holding a step whose result is the answer — which today means a chart.
The first two are the line in the turn that is asking for something. The third is there because a turn that drew three charts folded two of them away, and three charts are three answers rather than one with two footnotes.
Which tools count as that kind is opensOnSight in lib/tool-catalog.ts, the same
row the step reads to decide whether to open itself, so the rail and the step cannot
disagree. Nothing marks a step that simply worked, so a marker means what it says.
Once the turn has answered, the work above the answer folds into one line — Worked for 12s · 3 steps — with the thinking, the calls and the short narration between them behind it, one click away. The length is the run's, from its start to its end, so it includes any wait for an approval. While the turn is still working, the same place reads Working for 5s… with a light passing over it.
What is never folded stays on the page: the three kinds of run above, a question the agent asked, what the person added mid-turn, and a passage long enough to be an answer of its own.
What opens itself follows what somebody is watching, except when the result is the point.
A call that finishes while the turn is streaming opens on the spot — code that ran, or a file that was written, is the answer rather than a footnote to it.
A conversation reopened shows one line per past call and keeps open exactly one: the last call of the most recent turn that used a tool, which is the result the reader came back for.
The most recent turn is the wrong anchor, and was the first way this was written: an agent that writes a file and then answers about it in prose ends the transcript with text, and the file it had just written was folded away. Opening every finished call on mount turned a reopened chat into a wall; opening none of them hid the thing that was asked for.
A chart is the exception at both ends. It opens wherever it sits and however the turn is being read, because a picture nobody can see is not an answer.
The same turn, watched and reopened¶
A turn is one message, and its order is recorded rather than guessed. Both halves of that were once false, and together they made the live transcript and the reloaded one two different documents.
A multi-step turn makes one model request per tool round, and the client used to open a
message on each — so a turn that drew three charts arrived as four bubbles, each with its
own avatar. One turn is one messages row, so only one of those bubbles could ever be
matched to what was stored; the rest kept a temporary id, carried no cost and no rating,
and vanished on reload.
And the row said what a turn contained without saying when. content, thinking and
tool_calls are three buckets, so a client had to reconstruct an order and the only one it
could reconstruct was reasoning, then every tool, then the answer. A turn that introduced
the charts, drew them, and then summarised them has two blocks of text and one column to
put them in: the introduction was dropped on save and the summary reappeared above the work
it described.
So messages.parts holds the sequence as it was streamed — {"type": "text"|"thinking",
"text": …} and {"type": "tool", "tool_call_id": …}, in order — and both surfaces render
the same array instead of agreeing by coincidence. A tool's arguments and result stay in
tool_calls; the timeline names the call rather than copying it.
It is null on a turn of a single part, where there is no sequence to preserve, and on every assistant turn written before this existed. Those rows are still readable — the text is in the columns it always was — but their order was never recorded and cannot be recovered, so a client that finds null falls back to reconstructing one. That fallback is a guess, and it is kept only for them.
A write ends in the file, not in a sentence about it. write_file answers "Wrote 1
lines to /workspace/test1.md"; what the transcript shows is a card naming the file, with
Open — the same viewer the Workspaces screen uses — and Download. The path is
resolved against the conversation's own listing rather than trusted from the arguments,
because a tool called with test1.md reports /workspace/test1.md and the workspace may
store either; with no match the card is drawn without controls that would fail.
An MCP call is named by its server. Nothing on a tool call records where it came from — the only trace is the prefix the backend puts on a connection's tools, which is the connection's name — so the frontend matches that prefix against the servers the caller can see and shows the server's own logo beside the step. A miss reads as the humanised tool name, which is what it read as before.
A delegation is a panel, not a pause.
When the agent hands work to
a delegate or a specialist, that
delegation is a second agent's whole conversation happening inside one turn of the
first. Left alone it is a tool call named task that goes quiet for thirty seconds.
So it streams into a panel of its own: which specialist is working, its text and its reasoning as they are generated, its own tool calls — which may reach a collection the parent cannot even see — and on close its status, its tokens and its share of the turn's cost.
Every frame carries the delegation's task id and its depth. A fan-out of three is three panels, and interleaving three specialists into one paragraph is worse than not streaming at all. An opening frame also carries the task id of the delegation it was made inside, so a specialist that delegates further nests under the right panel rather than under whichever one started most recently.
A child's text is never folded into the parent's answer. That would put words in the parent's mouth its own model never generated, and the conversation is persisted with them.
An approved call is not the end of the turn, and the rest of it is drawn too.
Approving continues the run over HTTP, so nothing about the continuation arrives on this conversation's socket. Its steps come back in the resume's own answer and are appended as one more assistant turn: the calls it made, then what it said.
Without them the second half of a turn was invisible, and a run that parked twice was the worst version of it — approve a command, watch nothing happen, and be asked to approve a second command with no step on screen accounting for the first.
The newly parked call is drawn in that turn as waiting for a person, which is also the step the next decision is written back onto.
One run is one turn on screen, however many messages it took.
A run that parks writes what it had done so far, and each continuation is written as it happens rather than folded back into the message before it — rewriting a turn somebody has already read is worse than appending to it.
So one run can leave three assistant rows, and drawing three avatars and three agent names down the page reads as three agents answering one question.
MessageList groups consecutive assistant messages carrying the same run_id
into one turn: the avatar and the name once, at the top. Consecutive is part of the
rule — a person speaking between two segments means the turn genuinely restarts —
and a message with no run recorded never groups, because absent means "not recorded"
rather than "the same run".
Live, the run id arrives on the tool_approval_required frame, which is the only
frame that names it and the only turn that needs it. On a reload it comes off the
stored message.
The time and the cost go under the end of the turn, once, however many messages it took. A run reports what it has spent when it parks, so the figure is recorded on the first segment — drawn there it sat halfway up the answer, with nothing under the end of it. The last segment shows the run's total: each figure is cumulative as at that point, so the later one supersedes the earlier rather than being added to it, and the continuation takes its numbers from the resume's own answer.
What the approved call returned is recorded on the step that was approved. The row is written open when the run parks — it has not run yet — and the resume that finally runs it produces the return without the call it belongs to, because that call was made by the previous execution. So it settles the existing row rather than writing a new step: the alternative is the same command twice in one turn, and the alternative to that was the one call somebody deliberately reviewed being the one call that opened onto nothing.
A replayed step never animates.
A tool call is stored as running until something records its outcome, and not every ending records one: an approval that expires runs nothing, so the step it parked on was written open and stayed that way.
Read back, it pulsed in the present tense under a conversation that had ended days earlier, promising a result nothing was going to deliver.
So the sweep that expires an approval now closes the step too — the one ending that never ran the call — and a replayed call still marked in flight renders as unfinished: past tense, no spinner, no result.
Not an error and not a success. The outcome nobody wrote down.
And the panel belongs to its conversation, not to the tab. Opening another thread takes the approval panel and any pending question off screen, the way it already takes the delegation panels. Left there the approval was not merely stale but actionable: Approve still decided the call, from under a different agent's transcript, and the step it settles is in messages that are no longer loaded — so nothing on screen changed to say it had happened. Clearing it loses nothing, because the approvals queue holds the same row. The one transition that is not a switch is a first turn learning its own conversation id mid-stream, and the panel survives that.
A delegate can stop for a person too — a gated tool inside a specialist parks the whole turn in the approval queue.
The panel then closes into a waiting for a person state rather than spinning on "working" for as long as the approver takes, and the delegation keeps the task id it parked under, so its identity survives the resume rather than a second panel appearing beside the first.
The resume itself runs over HTTP (POST /runs/{id}/resume), which carries no
delegation frames. So the waiting panel is moved to the resumed run's own outcome —
completed, failed or cancelled — from that answer. A resume that parks again on a
fresh decision leaves it waiting.
The assistant's answer is not in a bubble; only the person's message is. An answer is prose with headings, code and tables in it, and a rounded fill around that fights every one of them.
Every word on any of these screens comes from frontend/messages/en.json. English
is the source language; pl.json and de.json hold only what has actually been
translated - src/i18n.ts merges English underneath every locale, so a missing
translation renders English rather than the key. make lint runs frontend/scripts/check-i18n.ts, which
fails both ways: on copy left in a component, and on a key a component reads that the
catalog does not hold.
A delegation on a surface that cannot show one¶
Every other surface — Slack, Telegram, Mattermost, the embedded widget, the REST
API — gets no delegation frames at all. The delegation still runs and is still recorded; it is
simply not narrated, the same arrangement ask_user has.
That default is load-bearing rather than convenient, and it is the one thing to know before adding a surface that wants the panels.
Attaching a handler to a delegation changes the transport, not just the observability
The library drives each child through iter() and opens a streamed request
for it.
So a delegate whose model or provider cannot stream works perfectly from the API and stops working the moment somebody opens the chat window — the same published version, the same agent, failing on one surface.
Which is why a handler is attached only where a sink exists, rather than
unconditionally for the benefit of the one surface that draws them.
tests/test_subagents_library_contract.py pins that property of the library, so a
release that starts falling back to a plain request turns red and says so.
Files¶
Somebody dropping a spreadsheet on a bot used to have it discarded. IncomingMessage
had no attachment field, so no adapter parsed one and the agent answered about a
document it never received.
Now a message with a file — with or without a caption — reaches the agent the same
way a web upload does, and is read back the same way: the file is a row on the
turn it arrived with, so the thread in /chat shows a card rather than the briefing
the model was given about it.
A caption-less upload is still a turn, and its message names what arrived —
Attached image: photo.jpg — rather than sitting blank above the card, because a
blank user message reads as somebody sending nothing.
On every transport, because each adapter has exactly one parser.
Each platform has two ways in — a webhook and a stream, or long-polling — and the
second one used to build its own normalised message. Telegram's polling loop read
text and nothing else; the Mattermost outgoing webhook read no file_ids at all.
Both now put their update back into the shape the platform sends and hand it to the
same parse_incoming, so what counts as a message is decided once. It had been
decided twice, and the copies disagreed about files — which mattered most on the
paths a self-hosted deployment actually runs.
What each transport is handed still differs, and that is the platform's doing rather than ours: Telegram's polling loop subscribes to new messages only, so an edit reaches the webhook receiver and never the poller.
Inbound is the web upload path reached differently. The bytes come from a
platform instead of a browser and then go through exactly what a web upload gets:
the MIME allowlist, MAX_UPLOAD_SIZE, the parser, storage, and a ChatFile row.
A bot is the most permissive edge this platform has — anyone in a channel can drop
a file on it — so it must not also be the lenient one. From there the file follows
the routing in File processing: pasted inline for an agent
with no workspace, written to /uploads with a reference for one that has it.
The size is checked twice on purpose: against what the platform claims before anything is fetched, because downloading a gigabyte to then reject it is the attack, and against the bytes afterwards, because a claim is not a measurement.
A file the turn ran on belongs to that turn. Its ChatFile row is linked to the
user message the run's transcript writes, exactly as a web upload is linked to the
message somebody typed — so a transcript of a channel thread shows the spreadsheet
beside the question it was asked about. It matters more here than it reads:
chat_files carries no organization, so a row with no message is scoped by the
sender alone, reachable through GET /files/{id} by its owner and by nothing else.
Every channel turn used to leave one that way, because linking was done by the one
surface that writes its own transcript.
What the link widens is the metadata, not the bytes. A channel's conversation is owned by whoever spoke in it first, so in a shared channel a colleague's file now appears in a transcript other members can read — as a name, a type and a size. Downloading it still answers only its owner, which is the right half to keep private and the half a reader has to be told about: the chip is there, the bytes are not theirs.
Fetching a file needs a second authenticated request on every platform, which is why an attachment arrives as a handle rather than as content:
| Slack | The private URL on the event, fetched with the bot token. Slack answers 200 with a sign-in page rather than 401 when the token cannot read a file, so the content type is checked — otherwise a login page would be stored as the user's spreadsheet |
| Telegram | getFile resolves a file_id to a path that expires, then the file API. A photo arrives as several sizes; the largest is the one kept |
| Mattermost | /files/{id} on that bot's own server. A bot whose server is not recorded says so rather than guessing which company's server to send a token to |
| Teams | A file in a personal chat carries a download URL Teams has already authorised; a picture pasted into a message is fetched from the Bot Connector with the bot's token, which goes nowhere else |
Recordings are not supported yet. Telegram puts each kind of media in its own field, so a voice note arrives with no text at all — and until this change it parsed as nothing and vanished with no log line. It is now read, refused, and the refusal says what is actually true: the recording arrived and nothing here can listen to it yet. Transcription is #54; when it lands, audio joins the allowlist and that refusal goes.
A file that is refused — unsupported type, a recording, too large, a download that failed — is named in the reply. One bad file among three does not lose the other two or the question that came with them, and a bot that silently ignores an attachment looks exactly like a bot that read it.
A turn refused before it runs gives its files back.
The bytes are fetched and stored before the agent is resolved, so a refusal raised
in the run's place — no agent exposed on this bot, a sender whose chat account is
nobody's — used to leave the rows and the files behind with no message that would
ever link them. chat_files carries no organization, so an unlinked row is scoped
by user_id alone and nothing collects it.
Both are now deleted before the refusal is sent, and the refusal is sent whether or not that succeeded (#661).
A turn that actually ran keeps its files — they fed it, and the run is in the transcript.
Outbound files are sent only through send_channel_file, when the user asks to receive or download a file. Creating, reading or editing a workspace file does not attach it. Grant the tool per bot binding under Where this agent is available. Existing bindings keep their chosen tools; enable this action explicitly after upgrading. The tool is side-effecting and follows the normal approval policy. It sends only the named workspace file, refuses internal paths and files over 8 MB, and permits at most three attempts per turn. Platform failures are reported instead of claiming delivery. Teams may require acceptance in a personal chat. Charts and requested visuals keep their separate picture delivery.
Each file carries the type its name implies rather than a flat
application/octet-stream, so a chart an agent wrote arrives as a picture on the
platforms that read the field instead of as a blob somebody has to download to
identify.
Capped at 3 files and 8 MB each, below every platform's own limit so the refusal is ours and can be explained rather than arriving as an opaque API error. Anything past the cap is named in the reply and stays in the workspace.
A chart stays separate from all of this. It is a photo on these platforms,
rendered inline, which is the whole point of the charts capability — folding it
into the attachment list would make every chart arrive as a download.
Saying what a turn cost¶
A bot that stops answering because its organization hit its monthly cap looks broken. The only difference between "broken" and "out of budget" is somebody having said so beforehand, so a bot can report what a turn spent: tokens, cost, how much of the month is gone, and how full the workspace behind it is.
In web chat the same two numbers sit under the composer, and they come from different places because they measure different things.
The cost is the newest measured answer in the conversation on screen. It is read from the transcript, so it is there when a thread is reopened rather than after the next message — and filtered by conversation id, because the store still holds the previous thread's messages for the moment between the click and the fetch landing. It reported those under the new conversation until it was.
The fill is the workspace as it stands now. A live turn reports it — a container's resident memory can only come from its host — and a reopened conversation reads it from the workspace listing, which carries the ceiling a stored workspace fills up against. Without that, "workspace 0% full" appeared only after somebody sent a message, the one moment nobody needs it.
Chosen per binding, in the Builder under Where this agent is available - beside the extra instructions and the channel lookups, because whether a reply carries a cost footer is part of what this agent says on this surface. It sat on the bot until a bot served one agent, where it was an operator's setting in a table of servers and tokens with nothing else about the agent near it.
| Mode | |
|---|---|
log only |
Recorded and not said. Unspoken is not unmeasured — "the bot went quiet" is a question somebody asks days later |
near a limit |
Said once the budget or the workspace passes a threshold (80% by default). The default |
every n messages |
Said every n-th turn of that chat, not of the bot |
every reply |
Said every turn |
near a limit is the default rather than log only, because defaulting to
silence would leave every already-registered bot in exactly the state this exists
to prevent. And rather than every reply, because a footer under every message in
a busy channel is the other way to make a warning useless.
The workspace counts as well as the money. A stored workspace that fills up starts refusing writes, which the agent reports as a tool error in the middle of doing something — a bot that only watched the budget would go quiet on the other limit with nothing said.
Measuring costs something for a container: its memory is a round trip to the host
per sandbox. So log only never asks, and every other mode asks about one session
rather than listing them all.
In /chat there is no noise argument, so the numbers are always sent — the client
draws them under the input and decides what to show. Three things it shows that a
channel footer does not:
- The agent's own cap first, and the organization's only past 80%. The organization's stops every agent at once and belongs to somebody else; the agent's own is the one whoever is looking at it can raise.
- Input and output separately, under each answer as well as under the input. They are priced an order of magnitude apart, so a total cannot say whether a turn was expensive because of a long context or a long answer — and the strip only ever describes the last turn, which in a long conversation hides which answer cost the money. Live turns only: usage is measured when a run finishes and is not stored per message, so a reloaded conversation shows none.
- The files themselves, in a panel beside the transcript reading
GET /conversations/{id}/workspace. It re-reads when a turn ends rather than on a timer, and it is absent entirely — not empty — for an agent that keeps no files, which is most of them. It names whose files these are, because underagentscope one workspace is shared and finding a file you never created reads as a leak until something on screen explains it. A file is a tile, and opening one opens the same viewer the Workspaces screen uses — a picture, a PDF, a player for audio and video, markdown as preview or source, and always a download — reading…/workspace/filefor text and…/workspace/rawfor bytes. Through the conversation rather than the workspace id, deliberately: that is what keeps these files reachable for somebody the chat was shared with.
Overriding who shares the workspace¶
On Slack, thread_ts is folded into the chat id — so a thread is a
conversation, and an agent whose spec says conversation gets one workspace per
thread. In a busy channel that is fifty containers and a 429 for the fifty-first
person to reply. The binding can say channel instead, and every thread in that
channel shares one.
The choices are the same as the spec's (run, conversation, channel, user,
agent), plus "as the agent says", which is the default and stores nothing. The
control is on the binding in the Builder, and it appears only for an agent that
keeps files at all.
user scope is what carries a workspace across surfaces: a person who starts in
web chat and continues in Slack is one ChannelIdentity linked to one account, so
they find the same files. conversation and channel deliberately do not — those
name a place, and a place does not follow somebody to another platform.
What the agent may look up about the channel¶
A bot answering in ~support knows the words somebody typed and nothing else.
It does not know the channel is called ~support, who is in it, what it was set
up for, or what was said in it ten minutes ago — so "who should I ask about
billing?" and "summarise what we decided above" are questions it can only
answer by guessing.
Five tools change that, and each is granted per binding, under Where this agent is available:
| Tool | Answers | Slack | Telegram | Mattermost | Teams |
|---|---|---|---|---|---|
get_channel_info |
Name, purpose, topic, size | ✅ | ✅ | ✅ | ✅ |
list_channel_members |
Who is here | ✅ | admins only | ✅ | ✅ |
search_channels |
Which other channels exist | ✅ | — | ✅ | — |
read_channel_history |
Recent messages in this channel, or scope="thread" for this thread |
✅ | — | ✅ | — |
send_channel_file |
Send a requested workspace file | ✅ | ✅ | ✅ | ✅ |
Use a direct message containing plain link (without a slash) to connect an account. Open the private connection link while signed in to AgenticOS, then return to the channel. When earlier thread files require linking, the bot explains this without downloading them and retries after connection. Channel pictures use twice the CSS resolution; very wide horizontal Mermaid flowcharts receive a vertical layout for the PNG only.
Images requested through generate_image are attached to replies on all four chat platforms, alongside the existing chart and diagram support. Stored images are read within the run's organization, limited to 8 MiB and 16 megapixels, and converted to PNG; missing, invalid or oversized images produce a delivery notice. Teams additionally fits inline pictures to its own limits. Creating an ordinary workspace file still does not send it: that requires send_channel_file. Thread context preserves the original message timestamps; relative times refer to those timestamps, not to when the agent joins the thread.
Only files recorded as generated by the run's own tools are attached. A tool name or result text cannot authorize access to another conversation's media. Rejecting image generation approval does not trigger a missing-image notice. Existing threads refresh text once without downloading earlier attachments again.
Per binding rather than per agent, because an organization can bind one agent to
two Mattermost servers and three Slack workspaces — and "may it read what was
said in this channel" has a different answer on the internal one and the
customer one. A switch in the agent's Toolbox would have one answer for all
five, which is why there is no such switch: publishing refuses a spec that
carries channel_tools, and the run assembles the binding from the row that
admitted the message, the same way it appends that binding's prompt.
Nothing is granted by default. What a platform cannot answer is not offered:
Telegram gives a bot no directory of chats to search and no way to read messages
it was not sent, and getChatAdministrators is the whole of what it may list —
so a Telegram member list is a list of administrators and says so.
Three things worth knowing before granting them:
- The bot's membership is the whole permission boundary. Every call goes through the bot's own token, so the agent sees exactly what the bot sees. There is no allow-list of ours to get out of step with the platform's own.
- The model never names a channel or thread ID.
read_channel_historydefaults toscope="channel"for recent messages in the current channel;scope="thread"reads only the bound thread. Neither can read a different channel. Results are a bounded recent window, not evidence that older messages do not exist. Automatic context backfill remains thread-only. read_channel_historyis the one worth gating. It is a read, so it does not ask by default, but it puts other people's messages into a run transcript somebody reads weeks later. Atool_approvaloverride on the binding is how you make it ask.
Automatic channel context includes the name, purpose and a bounded visible roster, marked as data rather than instructions. These per-binding tools grant additional lookups and explicit file delivery; clearing a tool grant does not remove the automatic context.
A bot whose messages start a workflow¶
A workflow with a Channel message trigger takes a bot's messages in its agent's
place. Only the messages the agent would have answered reach it, and of those only
the ones that fit the trigger's filter: a message naming another agent by @handle
still goes there, and one the filter leaves is answered by the bot's agent as
before. The workflow answers with its own Send a message step, into the thread
the message came from. See
From a channel message.
Choosing¶
flowchart TD
A{a site of your own?} -->|no| L{a link will do?}
L -->|yes| H[a hosted page]
L -->|no| T[Slack, Teams, Telegram or Mattermost]
A -->|yes| U{your own interface?}
U -->|yes| WS[the raw WebSocket]
U -->|no| P{visitors signed in to your product?}
P -->|yes| J["the widget, <code>jwt</code> mode"]
P -->|no| PB["the widget, <code>public</code> mode"]
A -->|another system entirely| API["the REST API"]
- Your own site, no accounts → widget,
publicmode. - Inside your product, per-user → widget,
jwtmode. - Your own interface entirely → WebSocket.
- No site of your own, and a link will do → a hosted page.
- Where the team already talks → Slack, Teams, Telegram or Mattermost.
- Another system entirely → the REST API (
POST /api/v1/agents/{id}/run).
The first four are one object
A widget, a socket client and a hosted page are three ways of reaching the same embed, with one set of refusals between them — so "who may talk to this agent" has exactly one answer whichever of the three somebody arrives through.
Recap¶
- One runner behind every surface. Web chat, a hosted page, a widget, the API, Slack, Teams, Telegram and Mattermost all reach the same code, so governance is not something a caller can route around.
- A bot answers as one agent, and a mention runs as the sender — never as the bot.
- What a stranger may do, they may do at a rate: the public surfaces carry per-caller and per-address limits counted in Redis.
- What is recorded is what the person wrote, not the prompt assembled around it.
- A delegation is a panel, not a pause — and attaching a handler to one changes the transport, so it is attached only where a sink exists.
A channel post containing only the bot mention or whitespace is ignored after mention removal, before claiming delivery or starting a run. This avoids sending an empty user message to model APIs that drop it and then reject the preceding assistant answer as prefill. A post containing only attachments still follows the attachment pipeline.
Current channel defaults¶
Channel formatting and conversation guidance come from the current deployment on every run. A binding prompt stores only custom additions. Upgrades remove exact recognized copies of previously shipped defaults while preserving surrounding custom text; edited or unknown text is left alone. Clearing the custom prompt keeps the platform defaults and channel context. No runtime compatibility rewrite of old prompt strings is needed.
Downgrading restores the preceding release’s stored channel defaults while preserving custom additions, including bindings created after the upgrade. Other surfaces remain unchanged.
Notifications through linked channels¶
Choose a private destination per platform and organization in Settings → Notifications: Slack, Telegram, Mattermost, Teams, Discord, SMS and iMessage use the existing linked bot account. Email and push keep their own setup. Event preferences and routine overrides apply at send time, together with resource permissions and the shared case's current state. Linking a chat account alone does not enable delivery.
Slack receives plain-text Block Kit notices with a link to the existing case. Opening the link uses normal console authorization. Acknowledging or resolving the case stops pending external notices across channels. A message's sent status records platform acceptance; the case retains its own acknowledgement state.
A confirmed Slack HTTP 429
permits a bounded retry after Retry-After. The notification client disables
SDK retries, so timeouts and
unknown outcomes remain visible failures instead of duplicate submissions. See
notification policy for route revocation, approval degradation
and migration details.
iMessage through a company Mac¶
A company-owned Mac running Messages and BlueBubbles provides this transport. Keep it signed in and online, grant BlueBubbles the macOS permissions it requests, and enable its REST API. In Channels, register iMessage with the bridge's HTTP URL and its server password as the bot token. The password is sealed in the organization's vault; the URL must contain no credentials, query or fragment.
Bind the published agent, then edit the channel and choose Register iMessage webhook after saving any changes. Replacing the endpoint or password revokes the previous callback credential; register again. AgenticOS registers only new-message at /api/v1/imessage/{bot_id}/webhook. It generates a separate callback credential and registers it as HTTP Basic authentication. Configure HTTPS for traffic crossing an untrusted network. If AgenticOS has a private address, the BlueBubbles administrator must explicitly allow API webhooks to reach local addresses. Removing the webhook removes only callbacks naming this bot on this deployment.
Text and files use BlueBubbles' AppleScript transport. Optional tapback acknowledgements need its Private API. Linked private senders get a separate channel conversation; linking an identity does not grant a bot or bridge access to existing private console history; groups retain their separate conversation and require the usual agent mention. Approvals use the existing numbered prompts: answer with the displayed number. Self echoes, tapbacks and system events do not start another turn. Incoming attachments are fetched only by GUID from the configured bridge and remain subject to the upload limit.
Select the linked private conversation in Settings → Notifications to opt in. A group is never a private notification destination. Bridge acceptance records submission, not Apple's delivery or the recipient's acknowledgement. An offline bridge produces a visible failure; an uncertain submission is not retried automatically.
Apple Messages for Business is a separate business service. Its provider setup requires a Messages for Business account and messaging provider; this Mac adapter does not provision that integration.
SMS webhook forms are capped at 64 KiB and a ten-second read deadline before signature verification. Numbered SMS and iMessage replies claim the provider message ID before dispatch, so a duplicate delivery cannot toggle a pending selection twice.
Inbound email is read as a bounded stream (25 MiB maximum) before signature verification, including requests without an honest Content-Length.
Before durable email admission, each organization is limited to 4,096 retained delivery jobs and 256 MiB of retained bodies, including completed and failed deliveries. Admission is serialized across workers; duplicate Message-IDs remain idempotent. A full quota returns HTTP 429 before retaining another body; retention cleanup or deletion of retained jobs releases capacity.