AiKraft
Главная/Документация/Справочник агента/Страницы
Справочник агента

Страницы

Страницы — постоянные HTML-мини-приложения в workspace. Описаны структура данных, разрешения, обновления по расписанию и публикация.

Страницы — это постоянные HTML-мини-приложения, размещаемые в workspace и отображаемые внутри приложения (боковая панель → «Страницы»). Используйте их для дашбордов, отчётов, трекеров и небольших инструментов, которые должны переживать диалог — при необходимости они автоматически обновляются по расписанию и могут публиковаться как публичные ссылки с паролем.

Структура папок (управляется системой — не редактируйте напрямую)

terminalbash
{workspace}/pages/{slug}/
├── page.json          # конфигурация/манифест (триггер watcher — всегда пишется последним)
├── index.html         # содержимое страницы (дайджест sha256 отслеживается в page.json)
└── data/
    ├── store.sqlite   # внутреннее хранилище (только скрипты — никогда не читайте это)
    └── snapshot.json  # ЕДИНСТВЕННЫЙ артефакт данных, который читают страницы/хосты

Всегда используйте инструменты сессии (create_page, update_page, write_page_data, delete_page), а не файловые инструменты. Они сохраняют согласованность дайджестов содержимого, разрешений, watcher конфигурации и открытых рендеров. list_pages / get_page — только для чтения и возвращают абсолютные пути, когда вам действительно нужно прочитать что-то (например, data.snapshotPath).

Выбор типа

Тип Песочница Применение
static без JS фиксированные документы, отформатированные отчёты
interactive JS + формы калькуляторы, инструменты для исследования, утилиты
live JS + получение обновлений снимка данных, пока открыто дашборды, обновляемые скриптами refresh или write_page_data

Модель данных

У каждой страницы есть небольшое хранилище данных с двумя формами:

  • kv — ключ → любое значение JSON (объекты и массивы подходят). Для текущих значений: настройки, последние итоги, объекты статуса.
  • series — именованные временные ряды точек { t: epoch ms, v: number }. Для всего, что отображается на графиках во времени. Записи (series, t) — идемпотентные upsert, поэтому повторное выполнение записи безопасно.

write_page_data применяет один транзакционный патч и пересоздаёт data/snapshot.json:

terminalbash
write_page_data({
  slug: "build-health",
  set: { summary: { total: 42, failing: 3 }, updatedBy: "agent" },
  delete: ["obsolete_key"],
  appendSeries: { "ci.duration_ms": [{ v: 84213 }, { t: 1756165200000, v: 79544 }] },
  pruneSeries: { "ci.duration_ms": 1748000000000 }
})

Снимок данных, который получает страница, выглядит так:

terminaljson
{
  "version": 1,
  "generatedAt": 1756191234567,
  "kv": { "summary": { "total": 42, "failing": 3 } },
  "series": { "ci.duration_ms": [ { "t": 1756165200000, "v": 79544 } ] }
}

Series упорядочены по t по возрастанию и ограничены 1000 самыми новыми точками на series. Само хранилище тоже ограничено: не более 1000 ключей kv и 100 различных series на страницу — запись, превышающая любой из лимитов, полностью откатывается с понятной ошибкой. Проектируйте ключи и series как стабильные имена, которые вы обновляете, а не как постоянно растущие множества (храните списки внутри одного значения kv; не создавайте ключи вида item-<id> или названия series по дням).

Создание HTML-страницы

Правила, благодаря которым страницы работают везде (в локальной песочнице и в опубликованных копиях):

  1. Один полный автономный HTML-документ. Встройте весь CSS и JS. Никаких внешних запросов — опубликованные копии обслуживаются с connect-src 'none' (весь исходящий сетевой трафик заблокирован), поэтому скрипты CDN, шрифты или вызовы fetch() сломали бы их. Рисуйте графики с помощью встроенных SVG/canvas, которые вы создаёте сами.
  2. Данные приходят через мост, а не через fetch. Хост передаёт снимок данных через postMessage; страницы live автоматически получают заменяющие снимки данных при каждом изменении данных.
  3. iframe имеет непрозрачный origin (sandbox без allow-same-origin): нет cookies, нет localStorage, нет доступа к DOM родителя. Храните состояние в переменных JS.

Фрагмент моста (копирование и вставка)

