Страницы
Страницы — постоянные HTML-мини-приложения в workspace. Описаны структура данных, разрешения, обновления по расписанию и публикация.
Страницы — это постоянные HTML-мини-приложения, размещаемые в workspace и отображаемые внутри приложения (боковая панель → «Страницы»). Используйте их для дашбордов, отчётов, трекеров и небольших инструментов, которые должны переживать диалог — при необходимости они автоматически обновляются по расписанию и могут публиковаться как публичные ссылки с паролем.
Структура папок (управляется системой — не редактируйте напрямую)
{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:
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 }
})Снимок данных, который получает страница, выглядит так:
{
"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-страницы
Правила, благодаря которым страницы работают везде (в локальной песочнице и в опубликованных копиях):
- Один полный автономный HTML-документ. Встройте весь CSS и JS. Никаких внешних запросов — опубликованные копии обслуживаются с
connect-src 'none'(весь исходящий сетевой трафик заблокирован), поэтому скрипты CDN, шрифты или вызовыfetch()сломали бы их. Рисуйте графики с помощью встроенных SVG/canvas, которые вы создаёте сами. - Данные приходят через мост, а не через fetch. Хост передаёт снимок данных через
postMessage; страницыliveавтоматически получают заменяющие снимки данных при каждом изменении данных. - iframe имеет непрозрачный origin (
sandboxбезallow-same-origin): нет cookies, нет localStorage, нет доступа к DOM родителя. Храните состояние в переменных JS.
Фрагмент моста (копирование и вставка)
<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, требуется действие пользователя):
window.parent.postMessage({ protocol: 'craft-pages/v1', type: 'open-url', nonce, url: 'https://example.com' }, '*');Действия источников (разрешения)
Интерактивные страницы могут запускать вызовы к источникам workspace (например, кнопка «Отправить письмо», использующая подключённый источник Google) — без какого-либо доступа к учётным данным. Страница отправляет запрос действия; хост проверяет его по разрешению и выполняет на стороне сервера.
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 — не используйте жёстко закодированные идентификаторы разрешений.
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 мин).
// Дескриптор, запрашиваемый через 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), чтобы детерминированно обновлять её данные — сессия агента не создаётся:
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, что отправляет новый снимок данных в открытые рендеры.
// 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:
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, если это не удалось подтвердить.
Стартовый шаблон
<!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С и внутренними системами, обучение команды. Подробнее о внедрении →