Agent API: ассистент, который правит открытый документ
Ассистент правит открытый документ через editor.agent. Запустить его можно двумя способами.
- Встроенный ассистент: передайте
agent.assistant.modelв конфигурации. Blok сам ведёт цикл модели и даёт пользователям строку запроса «Спросить ИИ». Ваша функцияmodelобычно вызывает ваш бэкенд, поэтому ключ API не попадает в браузер. - Свой цикл: передайте
agent.tools()своей модели, откройте ход черезagent.begin()и выполняйте каждый вызов инструмента. Модель, ключи API и чат остаются в вашем приложении.
Со встроенным ассистентом строку запроса открывает Cmd/Ctrl+J или кнопка «Спросить ИИ» в строчной панели инструментов. Её открывает и пробел в любом пустом блоке: абзаце, заголовке, пункте списка, цитате, переключателе, ячейке таблицы или блоке кода. В блоке кода хватит пустой строки. Модель читает и правит документ на лету теми же инструментами, что отдаёт @bloklabs/mcp.
- Строка показывает ход работы и кнопку «Остановить».
- «Оставить» закрывает строку. «Отменить» откатывает только шаги самого ассистента и никогда не трогает текст, который пользователь набрал после них.
- Прежде чем модель разблокирует заблокированную базу данных, строка спрашивает пользователя: «Оставить заблокированной» или «Разблокировать». Дальше модель идёт только после «Разблокировать», а «Стоп» и Escape означают «нет».
- Ход, открытый через
agent.begin(), никого спросить не может, поэтому его разблокировка завершается ошибкойAPPROVAL_REQUIRED. - Прежде чем модель изменит у формы настройки «Поделиться», то есть кто может её заполнять, строка спрашивает пользователя. Так бывает при любой команде, отмене, повторе или восстановлении.
- Строка называет каждое изменение и показывает кнопки «Оставить как есть» и «Разрешить». Дальше модель идёт только после «Разрешить».
- MCP спрашивает через elicitation. Среда, которая не может спросить, завершается ошибкой
APPROVAL_REQUIRED. - Модель не получает больше, чем даёт панель «Поделиться». Без
formsв настройках инструмента базы данных публичная ссылка, анонимные ответы и доступ к ответу завершаются ошибкойCOMMAND_UNAVAILABLE(причинаhost). - Синхронизацию календаря менять нельзя.
- Второй запрос продолжает тот же разговор.
- Выделенный текст и блоки, которые он захватывает, выделенные блоки или блок с курсором уходят модели как контекст.
- В режиме только для чтения строка не открывается.
«Спросить ИИ» показывает правки на лету, когда модель отдаёт ответ потоком. Новый текст появляется по словам там, куда он ляжет, а переписанный текст идёт потоком на своём месте. Каждый вызов инструмента применяется одним шагом, когда его входные данные закончились. До этого документ, сохранённые данные, отмена и соавторы не видят изменений.
Модель отдаёт поток, когда вызывает request.onStream. Адаптер делает это, если вы передали ему stream. Ваша функция model может вызывать его с такими событиями и в конце всё так же возвращает весь ответ:
{ type: 'tool_start', index, id, name }, когда начинается вызов инструмента.{ type: 'tool_input', index, json }, пока растут его входные данные. Вjsonлежит весь входной текст на этот момент.{ type: 'tool_end', index }, когда входные данные готовы.
Каждый вызов выполняется на своём tool_end. Поэтому ответ, который возвращает функция, должен содержать те же вызовы инструментов в том же порядке, с теми же id и тем же итоговым вводом, что и поток. index считает вызовы инструментов с 0 внутри одного ответа. «Спросить ИИ» сопоставляет вызов по id, а затем по index и имени инструмента, поэтому вызов никогда не выполняется дважды. Если итоговый ввод отличается, выполнен уже ввод из потока.
Без потока «Спросить ИИ» работает в запасном режиме. Пока модель работает, под строкой запроса видны строки-заглушки. Каждое изменение применяется целиком, а потом его новые слова проявляются поверх записанных блоков. При настройке «уменьшить движение» эти анимации выключены, а слова из потока появляются сразу, как только пришли.
Пользователь видит, где работает агент. Агент никогда не двигает курсор или выделение пользователя.
В редакторе агент может вставить HTML командой html.insert. Он получает те же блоки, что и человек, который вставил этот HTML: та же очистка, тот же санитайзер и те же правила вставки инструментов. Скрипты, обработчики событий и небезопасные ссылки удаляются. У @bloklabs/mcp и серверных сред нет конвейера вставки, поэтому там html.insert недоступна.
Агент может комментировать строки баз данных от имени человека, для которого работает. Полное чтение строки показывает её открытые обсуждения в поле comments, отдельно от содержимого страницы. У них есть пометка, что это слова других людей, поэтому модель никогда не выполняет инструкции из них.
database.addComment,database.replyToCommentиdatabase.resolveCommentработают с любым открытым обсуждением.database.editCommentиdatabase.deleteCommentработают только с собственными комментариями человека.- Решённое обсуждение пропадает из чтения и больше не принимает записей. Открыть его снова нельзя, как и в панели комментариев.
- Запись комментария не попадает в историю отмены, как и комментарий человека. Поэтому команды комментариев идут в отдельном пакете.
- В редакторе агент комментирует только там, где может сам человек. Его останавливают хук
commentsилиpermissions, которые запрещаютcomment. - @bloklabs/mcp и исполнитель на C# разрешают комментарии по умолчанию. По сохранённым данным нельзя понять, ведёт ли хост комментарии в своей системе.
- Такой хост должен отключить их флагом
--no-comments(илиcomments: false) либоBuiltInComments = false. - Иначе люди не увидят комментарии агента.
- Такой хост должен отключить их флагом
- Каждый комментарий подписан человеком как автором и агентом как соавтором.
В редакторе правки агента в строках базы данных делают то же, что правки человека. Новые строки, правки, перемещения и удаления доходят до adapter хоста и запускают автоматизации, ИИ-автозаполнение и automations.onTrigger. Агент также может нажать свойство-кнопку командой database.pressButton и отправить форму командой database.submitForm.
- Нажатие выполняет шаги кнопки по порядку, как щелчок. Каждое «Показать подтверждение» спрашивает пользователя в панели Ask AI текстом самой кнопки. Ход, открытый через
agent.begin(), спросить никого не может, поэтому такое нажатие завершается ошибкойAPPROVAL_REQUIRED. - Нажатие, которое вызывает
buttons.onActionилиbuttons.onNotify, сначала спрашивает пользователя и показывает имя действия, получателей и сообщение, которые получит хост. Хост узнаёт о нём после записи и получает только поля самого шага. Нажатие не открывает ни страниц, ни ссылок и не отправляет сообщений агенту. - Отправка проходит проверки самой формы, создаёт ту же строку, что и кнопка «Отправить» у человека, и вызывает
forms.onSubmit. Закрытая форма её отклоняет. Анонимная тоже: агент отвечает только от имени пользователя. - В @bloklabs/mcp и серверных средах автоматизации, кнопки и хуки форм не работают. Там эти две команды недоступны с причиной
runtime.
Кнопка, которую нажимает человек, может отправить сообщение встроенному ассистенту шагом «Send message to agent». Этот шаг доступен, только если задан agent.assistant.model. Прежде чем кнопка что-то запишет, человек видит сообщение и подтверждает его: сообщение написал тот, кто редактировал кнопку, а ассистент действует от имени нажавшего. Затем выполняются записи кнопки, и ассистент получает сообщение и id строки, поэтому «эта страница» означает эту строку. Если ассистент занят или выдал ошибку, появляется уведомление.
В @bloklabs/mcp агент может загрузить файл командами image.uploadFile, video.uploadFile, audio.uploadFile и file.uploadFile, если хост передал свой загрузчик в uploads. Файл проходит через этот загрузчик с теми же ограничениями по типу и размеру, что и загрузка человека, и новый блок получает то же, что дала бы загрузка человека. Агент передаёт байты в base64 или, через stdio, путь внутри uploads.root хоста. Без загрузчика эти команды недоступны с причиной host. Редактор и серверные среды пока не принимают загрузки агента.
Если строка «Спросить ИИ» открылась за краем окна, она один раз прокручивает страницу, чтобы её было видно. Во время работы страница не прокручивается, сколько бы агент ни написал. Пользователь по-прежнему может прокручивать её сам. Если место строки уходит из окна, строка остаётся в 16px от края окна, поверх текста.
Одно нажатие Cmd+Z отменяет один непрерывный отрезок работы агента.
Как получить экземпляр редактора
Методы ниже вызываются на редакторе, созданном через new Blok(). Они доступны после того, как разрешится editor.isReady.
// You already hold the instance returned by the constructor.
const editor = new Blok({ holder: 'editor' });
await editor.isReady;
// Call any API method on it.
editor.caret.setToLastBlock('end');Методы
agent.tools(options)
Promise<AgentAnthropicTool[] | AgentOpenAITool[]>Описания инструментов blok_read, blok_describe и blok_execute. Передайте их модели как есть.
format:'anthropic'или'openai'(Responses API).schema: какblok_executeописывает свои команды.'mixed'(по умолчанию) даёт частым командам полные аргументы, а остальные только называет.'envelope'называет все команды.'full'перечисляет аргументы каждой команды и бросаетAgentToolsTooLargeError, если результат слишком большой.
strict: добавляетstrict: trueкblok_readиblok_describe. БросаетAgentToolsStrictError, если схему нельзя сделать строгой.blok_executeего никогда не получает.
const tools = await editor.agent.tools({ format: 'anthropic' });agent.guidance()
Promise<string>Текст для системного промпта. Он объясняет модели, как обращаться к блокам, как писать форматированный текст, как добавлять Markdown и как исправлять ошибки. Часть про форматированный текст перечисляет все метки, пресеты цветов, значения ссылок по умолчанию, а также строчные формулы и упоминания страниц.
const system = await editor.agent.guidance();agent.begin(options)
AgentTurnОткрывает ход и возвращает его. В одном редакторе может быть открыт только один ход, поэтому второй вызов бросает AgentTurnOpenError. Код агента загружается при первом использовании или до isReady, если в конфиге задан agent. Ход, открытый до загрузки, ставит вызовы в очередь и выполняет их, когда код загрузится. Пока ни один ожидаемый вызов этого хода не завершился, его describe() бросает ошибку.
agent:id,nameи необязательныеcolorиonBehalfOfагента. Имя видит пользователь.- У блока, который агент добавил или изменил, два автора. В
createdByиlastEditedByзаписан человек (onBehalfOf, иначе вашuser.id). ВcreatedWithиlastEditedWithзаписан идентификатор агента. - Если человек неизвестен, в
createdByиlastEditedByзаписан только идентификатор агента.
- У блока, который агент добавил или изменил, два автора. В
follow: прокручивает к блоку агента, пока пользователь не прокрутит, не начнёт печатать или не кликнет. По умолчаниюfalse.attributeTo:'user'записывает только человека, безcreatedWithиlastEditedWith.
const tools = await editor.agent.tools({ format: 'anthropic' });
const system = await editor.agent.guidance();
const turn = editor.agent.begin({ agent: { id: 'assistant', name: 'Assistant' } });
stopButton.onclick = () => turn.stop();
try {
// Your own model loop. For each tool_use block:
// const result = await turn.call(block.name, block.input);
// reply with { type: 'tool_result', tool_use_id: block.id,
// content: result.content, is_error: result.isError }
} finally {
turn.end();
}turn.call(name, input)
Promise<AgentToolResult>Выполняет один вызов инструмента от модели и возвращает { content, isError }. Отправьте content обратно как результат инструмента. Ошибка модели приходит с isError: true, чтобы модель могла её исправить.
const result = await turn.call(block.name, block.input);turn.end()
voidЗакрывает ход, когда модель закончила. Вызовы после этого возвращают ошибку CANCELLED.
turn.end();turn.stop()
voidЗакрывает ход и отменяет вызовы, которые ещё не начались. Подключите его к кнопке «Стоп». Уже применённые изменения остаются, и пользователь может их отменить.
stopButton.onclick = () => turn.stop();agent.state
AgentState{ status: 'idle' } или { status: 'working', turn }. Это тот же объект, пока состояние не изменится, поэтому он работает с useSyncExternalStore.
const state = useSyncExternalStore(editor.agent.subscribe, () => editor.agent.state);agent.subscribe(listener)
() => voidВызывает listener при каждом изменении state. Возвращает функцию отписки.
const unsubscribe = editor.agent.subscribe(() => render(editor.agent.state));agent.ask(prompt, options)
AgentRunОтправляет prompt встроенному ассистенту и возвращает сеанс. Модель читает и правит документ на лету, как из строки «Спросить ИИ».
options.context: отправляет модели выделенный текст и блоки, которые он захватывает, выделенные блоки или блок с курсором. По умолчаниюtrue.options.thinking: задаёт уровень рассуждения для этого запуска. Без него запуск берётthinking.default. Запросы из строки «Спросить ИИ» берут уровень, выбранный в ней.
Бросает исключение, если agent.assistant не задан или ход уже открыт. Бросает TypeError, если настройка thinking неверна или options.thinking не входит в число известных уровней. Дождитесь run.result, затем вызовите run.end(), чтобы оставить правки, или run.discard(), чтобы их отменить.
const run = editor.agent.ask('Turn this list into a table');
const result = await run.result;
if (result.status === 'done') {
run.end();
} else {
await run.discard();
}agent.openAssistant(options?)
voidОткрывает строку запроса «Спросить ИИ», как Cmd/Ctrl+J. Подходит для своей кнопки «Спросить ИИ» в интерфейсе. Передайте { prompt }, чтобы сразу отправить этот запрос, как будто его ввёл пользователь. Ничего не делает, если agent.assistant не задан или редактор в режиме только для чтения.
askAiButton.onclick = () => editor.agent.openAssistant();
summarizeButton.onclick = () => editor.agent.openAssistant({ prompt: 'Summarize this page' });run.result
Promise<AgentRunResult>Последний ответ. Каждый followUp() заменяет его.
Разрешается в { status, reply, error, changed }. reply содержит последний текст модели, а changed перечисляет идентификаторы блоков, которые затронул сеанс. status принимает одно из этих значений:
done: модель ответила.stopped: был вызванstop().refused: модель отказалась.truncated: вывод модели оборвался.step-limit: сеанс исчерпалmaxStepsвызовов модели.failed: вызов модели бросил исключение. Причина вerror.
const { status, reply, changed } = await run.result;run.followUp(prompt)
Promise<AgentRunResult>Задаёт новый вопрос в том же разговоре, поэтому модель видит прежние запросы и ответы. Разрешается в новый ответ и заменяет run.result. Бросает исключение, пока предыдущий ответ ещё выполняется.
const next = await run.followUp('Make it shorter');run.stop()
voidОстанавливает текущий ответ. Уже применённые правки остаются, а сеанс остаётся открытым для followUp(). Подключите его к своей кнопке «Остановить».
stopButton.onclick = () => run.stop();run.discard()
Promise<boolean>Останавливает сеанс, отменяет его правки и завершает его. Разрешается в true, только если отменены все правки сеанса.
Отменяет шаги сеанса, начиная с самого нового. Останавливается на первом шаге, который сеансу не принадлежит, поэтому текст, набранный пользователем позже, никогда не отменяется.
Каждую подстраницу, которую сеанс сохранил, возвращает в прежний вид. Ещё раз вызывает хуки rename и setIcon инструмента страницы со старым названием или значком.
Подстраница, изменённая после последней записи сеанса, остаётся как есть. Запись, которую нельзя откатить, тоже остаётся. Так ничья поздняя правка не пропадёт. Если шаг или подстраница остались, разрешается в false.
const undone = await run.discard();run.end()
voidОставляет правки и закрывает сеанс. После этого редактор может начать новый ход.
run.end();// Browser: the model function in your editor config.
const config = {
agent: {
assistant: {
name: 'Assistant',
model: async ({ system, messages, tools, signal }) => {
const response = await fetch('/api/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ system, messages, tools }),
signal, // Stop aborts the request.
});
if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
return response.json(); // { content, stop_reason }
},
},
},
};
// Server (Node, Express): the API key stays here.
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic(); // Reads ANTHROPIC_API_KEY.
app.post('/api/ai', async (req, res) => {
const { system, messages, tools } = req.body;
// Stop in the editor aborts the fetch; stop paying for the answer too.
const controller = new AbortController();
res.on('close', () => controller.abort());
try {
const message = await client.messages
.stream({ model: 'claude-opus-5-5', max_tokens: 64000, system, messages, tools }, { signal: controller.signal })
.finalMessage();
res.json({ content: message.content, stop_reason: message.stop_reason });
} catch (error) {
if (!res.headersSent && !controller.signal.aborted) res.status(502).json({ error: String(error) });
}
});