terminaltypescript
<script>
  let nonce = null;

  function render(snapshot) {
    // snapshot = { version, generatedAt, kv, series } или null (данных ещё нет)
    // ... обновите DOM ...
  }

  window.addEventListener('message', (event) => {
    const msg = event.data;
    if (!msg || msg.protocol !== 'craft-pages/v1') return;
    if (msg.type === 'init') {           // { page: {slug, kind}, nonce, snapshot, grants }
      nonce = msg.payload.nonce;
      handleGrants(msg.payload.grants);  // [{ id, action, expiresAt }] — доступные разрешения
      render(msg.payload.snapshot);
    } else if (msg.type === 'data') {    // страницы live: заменяющий снимок данных
      render(msg.payload.snapshot);
    } else if (msg.type === 'action-result') {
      handleActionResult(msg.payload.result);  // { requestId, ok, status?, body?, error?, durationMs }
    } else if (msg.type === 'grants') {  // ответ на 'grant-request' + отправляется при изменениях разрешений
      handleGrants(msg.payload.grants);
    }
  });

  // Запросите init у хоста (также доставляется автоматически после загрузки)
  window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'ready' }, '*');
</script>

Открытие внешних ссылок

Страницы в песочнице не могут выполнять навигацию. Запросите хост (только URL http/https, требуется действие пользователя):

terminalbash
window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'open-url', nonce, url: 'https://example.com' }, '*');

Действия источников (разрешения)

Интерактивные страницы могут запускать вызовы к источникам workspace (например, кнопка «Отправить письмо», использующая подключённый источник Google) — без какого-либо доступа к учётным данным. Страница отправляет запрос действия; хост проверяет его по разрешению и выполняет на стороне сервера.

terminalbash
window.parent.postMessage({
  protocol: 'craft-pages/v1',
  type: 'action',
  requestId: crypto.randomUUID(),
  nonce,                                   // из init — обязательно
  grantId: 'grant_1a2b3c4d',               // одобренное разрешение для этой страницы
  invocation: { kind: 'api', method: 'GET', path: '/health' }  // должно соответствовать разрешению
}, '*');
// Результат приходит сообщением 'action-result' (сопоставление по requestId).

Запрос разрешений из страницы

Страница запрашивает нужные разрешения сообщением grant-request; хост показывает пользователю диалог одобрения и отвечает сообщением grants (полный список разрешений, доступных в данный момент). Сопоставляйте разрешения с потребностями, сравнивая дескрипторы action — не используйте жёстко закодированные идентификаторы разрешений.

terminaltypescript
let grants = [];                            // [{ id, action, expiresAt }]
function grantFor(action) {                 // поиск по дескриптору, никогда по id
  return grants.find(g => g.action.kind === action.kind
    && g.action.sourceSlug === action.sourceSlug
    && (action.kind === 'mcp' ? g.action.toolName === action.toolName
        : g.action.method === action.method && g.action.pathPattern === action.pathPattern));
}
function handleGrants(list) { grants = list || []; /* включение/отключение кнопок */ }

const NEEDS = [
  { key: 'read',  description: 'Refresh the task list',
    action: { kind: 'mcp', sourceSlug: 'craft-private', toolName: 'craft_read' } },
  { key: 'write', description: 'Add and complete tasks',
    action: { kind: 'mcp', sourceSlug: 'craft-private', toolName: 'craft_write' } },
];
// После init: запросите всё, чего ещё не хватает (максимум 8 записей на запрос).
if (NEEDS.some(n => !grantFor(n.action))) {
  window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'grant-request', nonce, requests: NEEDS }, '*');
}

Отклонённые дескрипторы запоминаются до конца рендера — повторный запрос отвечает текущим списком разрешений вместо нового диалога, поэтому страницы не могут навязчиво запрашивать одобрение. Проектируйте сценарий отказа: сохраняйте страницу полезной с отключёнными кнопками и показывайте, что откроет одобрение.

Что нужно знать о разрешениях:

  • Разрешения — это одобренные пользователем возможности, сохраняемые в конфигурации страницы: { kind: 'api', sourceSlug, method, pathPattern } (якорный regex), { kind: 'mcp', sourceSlug, toolName } или { kind: 'script', script, runtime?, args? } (см. ниже). Их нельзя создать инструментом сессии — страница запрашивает их (grant-request), а пользователь одобряет в диалоге хоста.
  • Разрешения привязаны к точному дайджесту содержимого на момент одобрения и имеют жёсткий срок действия (30 дней). Изменение HTML страницы по замыслу инвалидирует все разрешения — странице достаточно запросить их заново при следующем открытии.
  • Пользователь может удалить любое одобрение в любой момент (меню страницы ⋯ → «Одобрённые действия», либо прямо в диалоге «Общий доступ»). Открытые рендеры получают обновлённое сообщение grants, когда это происходит, поэтому управляйте состоянием кнопок из handleGrants, а не из кэшированного списка на момент init.
  • Если источник, для которого выдано разрешение, теряет аутентификацию, действия быстро завершаются ошибкой, начинающейся с source-auth-required (например, source-auth-required: reconnect "gmail" in the app). Хост показывает баннер повторного подключения над страницей. Рассматривайте это как повторную попытку: показывайте подсказку «переподключитесь в приложении» и позволяйте пользователю просто нажать снова после переподключения — не отключайте кнопку навсегда.
  • Только действия api GET могут выполняться без действия пользователя. Всё остальное — api не-GET, каждый mcp-инструмент (непрозрачный: он может выполнять запись) и каждый script — требует свежего действия пользователя внутри страницы (нажатия кнопки): запускайте действие непосредственно из обработчика клика, никогда из таймера или при загрузке. Для данных, которые должны быть видны при открытии, рендерьте их из снимка данных и делайте живые вызовы управляемыми кнопкой.
  • Лимиты на кадр: 5 запросов одновременно, 1 изменяющий за раз, 30 в минуту. Отмена: { type: 'action-cancel', requestId, nonce }.
  • Опубликованные (общие) копии никогда не выполняют действия — зрители получают public-actions-disabled.

