Widget and input/output channels

🔵 How users and external systems communicate with agents. This is not an "API" in the software sense, but channels — ready-made entry/exit points that an agent receives from configuration.

Channel Where it's configured Nature
Widget (web) server.yaml → widget + embedding widget.js embeddable chat on a website
Telegram bot agent.yaml → telegram (per agent) long-polling, private chats
Community VK bot agent.yaml → vk (per agent) Long Poll, community messages
Yandex Messenger agent.yaml → yandex_messenger (per agent) polling getUpdates, private and group chats
Email (IMAP→SMTP) agent.yaml → email (per agent) incoming emails + reply
Storage (S3/WebDAV/FTP) agent.yaml → storage (per agent) new files from bucket/folder → agent
HTTP API (SSE) no config POST /v1/chat/completions (§8)
Incoming webhook server.yaml → inbound POST /v1/inbound/:template (§8.8)
Sinks (output) notify in cron/email/workflow log/webhook/telegram/vk/yandex_messenger/messages_db/agent/email/workflow/storage (§11)

19.1 Widget (web)

An embeddable chat interface that communicates with the agent via /v1/chat/completions (SSE) and closes the session via POST /v1/sessions/:id.

Embedding

On any page of a website:

<script src="http://127.0.0.1:3000/widget.js"></script>

