Корпоративный AI-агентчасть 8 из 9
- 01Концепция: агент, который знает компанию
- 02Первый разговор с моделью
- 03Агентный цикл и инструменты
- 04MCP: подключаем внутренние системы
- 05Знания компании: поиск по документам
- 06Права доступа: SSO, OAuth и агент от имени сотрудника
- 07Контекст и деньги: кэширование, сжатие истории и лимиты
- 08Интерфейс: веб-чат и бот в Slack
- 09В прод: периметр, аудит и проверки
В прошлых частях агент жил в терминале и брал токен сотрудника из переменной окружения. Сотрудники так работать не будут. В этой части сделаем два входа: веб-чат и бота в рабочем мессенджере. Тонкий слой, как обещали в концепции, но с несколькими местами, где легко ошибиться с безопасностью.
Что нужно от интерфейса
- Знать, кто спрашивает. Вход через корпоративный SSO, дальше агент работает с токеном этого человека, как в части 6.
- Не терять доступ посреди дня. Access token живёт час, сотрудник работает восемь. Нужен refresh token, и хранить его надо аккуратно.
- Показывать ответ по мере написания. Ответ с тремя вызовами инструментов может идти полминуты. Пустой экран всё это время выглядит как зависание.
- Помнить разговоры. Закрыл вкладку, открыл завтра, продолжил.
- Спрашивать подтверждение опасных действий из части 3, теперь через кнопку, а не через терминал.
Схема
Ядро отдельно от интерфейса
Цикл из части 7 печатал в консоль и спрашивал подтверждение через readline. Теперь у него два потребителя, поэтому всё, что касается вывода, уходит в три колбэка:
import Anthropic from "@anthropic-ai/sdk";
import { connectMcp } from "./mcp";
import { userToken } from "./oidc";
import { type Conversation, type User, saveConversation } from "./store";
import { DAILY_LIMIT_USD, record, spentToday, tally } from "./usage";
type Message = Anthropic.Beta.BetaMessageParam;
// Куда агент сообщает о ходе работы. Веб-чат и бот реализуют это по-своему
export type Events = {
text: (delta: string) => void;
tool: (name: string, input: unknown) => void;
confirm: (name: string, input: unknown) => Promise<boolean>;
};createAgent(user, token) собирает то же, что agent.ts в прошлой части: подключает MCP-серверы с токеном сотрудника, фиксирует порядок инструментов, ставит метки кэша, считает деньги. Функция turn(messages, events) это цикл из части 7, где process.stdout.write заменён на events.text, вывод вызова инструмента на events.tool, а вопрос в терминале на await events.confirm(...). Вокруг него ask(user, conversation, text, events): берёт агента сотрудника из кеша (или создаёт), блокирует разговор на время хода, дописывает вопрос и ответ в историю, сохраняет её, а при сбое отрезает всё, что дописал этот ход.
Три решения здесь стоит объяснить.
Агент на сотрудника, а не на запрос. Подключение к MCP-серверам стоит нескольких запросов, и открывать его на каждый вопрос незачем. Токен при этом передаётся функцией, а не строкой: access token обновляется раз в час, и подключения должны брать свежий.
Один ход за раз. Если сотрудник отправит второй вопрос, пока агент отвечает на первый, оба хода начнут дописывать одну историю, и она перемешается. Простая блокировка по id разговора это исключает.
Откат при сбое. Если что-то упало посреди хода, в истории может остаться вызов инструмента без результата, и следующий запрос к API вернёт ошибку. Поэтому при сбое отрезаем всё, что дописал этот ход. Это не правка истории в смысле части 7: отрезается только хвост, начало разговора не меняется.
Вход через SSO
Для веб-приложения с сервером стандартный способ входа это OAuth authorization code flow с PKCE:
- Сотрудник открывает
/login. Сервер придумывает случайныеstateиverifier, запоминает их и отправляет браузер на страницу входа провайдера. В адрес кладётstateи хеш отverifier. - Сотрудник входит, как обычно входит в корпоративные системы: пароль, второй фактор. Провайдер возвращает браузер на наш
/callbackс одноразовым кодом и тем жеstate. - Сервер проверяет, что
stateсовпадает с выданным этому браузеру, и меняет код на токены, прикладывая исходныйverifier. Провайдер сверяет его с хешем из первого шага.
state защищает от подделки входа: без него злоумышленник мог бы подсунуть сотруднику ссылку со своим кодом, и тот работал бы в чужой учётке. PKCE защищает код: даже перехваченный код бесполезен без verifier, который не покидал наш сервер.
Учебный провайдер из части 6 научился этому. Добавились зарегистрированные клиенты, страница входа и два новых вида обмена в /token: код на токены с проверкой PKCE и refresh token на новую пару токенов.
Refresh token здесь одноразовый: при каждом обновлении провайдер выдаёт новый и гасит старый. В Keycloak и Okta это включается настройкой ротации, и её стоит включить. Украденный refresh token тогда работает только до первого обновления. Если вор обновится первым, у настоящего владельца следующее обновление не пройдёт и его выкинет на страницу входа, а это заметно.
Клиентская сторона входа живёт в oidc.ts. Начало входа это два случайных значения и редирект:
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
const url = new URL(`${ISSUER}/authorize`);
url.search = new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
scope: "openid profile email offline_access",
state, code_challenge: challenge, code_challenge_method: "S256",
}).toString();state и verifier сервер запоминает за этим браузером. На /callback он меняет код на токены запросом в /token с grant_type: "authorization_code" и тем же verifier, сохраняет refresh token в базу, а access token держит в памяти. Когда access token подходит к концу, функция userToken получает новый по refresh token.
Самое тонкое место здесь userToken. Если два запроса одновременно увидят, что токен истекает, и оба пойдут его обновлять, первый получит новый refresh token, а второй придёт со старым, уже погашенным, и сотрудника выкинет. Поэтому обновление идёт одно на пользователя, остальные ждут его результата.
В настоящем провайдере веб-приложение регистрируется как confidential client с секретом, и секрет добавляется к запросам в /token. Scope offline_access нужен, чтобы провайдер вообще выдал refresh token.
Где хранить токены и разговоры
Хватает двух таблиц:
CREATE TABLE users (
login TEXT PRIMARY KEY, name TEXT, email TEXT,
refresh_token TEXT, -- зашифрован AES-GCM ключом из окружения
slack_id TEXT UNIQUE
);
CREATE TABLE conversations (
id TEXT PRIMARY KEY, login TEXT NOT NULL, title TEXT,
messages TEXT NOT NULL, -- история для API как есть, только дописывается
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);Refresh token зашифрован. Он даёт доступ к данным сотрудника на недели вперёд, а файл базы рано или поздно окажется в бэкапе, на ноутбуке разработчика или в тикете поддержки. Ключ лежит в переменной окружения, отдельно от базы. Сгенерировать его: openssl rand -base64 32.
Разговор хранится как есть. В колонке messages лежит ровно тот массив, который уходит в API, со всеми вызовами инструментов, блоками размышлений и блоками compaction. Если хранить только текст вопросов и ответов, продолжить разговор завтра не получится: модель не увидит, что она уже нашла. Человеку показываем выжимку из этого массива через transcript.
Логин в каждом запросе к разговорам. loadConversation(id, login) ищет по обоим полям. Без этого любой сотрудник, узнавший id чужого разговора, прочитал бы его, а в нём ответы, построенные на чужих правах.
Веб-сервер
Сервер на Bun.serve отдаёт страницу, обрабатывает /login, /callback и /logout и несколько адресов API: список разговоров, один разговор, вопрос и ответ на подтверждение. Интересен только вопрос: ответ на него это поток событий, и колбэки ядра просто пишут в этот поток.
const send = (event: string, data: unknown) =>
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
const ping = setInterval(() => send("ping", {}), 15_000);
const result = await ask(user, conv, text, {
text: (delta) => send("text", delta),
tool: (name, input) => send("tool", { name, input }),
confirm: (name, input) => waitForButton(user, name, input), // событие confirm и до двух минут ожидания
});
send("done", result);Что здесь важно для безопасности:
- Cookie сессии
HttpOnly(скрипт на странице её не прочитает),SameSite=Lax(не уходит с запросами, которые начал чужой сайт) иSecureна HTTPS. В cookie только случайный id, всё остальное на сервере. - Только JSON в POST. HTML-форма с чужого сайта не умеет отправлять
application/json, поэтому подделать вопрос от имени сотрудника через форму нельзя. - Чужой разговор отвечает 404, а не 403, по той же причине, что вики в части 6: не подтверждаем, что такой разговор существует.
И несколько деталей про стриминг:
- SSE поверх POST. Классический
EventSourceумеет только GET, а вопрос удобнее отправлять телом запроса. Поэтому ответ это обычный потокtext/event-stream, а браузер читает его черезfetchиReadableStream. - Пинги. Пока модель думает или ждёт инструмент, данных нет. Bun по умолчанию закрывает соединение после 10 секунд тишины, прокси по дороге тоже. Пустое событие раз в 15 секунд держит соединение, а
idleTimeoutподнят до минуты. - Закрытая вкладка не теряет ответ. Если браузер отключился,
sendпросто перестаёт писать, а ход доходит до конца и сохраняется. Сотрудник откроет разговор и увидит ответ. - Подтверждение опасного вызова приходит в браузер событием
confirm, а ответ сотрудника отдельным запросом в/api/confirm. Ход в это время ждёт, но не дольше двух минут.
Страница
Вся страница это один HTML-файл без сборки. Главное в скрипте это чтение потока событий:
const res = await fetch("/api/ask", { method: "POST", headers: { "content-type": "application/json" }, body });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop();
for (const raw of events) handle(raw); // text дописывает answer.textContent, tool и confirm рисуют плашки
}Одно правило важнее остальных: ответ модели вставляется через textContent, никогда через innerHTML. Агент читает документы, которые мог написать кто угодно, и пересказывает их. Если документ содержит <img src=x onerror=...>, а страница вставит ответ как HTML, скрипт выполнится в сессии сотрудника. Если хочется красивого Markdown, рендерите его библиотекой и пропускайте результат через санитайзер вроде DOMPurify.
Бот в Slack
Сначала приложение в Slack: на api.slack.com создаётся приложение, в Event Subscriptions указывается адрес https://agent.company.ru/slack/events и события message.im и app_mention. Права бота: chat:write, im:history, app_mentions:read, users:read, users:read.email. Из настроек понадобятся Bot Token и Signing Secret.
Главное в slack.ts это проверка подписи:
function verify(req: Request, raw: string) {
const ts = req.headers.get("x-slack-request-timestamp") ?? "";
const signature = req.headers.get("x-slack-signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = `v0=${createHmac("sha256", SIGNING_SECRET).update(`v0:${ts}:${raw}`).digest("hex")}`;
return expected.length === signature.length && timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Остальное это обработчик событий: проверить подпись, сразу ответить, найти сотрудника по Slack id и передать вопрос в то же ядро, что у веб-чата.
Подпись. Адрес /slack/events открыт в интернет, и без проверки любой мог бы прислать событие «пользователь U1 спрашивает про зарплаты». Slack подписывает каждый запрос секретом приложения, а метка времени не даёт повторить старый подписанный запрос.
Три секунды. Slack ждёт ответа на событие три секунды, потом повторяет его. Ответ агента занимает дольше, поэтому сразу отвечаем ok, а работу запускаем без await. Повторы с заголовком x-slack-retry-num пропускаем, иначе агент ответил бы на один вопрос дважды.
Кто пишет. Slack сообщает id пользователя в Slack, а агенту нужна учётка в SSO с токеном. При первом сообщении бот присылает ссылку на вход, и после входа мы запоминаем связку. Ссылка одноразовая и живёт десять минут.
Здесь есть ловушка. Без проверки почты злоумышленник мог бы написать боту сам, получить ссылку для привязки своего аккаунта Slack и переслать её коллеге с текстом «зайди, тут новый портал». Коллега входит через SSO, и аккаунт злоумышленника в Slack оказывается привязан к учётке коллеги, со всеми её правами. Поэтому после входа сверяем почту из SSO с почтой профиля в Slack и привязываем только при совпадении.
Только личные сообщения. Ответ строится на правах того, кто спросил. Если HR-директор спросит про оклады в общем канале, ответ увидят все. Поэтому на упоминание в канале бот отвечает невидимым для остальных сообщением с просьбой написать в личку. Тред в личке это один разговор: пока отвечаете в том же треде, агент помнит контекст.
Стриминг через правку. Бот сразу пишет «Думаю…», а потом правит это сообщение по мере ответа. У Slack есть лимиты на частоту вызовов, и правка на каждый фрагмент текста быстро в них упрётся, поэтому накопленный текст отправляется раз в полторы секунды. У Slack есть и отдельные методы для потоковых ответов AI-приложений: chat.startStream, chat.appendStream и chat.stopStream. Правка сообщения работает в любом рабочем пространстве и показывает идею без лишних зависимостей. Если ваше приложение настроено как AI-ассистент Slack, потоковые методы дадут более плавный вывод.
Без подтверждений. Кнопки в Slack требуют отдельного обработчика интерактивности. Чтобы не усложнять, опасные инструменты из бота всегда отклоняются, а модель говорит, что это действие доступно в веб-чате. Все инструменты из наших частей только читают, так что для них ничего не меняется.
Почти всё это одинаково для Mattermost, Microsoft Teams и других мессенджеров: проверка подписи, быстрый ответ на событие, привязка учётки с проверкой, ответы только в личке.
Запускаем
export TOKEN_KEY=$(openssl rand -base64 32)
export SLACK_BOT_TOKEN=xoxb-... SLACK_SIGNING_SECRET=...
export PUBLIC_URL=http://localhost:8080
bun idp.ts # учебный SSO, :4000
bun wiki-server.ts # :3001
bun db-server.ts # :3002
bun web.ts # веб-чат и вебхук Slack, :8080Открываем http://localhost:8080, нас перебрасывает на страницу входа, выбираем сотрудника и попадаем в чат. Чтобы Slack достучался до локальной машины, нужен туннель (ngrok, cloudflared) или тестовый стенд.
Мы проверили всю цепочку на заглушках модели и Slack:
- Игорь входит, спрашивает про сделки и видит только свои две. RLS из части 6 работает через веб так же, как в терминале.
- Второй вопрос в том же разговоре продолжает историю.
- Без cookie API отвечает 401, POST формой 415, а Ольга, запросившая разговор Игоря по id, получает 404.
- Подпись Slack с неверным секретом даёт 401. Первое сообщение от незнакомого пользователя приводит к ссылке на вход. После входа аккаунт привязан, а повторное использование той же ссылки ничего не привязывает. Пользователь Slack с другой почтой не привязывается.
- После перезапуска сервера бот отвечает Игорю без нового входа: access token получен заново по refresh token из базы.
Перед продакшеном
- Сессии и блокировки в памяти. Это работает, пока экземпляр сервера один. Для нескольких экземпляров сессии, ожидающие подтверждения и блокировки разговоров переезжают в Redis или в базу.
- Лимит частоты на сотрудника. Дневной лимит в деньгах из части 7 есть, но стоит ограничить и число вопросов в минуту, чтобы скрипт с чужим cookie не забил очередь.
- Форматирование. Модель пишет Markdown, а у Slack свой синтаксис mrkdwn: ссылки и жирный пишутся иначе. Простой конвертер на несколько регулярных выражений решает большую часть проблем. Длинные ответы стоит резать на несколько сообщений.
- Выход. Когда сотрудник увольняется, его учётку блокируют в SSO, и следующее обновление токена вернёт ошибку. Но access token проживёт до часа. Если нужно быстрее, удаляйте refresh token и сессии по событию из каталога сотрудников.
Что дальше
У агента есть интерфейс, и им можно пользоваться. Осталась последняя часть: вывести его в прод. Развернуть внутри периметра компании, собрать полноценный журнал аудита, проверять качество ответов набором вопросов с эталонами и защититься от промпт-инъекций в документах, которые агент читает.