get_page выводит существующие разрешения с флагом stale (несоответствие дайджеста или истечение срока).

Запуск скрипта хоста (разрешения script)

Разрешение script позволяет локальной странице запускать скрипт, путь к которому указан относительно workspace, на машине хоста — это действие с наивысшими привилегиями, которое может выполнить страница. Используется тот же исполнитель, что и для плановых обновлений: запуск через argv (никогда не через оболочку), путь ограничен workspace (с учётом символьных ссылок), окружение только с переменными CRAFT_* (без PATH и учётных данных) и таймаут по умолчанию 60 с (максимум 15 мин).

terminalbash
// Дескриптор, запрашиваемый через grant-request:
//   { kind: 'script', script: 'pages/<slug>/build.sh', runtime: 'bun'|'node'|'python3'?, args?: string[] }
// Вызов — это ПУСТОЙ ТРИГГЕР — script/runtime/args берутся из разрешения:
window.parent.postMessage({
  protocol: 'craft-pages/v1', type: 'action',
  requestId: crypto.randomUUID(), nonce,
  grantId: scriptGrant.id,
  invocation: { kind: 'script' }              // ничего больше — страница не может передать аргументы
}, '*');
// body action-result → { exitCode, stdout, stderr }. ok === (exitCode === 0),
// но stdout/stderr возвращаются даже при ненулевом выходе.

Правила, специфичные для script-разрешений:

  • Аргументы фиксируются на момент одобрения. Страница не может указать или изменить script/runtime/args при вызове — одобрение разрешения одобряет одну точную команду, а не семейство команд. Чтобы менять поведение, одобрите обёртку-скрипт (например, run.sh) и пусть он принимает решение.
  • Сопоставляйте по дескриптору так же, как для других типов, но по script + runtime (по умолчанию bun) + упорядоченным args — здесь нет sourceSlug/toolName.
  • Всегда изменяющее действие — запускайте только из реального обработчика клика; без свежего действия пользователя оно будет отклонено.
  • Нельзя публиковать. Страница, у которой есть script-разрешение, вообще не может быть опубликована (публикация завершается ошибкой PAGE_SHARE_SCRIPT_GRANT) — даже пассивный режим только для просмотра отклоняется, и устаревшие/истёкшие script-разрешения тоже учитываются. Пользователь может удалить одобрение (⋯ → «Одобрённые действия», либо прямо в диалоге «Общий доступ»), а затем опубликовать страницу; отозвать разрешения инструментом нельзя. Упоминайте этот компромисс, когда добавляете script-действие на страницу, которую пользователь может захотеть поделиться.
  • Рабочая папка скрипта — корень workspace; CRAFT_PAGE_DIR / CRAFT_PAGE_DATA_DIR / CRAFT_WORKSPACE_PATH указывают на собственные данные страницы. Запуск скрипта не затрагивает статус планового обновления страницы.

Обновление по расписанию

Укажите для страницы спецификацию refresh (в create_page или update_page), чтобы детерминированно обновлять её данные — сессия агента не создаётся:

terminalbash
refresh: { cron: "*/15 * * * *", script: "scripts/refresh-build-health.ts" }

Выражение cron проверяется при записи: оно должно разбираться, должно реально срабатывать и не должно выполняться чаще, чем каждые 5 минут (*/5 * * * * — самое быстрое принимаемое расписание) — некорректная спецификация приводит к сбою create_page/update_page с указанием причины.

Скрипт должен находиться внутри workspace и выполняется под Bun в минимальном окружении: CRAFT_WORKSPACE_PATH, CRAFT_PAGE_SLUG, CRAFT_PAGE_DIR, CRAFT_PAGE_DATA_DIR (а также другие переменные CRAFT_*). Поток: обновить хранилище → экспортировать снимок данных → завершиться с кодом 0. Исполнитель затем обновляет page.json, что отправляет новый снимок данных в открытые рендеры.