Served routes: /widget (page), /widget.js (loader), /dist/* (assets). The /widget page references assets at versioned addresses (/dist/<версия>/*): the browser caches them forever, and after a deploy it immediately picks up the new ones. Addresses without a version remain and are revalidated on every request.

Which agent opens

The widget resolves the model in this order (last one wins):

  1. widget.default_model (from server.yaml);
  2. window.AGENTOS_MODEL — a variable of the parent frame;
  3. ?model= in the URL (/widget?model=hello-agent).

Configuration in server.yaml:

widget:
  default_model: "drom-agent"   # агент по умолчанию

The widget takes the base URL from window.AGENTOS_BASE_URL (empty = same origin) — needed when hosting the widget on a different domain.

Ready-made demo pages

URL What
/widget?model=hello-agent widget with a specific agent
/demo/overlay widget over live websites (tabs from config/demo.yaml)
/demo widget demo

Tabs /demo/overlay are defined in config/demo.yaml (tabs: [{label, url, model}], hot-reloads on the fly).

Close button

By default, the widget header has a "close" button (×): it sends agentos:close to the parent frame, and the loader widget.js hides the popup chat. For inline embedding (the widget is inserted directly into the page rather than opened as a popup), the button is not needed — visibility is managed by the page itself. It can be disabled at the template level:

widget:
  show_close: false   # не показывать кнопку «закрыть» (по умолчанию true)

This is done for felix-agent (page /ext/felix/) and admin (page /agents/admin), where the chat is embedded inline.

The dialog is preserved

By default, the dialog lives until the user starts a new one with the "New dialog" button (the pencil in the header). Closing the widget only hides it, switching demo tabs returns to the dialog of the selected agent, and after a page reload the completed questions and answers (along with tables, maps, and charts) are drawn again — they are stored in the browser's localStorage separately for each agent.

The behavior is configured at the template level:

widget:
  persist: true        # false — закрытие/перезагрузка начинают новый диалог
  history_limit: 40    # сколько завершённых обменов хранить в истории

With persist: false, the widget does not save or restore history: closing or reloading the page starts the dialog anew. history_limit limits the size of the saved transcript — the oldest exchanges are evicted.

Links from answers open on the website behind the widget. When the widget occupies most of the screen (phones), the loader closes it so that the opened page is visible; pressing the chat button again continues the dialog.

The dialog cost and the agent name are hidden behind the "$" button in the header.

Suggestion buttons

In a new dialog, below the greeting, you can show ready-made questions as buttons — pressing one sends the question just as if the user had typed it themselves. After the first message, the buttons disappear.

widget:
  starters:
    - "Подбери игру для компании из шести человек"
    - "Что подарить ребёнку 10 лет?"

2–4 short, specific questions that the agent answers confidently work well. Hosts on their own ChatWidget call the same thing directly: widget.showSuggestions([...]).

Visual blocks in the widget

Tools can return a visual layer (charts chart, tables table, text) — the widget renders them inline (Chart.js + tables). The block format and pagination are common fields.

How to show these blocks is set by widget.tool_visuals (default final):

widget:
  tool_visuals: final   # final | always | never

The rule applies to the widget's SSE stream, so the saved history contains exactly the blocks that the user saw.

Attachments in the widget

When a template has attachments.enabled: true set (§4.3), the widget shows a 📎 button with a source picker: file (upload), screenshot (getDisplayMedia), photo (camera; on mobile — native capture). Files go to POST /v1/uploads, and are passed in /v1/chat/completions via the attachments field — the agent receives a message with their names in the session's scratch directory.

In addition, the agent can itself request an attachment with the request_attachment tool: the widget shows a hint card with a button, and after attachment responds via POST /v1/input-response (§8.2.1).

Sign-in in the widget

If the template has login.enabled: true set (§4.3) and providers are configured auth.oidc, a "Sign in" button appears in the widget header. Clicking it opens a popup with OIDC login (GET /v1/login/widget/:provider); after sign-in, the callback issues a signed x-user-token (HMAC), which the widget caches in localStorage and sends in the x-user-token header on subsequent requests. The browser-id is then automatically merged with the OIDC identity (Origin::Oidc in the user-registry) — one user across all channels. The button changes to "Sign out" (resets the local token).

login:
  enabled: true
  providers: [yandex]           # пусто = все включённые провайдеры
  prompt: "Войти через Яндекс"  # текст кнопки (опционально)

Mandatory sign-in (login.required)

login.required: true makes sign-in mandatory: an anonymous user cannot send a message to the agent. The widget shows a full-screen sign-in screen instead of the input field, and the server responds with 401 to anonymous POST /v1/chat/completions. The requirement is satisfied in one of the following ways:

login:
  required: true
  providers: [yandex]     # вход через Яндекс
  allow_users: true       # ИЛИ логин/пароль из auth.users
  prompt: "Войдите, чтобы продолжить"
Field Default Description
enabled false show the "Sign in" button (optional sign-in)
required false require sign-in; anonymous requests are rejected (401)
providers [] OIDC providers (auth.oidc.providers); empty = all
allow_users false allow sign-in with login/password from auth.users
prompt empty text of the button/sign-in screen title

Sign-in in the Android app

The Android client supports the same sign-in methods. If the selected agent has login.required, the app shows a sign-in screen instead of the chat:

The resulting token is stored in the app settings (x-user-token) and sent along with x-user-id; the "Sign out" button in the chat header resets the identity.

Dynamic sender allowlist

For messenger channels (Telegram / VK / Yandex Messenger / email), the list of allowed senders (allowed_user_ids / allowed_logins / allowed_senders) is set in agent.yaml and requires a redeploy. There is a dynamic alternative — the allowed_senders table in logs/allowed_senders.db, which is changed via the API without a restart/redeploy.

A record is a channel identity:

Records are bound to an agent template (or * — globally). When the table has records for a template (or global *), the table takes priority: the static allowlist from agent.yaml is ignored. If the table is empty, the static allowlist applies as before.

# разрешить tg:12345 и ym:ivan агенту my-agent (без редеплоя)
curl -X POST http://127.0.0.1:3000/v1/allowed-senders/my-agent \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"identity":"tg:12345","note":"Pavel"}'
curl -X POST http://127.0.0.1:3000/v1/allowed-senders/my-agent \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"identity":"ym:ivan"}'

# список
curl http://127.0.0.1:3000/v1/allowed-senders/my-agent -H "Authorization: Bearer $ADMIN_TOKEN"

# удалить
curl -X DELETE http://127.0.0.1:3000/v1/allowed-senders/my-agent/tg:12345 \
  -H "Authorization: Bearer $ADMIN_TOKEN"

The endpoints are in 08-http-api.md (§ "Allowed senders").


19.2 Telegram

One long-polling bot per template; started at server startup if token_env is set. The token is read from env (never from YAML).

telegram:
  token_env: MY_TG_BOT_TOKEN      # имя env с токеном бота
  allowed_user_ids: []            # пусто = любой; список = только эти Telegram user id
  show_typing: true               # индикатор «печатает…» во время работы
  show_tool_calls: true           # компактные строки статуса tool-вызовов
Field Default Description
token_env empty name of the env var with the token; empty = bot disabled
allowed_user_ids [] restrict senders by Telegram user id
show_typing true show the typing indicator
show_tool_calls true echo tool calls to the chat

Visual blocks on Telegram: chart → PNG (sendPhoto), table → markdown lines.

Incoming photos and documents from the user (with attachments.enabled: true on the template) are downloaded via getFile and saved to the session scratch; the agent receives a message with the file names. The agent's request_attachment request is resolved the same way: the bot sends a hint "send a file/photo", and the user's reply with media continues the turn.


19.3 VK (community)

A community bot on the Long Poll API — no public HTTPS needed. One bot per template; started at server startup if token_env is set. The community token is read from env (never from YAML).

vk:
  token_env: VK_GROUP_TOKEN     # имя env с group access token
  group_id: 123456789           # id сообщества (для longpoll и сообщений от имени группы)
  allowed_user_ids: []          # пусто = любой; список = только эти VK user id
  show_typing: true             # индикатор «печатает…» (messages.setActivity)
  show_tool_calls: true         # эхо tool-вызовов в чат
  poll_wait: 25                 # wait для longpoll (сек, 1..25)
Field Default Description
token_env empty name of the env var with the token; empty = bot disabled
group_id 0 community id; 0 = derive from the token
allowed_user_ids [] restrict senders by VK user id
show_typing true show the typing indicator
show_tool_calls true echo tool calls to the chat
poll_wait 25 longpoll request wait interval (sec)

The session is vk:{template}:{peer_id} (for a conversation, peer_id = 2000000000 + chat_id). Token permissions: messages (and photos/docs for uploading media). Visual blocks: chart → PNG via photos.getMessagesUploadServer, table → text lines, file → document via docs.getMessagesUploadServer. Incoming photos and documents from the user (with attachments.enabled: true in the template) are downloaded and saved to the session scratch; the agent receives a message with the file names. The agent request request_attachment is resolved the same way: the bot sends a hint, and the user's reply with media continues the turn.

As a result sink (for cron/email/monitor), VK is specified by the type vk:

notify:
  - type: vk
    peer_id: 41848219        # peer_id получателя (для беседы 2000000000 + chat_id)
    # token_env: VK_GROUP_TOKEN  # опц.; по умолчанию берётся токен из vk-конфига шаблона

Community setup

For the bot to receive messages, two things must be done in the community:

  1. Enable Long Poll API (Management → API → Long Poll API → "Enabled").
  2. Enable the "New messages" event type — by default VK enables Long Poll, but all event types are disabled (message_new: 0), so the bot receives nothing. In the UI — the "New messages" checkbox in Long Poll API; or via the API:
curl -X POST "https://api.vk.com/method/groups.setLongPollSettings" \
  -d "group_id=<id>&access_token=<token>&v=5.199" \
  -d "message_new=1&message_reply=1&message_edit=1&message_allow=1&message_deny=1&message_typing_state=1"

To check the current settings: groups.getLongPollSettings (the events.message_new field).


19.4 Yandex Messenger (Yandex 360)

A bot on Yandex's Bot API — polling bot/v1/messages/getUpdates/ with the cursor offset (no public HTTPS needed). One bot per template; it starts when the server starts, if token_env is set. The bot's OAuth token is read from env (never from YAML). The bot is created in Yandex 360 for Business ("Bots in Messenger").

yandex_messenger:
  token_env: YANDEX_MESSENGER_BOT_TOKEN  # имя env с OAuth-токеном бота
  allowed_logins: []                     # пусто = любой; список = только эти логины
  show_typing: true                      # индикатор «печатает…» (sendTyping)
  show_tool_calls: false                 # эхо tool-вызовов (по умолчанию выключено)
  poll_secs: 2                           # пауза между опросами getUpdates (сек)
Field Default Description
token_env empty name of the env with the OAuth token; empty = bot disabled
allowed_logins [] restrict senders by Yandex login
show_typing true show the typing indicator
show_tool_calls false echo tool calls (separate messages — noisy, since Messenger can't replace them)
poll_secs 2 pause between polls getUpdates (sec)

The session is ym:{template}:user:{login} (personal chat) or ym:{template}:chat:{chat_id} (group/channel). A reply to a personal chat goes via login, to a group/channel — via chat_id. Formatting — Yandex's markdown subset (**жирный**, __курсив__, ~~зачёркнутый~~, [ссылка](url), code); the agent's full Markdown is re-rendered via comrak. Visual blocks: chart → PNG (sendImage), table → text lines, file → document (sendFile).

Incoming files and images from the user (with attachments.enabled: true in the template) are downloaded via getFile and saved to the session scratch; the agent request request_attachment is resolved the same way.

As a result sink (for cron/email/monitor), Yandex Messenger is specified by the type yandex_messenger; the chat_id field accepts either the chat/channel id (the value from /, e.g. 0/0/4f24b544-…), or the user's login for a personal chat:

notify:
  - type: yandex_messenger
    chat_id: "0/0/4f24b544-697c-4e18-a9c1-b39432ee9bf9"   # или логин пользователя
    # token_env: YANDEX_MESSENGER_BOT_TOKEN  # опц.; по умолчанию токен из yandex_messenger-конфига

This channel is only about Messenger. Sign-in via Yandex (OAuth login on the web) and Yandex Disk / other service tools are separate integrations.

How to test

  1. In Yandex 360 → Bots in Messenger create a bot and copy its OAuth token.

  2. Put the token in .env (see .env.example):

    YANDEX_MESSENGER_BOT_TOKEN=At...
    
  3. Add the yandex_messenger section to the template's agent.yaml (an example already exists in drom-agent), restart the server. The log will show yandex messenger bot authenticated (or a warning about a token error).

  4. Write to the bot in direct messages or add it to a group chat.

A quick token check without starting the server — a smoke test:

cargo test -p agent_web_bot --test yandex_messenger_live -- --nocapture

It calls self/get and getUpdates and prints the bot's login; without a token the test is silently skipped.


19.4 Email

Polling an IMAP mailbox: each new email starts a one-shot turn and replies via SMTP. Context between emails — via long-term memory (the memory hook), the key being the canonical user id of the sender.

email:
  imap_host: imap.example.com
  imap_port: 993
  imap_username: support@example.com
  imap_password_env: EMAIL_PASSWORD   # пароль из env, не в YAML
  allowed_senders: []                 # пусто = любой; "@domain" = весь домен
  smtp:                               # reply-настройки (опусти = только обработка)
    host: smtp.example.com
    username: support@example.com
    password_env: SMTP_PASSWORD
    from: support@example.com
  notify: []                          # доп. sink'и (как у cron)
  poll_secs: 60
Field Default Description
imap_host empty IMAP host; empty = channel disabled
imap_port 993 IMAP port (implicit TLS)
imap_username empty login (usually the mailbox address)
imap_password_env empty name of the env with the IMAP password
allowed_senders [] allowed senders (exact address or @домен)
smtp none SMTP for replies: host, username, password_env, from, tls, port
notify [] additional result sinks
poll_secs 60 mailbox polling interval

Email attachments (with attachments.enabled: true in the template) are saved to the session scratch and passed to the agent (base64/quoted-printable are decoded).


19.4 Storage (S3/WebDAV/FTP)

Polling external storage: each new file under the prefix starts a one-shot agent turn (the file's text goes into the message). Similar to the email channel, but the source is files in a bucket/folder.

storage:
  storage:                           # подключение (как у storage_tool)
    backend: s3
    endpoint: https://s3.example.com
    bucket: inbox
    region: us-east-1
    access_key_env: S3_ACCESS_KEY
    secret_key_env: S3_SECRET_KEY
  prefix: inbox/                     # папка для опроса
  poll_secs: 60                      # интервал опроса
  include_suffix: ".txt"             # опц.: обрабатывать только *.txt
  move_after: processed/             # опц.: переместить файл после обработки
  # delete_after: true               # или: удалить после обработки
Field Default Description
storage none connection (backend: s3/webdav/ftp); empty = channel disabled
prefix "" folder/prefix to poll
poll_secs 60 polling interval
include_suffix "" filter by file name suffix (empty = all)
move_after "" move the processed file under this prefix
delete_after false delete the processed file (mutually exclusive with move_after)

Dedup — in memory (processed keys); to survive a restart, use move_after/delete_after so the file isn't processed again.

Storage sink (output)

Delivering the result to storage — the storage sink in notify (like cron/email):

notify:
  - type: storage
    storage:
      backend: s3
      endpoint: https://s3.example.com
      bucket: reports
      region: us-east-1
      access_key_env: S3_ACCESS_KEY
      secret_key_env: S3_SECRET_KEY
    key: "runs/{source}/{ts}.txt"   # {source} и {ts} подставляются

19.5 Programmatic channels (pointers)