terminalpython
// scripts/refresh-build-health.ts  (Bun)
import { openPageDataStore } from '@craft-agent/shared/pages/data-store';

const store = openPageDataStore(process.env.CRAFT_WORKSPACE_PATH!, process.env.CRAFT_PAGE_SLUG!);
const res = await fetch('https://ci.example.com/api/summary');   // скрипты МОГУТ использовать сеть
const summary = await res.json();
store.kvSet('summary', summary);
store.seriesAppend('ci.duration_ms', { v: summary.durationMs });
store.exportSnapshot();
store.close();

Если пакет @craft-agent/shared не резолвится из workspace (например, в упакованных установках), напишите автономный скрипт с bun:sqlite для $CRAFT_PAGE_DATA_DIR/store.sqlite с использованием именно этой схемы и записывайте снимок данных атомарно (временный файл + переименование) в $CRAFT_PAGE_DATA_DIR/snapshot.json:

terminalsql
CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS timeseries (series TEXT NOT NULL, t INTEGER NOT NULL, v REAL NOT NULL, PRIMARY KEY (series, t)) WITHOUT ROWID;

(value в kv кодируется в JSON; структура снимка данных показана выше. Более простой вариант: пропустите скрипт и обновите страницу сами с помощью write_page_data.)

Публикация

Пользователи публикуют страницы через кнопку «Общий доступ» страницы (за флагом функции): публичный URL с паролем, опциональный снимок данных, мгновенный отзыв. Вы сами не публикуете страницы — но помните: опубликованные копии блокируют весь исходящий сетевой трафик и отключают действия источников, поэтому важно встраивать всё в HTML. delete_page сначала снимает страницу с публикации (по возможности) и сообщает publicCopyMayRemain, если это не удалось подтвердить.

Стартовый шаблон

terminalbash
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Build Health</title>
<style>
  body { font-family: -apple-system, system-ui, sans-serif; margin: 24px; color: #1a1a1a; }
  .metric { font-size: 40px; font-weight: 700; }
  .muted { color: #777; font-size: 12px; }
  svg { width: 100%; height: 160px; }
</style>
</head>
<body>
  <h1>Build Health</h1>
  <div class="metric" id="total">—</div>
  <div class="muted" id="updated">waiting for data…</div>
  <svg id="chart" viewBox="0 0 600 160" preserveAspectRatio="none"></svg>

<script>
  let nonce = null;

  function render(snapshot) {
    if (!snapshot) return;
    const summary = snapshot.kv.summary || {};
    document.getElementById('total').textContent = summary.total ?? '—';
    document.getElementById('updated').textContent = 'updated ' + new Date(snapshot.generatedAt).toLocaleString();

    const points = snapshot.series['ci.duration_ms'] || [];
    const max = Math.max(1, ...points.map(p => p.v));
    const w = 600 / Math.max(1, points.length);
    document.getElementById('chart').innerHTML = points
      .map((p, i) => `<rect x="${i * w}" y="${160 - (p.v / max) * 150}" width="${Math.max(1, w - 2)}" height="${(p.v / max) * 150}" fill="#4f7cff"/>`)
      .join('');
  }

  window.addEventListener('message', (event) => {
    const msg = event.data;
    if (!msg || msg.protocol !== 'craft-pages/v1') return;
    if (msg.type === 'init') { nonce = msg.payload.nonce; render(msg.payload.snapshot); }
    else if (msg.type === 'data') { render(msg.payload.snapshot); }
  });
  window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'ready' }, '*');
</script>
</body>
</html>

Рецепты

  • «Сделай мне дашборд X, который обновляется каждые N минут» → create_page (тип live, содержимое + спецификация refresh) → запишите скрипт обновления в workspace → заполните начальные данные через write_page_data, чтобы страница не была пустой до первого срабатывания.
  • «Отслеживай это число во времени» → страница с графиком series; добавляйте точки через write_page_data, когда узнаёте новое значение (идемпотентно по временной метке).
  • «Преврати этот отчёт в то, что я могу поделиться» → create_page (тип static, полностью встроенный HTML) → скажите пользователю использовать кнопку «Общий доступ» для ссылки с паролем.
  • Итерации над страницей → update_page с новым content; предупредите пользователя, что существующие разрешения устаревают при изменении содержимого.

Нужен такой агент в вашей компании?

Устанавливаем под ключ: настройка на компьютерах сотрудников, единый аккаунт, интеграция с 1С и внутренними системами, обучение команды. Подробнее о внедрении →