Plank help · updated 2026-07-27

Интерактивные HTML-дашборды

Как HTML-файлы в рабочем пространстве Plank могут запускать скрипты, открывать файлы и отправлять сообщения в чат — через postMessage plank:run-script и заголовок @plank-button.

Agents: fetch the raw markdown of this page at /ru/help/html-buttons.md

Интерактивные HTML-дашборды

HTML-файлы в рабочем пространстве Plank отображаются внутри изолированного iframe. Этот iframe может попросить родительское приложение сделать три вещи, отправив postMessage:

  1. Отправить сообщение в чатplank:send-chat-message
  2. Открыть другой файлplank:open-file
  3. Запустить скрипт рабочего пространстваplank:run-script

На этой странице описаны все три, а также механизм согласия @plank-button, который разрешает прямой запуск скриптов.

Какую команду когда использовать

КомандаИспользуйте, когда
plank:send-chat-messageДействию полезно, чтобы агент прокомментировал результат (ошибки, особые случаи, «я заметил X»).
plank:run-scriptСкрипт детерминирован, дашборд сам управляет отображением успеха/ошибки, и пользователь хочет мгновенную реакцию без обращения к чату.
plank:open-fileПереход с одного дашборда на другой файл в рабочем пространстве.

Вы можете использовать plank:run-script и plank:send-chat-message на одном дашборде. Кнопка «Синхронизировать» может запускаться напрямую, а кнопка «Устранить расхождения» — отправлять сообщение в чат.

Формы postMessage

// Отправить в чат — агент получает 'text' так, будто его ввёл пользователь.
// Всегда открывает НОВЫЙ диалог (каждая кнопка начинает свою отдельную
// задачу), а не добавляет сообщение в открытый в данный момент чат.
window.parent.postMessage(
  { type: "plank:send-chat-message", text: "Run the weekly tender report" },
  "*",
);

// Открыть другой файл во вкладке файлов.
window.parent.postMessage(
  { type: "plank:open-file", path: "reports/q4.md" },
  "*",
);

// Запустить скрипт рабочего пространства напрямую. Путь указывается
// относительно корня рабочего пространства и должен находиться
// внутри scripts/ (см. «Пути к скриптам указываются относительно
// рабочего пространства» и «Подключение скрипта к @plank-button» ниже).
window.parent.postMessage(
  { type: "plank:run-script", path: "scripts/google/gmail/sync.py" },
  "*",
);

Пути к скриптам указываются относительно рабочего пространства

Путь path, который вы отправляете с plank:run-script, указывается относительно корня рабочего пространства и должен находиться внутри scripts/ — например, scripts/local/tender-sync-server.py. Не отправляйте абсолютный путь вроде /spaces/your-workspace/scripts/sync.py или /home/coder/…; обработчик отклонит его с кодом 400 ещё до запуска скрипта.

Путь должен соответствовать такой форме:

  • начинается с scripts/
  • заканчивается на .py, .ts или .sh
  • не содержит сегментов ..

Вам никогда не нужно самостоятельно указывать путь монтирования контейнера — Plank сам сопоставляет scripts/… с вашим активным рабочим пространством.

Изображения и другие ресурсы

Чтобы показать изображение, хранящееся в рабочем пространстве, ссылайтесь на него по пути относительно самого HTML-файла. Plank загрузит его для вас через временную подписанную ссылку — никакой настройки, никаких раскрытых учётных данных:

<img src="chart.png" />            <!-- та же папка, что и дашборд -->
<img src="images/q4.png" />        <!-- вложенная папка -->
<img src="../shared/logo.png" />   <!-- на уровень выше -->

Пути разрешаются относительно папки, в которой находится дашборд, а не корня рабочего пространства. Ведущий слеш трактуется так же: src="/chart.png" загружает chart.png из собственной папки дашборда, а не из корня рабочего пространства. Указывайте пути к ресурсам относительно того места, где находится HTML-файл.

Внешние и встроенные изображения остаются нетронутыми и загружаются как есть:

  • Полные URL — https://…
  • Data URI — data:image/png;base64,…
  • Object URL — blob:…
  • Протокол-относительные — //host/img.png

То же правило разрешения применяется и к другим ресурсам, которые дашборд загружает из рабочего пространства: таблицы стилей (<link href>), скрипты (<script src>), poster у видео, background в CSS. Если вы зададите собственный <base href> на странице, Plank отступит и учтёт его — ваши пути будут разрешаться относительно вашего base. Подписанная ссылка автоматически обновляется, пока дашборд открыт (она действует около пяти минут), так что вам не нужно ею управлять.

Два правила путей — не перепутайте их. src изображения указывается относительно папки HTML-файла. path в plank:run-script (см. выше) указывается относительно корня рабочего пространства и должен начинаться с scripts/. Их разрешают разные системы.

Запасной вариант для мобильных (React Native WebView)

На мобильных устройствах iframe — это WebView React Native, а не браузерный iframe. Один и тот же код работает в обоих случаях с одной дополнительной строкой:

const msg = { type: "plank:run-script", path: "scripts/google/gmail/sync.py" };
if (window.ReactNativeWebView) {
  window.ReactNativeWebView.postMessage(JSON.stringify(msg));
} else {
  window.parent.postMessage(msg, "*");
}

Подключение скрипта к @plank-button

API plank:run-script принимает только те скрипты, которые явно дали согласие через заголовок @plank-button. Это периметр безопасности — без этого заголовка API возвращает 403.

Добавьте это в начало скрипта (# для Python/Shell, // для TypeScript/JavaScript):

# @plank-button
# label: Sync Gmail → Tender Sheet

# … остальная часть вашего скрипта

label необязателен и по умолчанию берётся из имени файла скрипта. В версии v1 нет разграничения по вызывающей стороне — любой HTML в рабочем пространстве может вызвать любой подключённый скрипт. Шаблоны (globs) для отдельных вызывающих сторон запланированы на будущее.

Передача аргументов (кнопка в каждой строке)

Дашборд с таблицей обычно хочет кнопку в каждой строке — «Архивировать тикет», «Повторить заказ» — и все они вызывают один и тот же скрипт, сообщая ему, какая именно запись была нажата. Объявите параметры в заголовке и передайте значения в сообщении.

1. Объявите параметры

Добавьте строку args: со списком имён параметров по порядку:

# scripts/backlog/archive.py
# @plank-button
# label: Архивировать тикет
# args: code, reason

import sys

code   = sys.argv[1] if len(sys.argv) > 1 else ""
reason = sys.argv[2] if len(sys.argv) > 2 else ""

Скрипт без строки args: не принимает аргументов. Попытка их отправить вернёт 400, а не молча их отбросит — поэтому скрипт, написанный до того, как вам понадобились аргументы, не получит входные данные, которых он не ждёт. До 8 параметров; имена — строчные латинские буквы, цифры и подчёркивания, начиная с буквы.

2. Передайте значения

Работают обе формы. Именованная понятнее и не зависит от порядка:

// Именованная — рекомендуется
window.parent.postMessage({
  type: "plank:run-script",
  path: "scripts/backlog/archive.py",
  args: { code: "PB27", reason: "duplicate" },
}, "*");

// Позиционная — то же самое, в порядке объявления
window.parent.postMessage({
  type: "plank:run-script",
  path: "scripts/backlog/archive.py",
  args: ["PB27", "duplicate"],
}, "*");

В обоих случаях значения приходят как обычные аргументы командной строки в объявленном вами порядке. Пропущенные значения приходят как пустые строки и никогда не сдвигают остальные: при args: { note: "urgent" } для объявления args: code, action, note скрипт всё равно увидит note третьим аргументом. Так что sys.argv[3] всегда означает одно и то же.

3. Привяжите к строке таблицы

<tr>
  <td>PB27</td>
  <td>Выпустить кнопку архивации</td>
  <td><button data-code="PB27">Архивировать</button></td>
</tr>
<script>
document.querySelectorAll("button[data-code]").forEach((btn) => {
  btn.addEventListener("click", () => {
    btn.disabled = true;
    const msg = {
      type: "plank:run-script",
      path: "scripts/backlog/archive.py",
      args: { code: btn.dataset.code },
    };
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(JSON.stringify(msg));
    } else {
      window.parent.postMessage(msg, "*");
    }
  });
});
</script>

Сообщение с результатом возвращает args обратно, чтобы вы могли понять, какая строка завершилась: у кнопок всех строк один и тот же path.

window.addEventListener("message", (e) => {
  if (e.data?.type !== "plank:script-result") return;
  // Работает для обеих форм: именованной ({ code: "PB27" }) и позиционной (["PB27"]).
  const sent = e.data.args;
  const code = Array.isArray(sent) ? sent[0] : sent?.code;
  if (!code) return;
  // CSS.escape — значение может содержать кавычки (см. ниже); подстановка
  // такого значения прямо в селектор выбросит ошибку, и кнопка навсегда
  // останется заблокированной.
  const row = document.querySelector(`button[data-code="${CSS.escape(code)}"]`);
  if (row) row.disabled = false;
});

Что можно передавать в значениях

Передавайте строки. Числа и логические значения отклоняются с ошибкой 400 — пишите { count: "27" }, а не { count: 27 }, чтобы случайный undefined не попал в скрипт под видом настоящих данных.

Каждое значение — до 512 символов и может содержать что угодно, кроме управляющих символов (табуляций, переводов строки, NUL) и непарных суррогатов. Пробелы, кавычки, $, ;, обратные кавычки, а также кириллица и другие нелатинские тексты — всё допустимо: Plank экранирует каждое значение, поэтому оно доходит до скрипта одним аргументом ровно в том виде, в каком вы его отправили, без интерпретации оболочкой. ООО «Тендер» & Ко приходит как ООО «Тендер» & Ко.

За смысл по-прежнему отвечает ваш скрипт. Plank гарантирует, что значение дойдёт в целости; существует ли тикет PB27 и вправе ли этот пользователь его архивировать — проверяет сам скрипт.

Повторные нажатия и сколько запусков идёт одновременно

Повторное нажатие той же кнопки в течение пяти секунд вернёт результат первого запуска вместо нового — но только если тот запуск уже завершился. Если он ещё выполняется, второе нажатие запустит скрипт второй раз. Скрипт может работать до 90 секунд, поэтому для действий, которые небезопасно выполнять дважды (архивирование, списание средств, отправка), держите кнопку заблокированной до прихода plank:script-result, а не полагайтесь на пятисекундное окно.

Нажатие на другой строке всегда запускает скрипт заново — аргументы входят в то, что отличает одно нажатие от другого, поэтому строка PB44 никогда не покажет вам результат строки PB27.

Три запуска на рабочее пространство одновременно. Четвёртое параллельное нажатие вернёт 429, а результат придёт с exitCode: -1 и сообщением о превышении лимита в stderr. На длинной таблице запускайте строки последовательно — дожидайтесь plank:script-result перед следующим нажатием, а не перебирайте все строки сразу:

async function runRows(codes) {
  for (const code of codes) {
    await new Promise((resolve) => {
      const onDone = (e) => {
        if (e.data?.type !== "plank:script-result") return;
        window.removeEventListener("message", onDone);
        resolve();
      };
      window.addEventListener("message", onDone);
      window.parent.postMessage({
        type: "plank:run-script",
        path: "scripts/backlog/archive.py",
        args: { code },
      }, "*");
    });
  }
}

Скрипт выполняется в контейнере рабочего пространства с теми же учётными данными, что и при вызове из чат-агента. У него есть лимит выполнения в 90 секунд.

Получение результата

Когда скрипт завершается, родитель отправляет обратно в iframe сообщение plank:script-result. Слушайте его, чтобы обновить данные дашборда:

window.addEventListener("message", (e) => {
  if (e.data?.type !== "plank:script-result") return;
  if (e.data.path !== "scripts/google/gmail/sync.py") return;
  if (e.data.exitCode === 0) {
    location.reload();
  } else {
    console.error("sync failed:", e.data.stderr);
  }
});

Форма ответа:

{
  type: "plank:script-result";
  path: string;       // путь запущенного скрипта
  args?: unknown;     // отправленные вами args, возвращённые обратно — чтобы понять, какая строка завершилась
  exitCode: number;   // 0 = успех; 124 = таймаут выполнения; ненулевой = ошибка скрипта
  stdout: string;     // ТОЛЬКО СТАТУС — обрезается до 4 КБ. См. «Дашборды на основе данных» ниже.
  stderr: string;     // обрезается до 4 КБ
}

stdout нужен для статуса, а не для данных. Он жёстко ограничен 4 КБ и без предупреждения обрезается сверх этого — JSON.parse(stdout) на реальном наборе данных выбросит ошибку. Чтобы вернуть данные из скрипта в дашборд, запишите их в JSON-файл в рабочем пространстве и сделайте fetch из дашборда. Рецепт — в следующем разделе.

Дашборды на основе данных (паттерн с JSON)

Надёжный паттерн для дашборда, который отображает данные, полученные извне (Google Sheets, Gmail, Stripe, внутренний API…):

  1. Скрипт получает данные извне и записывает JSON-файл рядом с дашбордом. Он не пытается вернуть набор данных через stdout.
  2. Дашборд делает fetch этого JSON-файла по относительному URL со строкой запроса для обхода кеша и отрисовывает данные.
  3. Кнопка отправляет plank:run-script, чтобы запустить обновление. Дашборд слушает plank:script-result и при успехе заново загружает JSON-файл.

Это работает, потому что <base href> iframe разрешает относительные URL через подписанную ссылку, которой управляет Plank. Никакого CORS, никакой возни с токенами, никакого ограничения на обрезку.

Сторона скрипта — запись JSON-файла

# scripts/local/sync-tenders.py
# @plank-button
# label: Sync Tenders from Google Sheets

from pathlib import Path
import json

ROOT = Path(__file__).resolve().parents[2]            # корень рабочего пространства
DASHBOARD_DATA = ROOT / "curated" / "tenders.json"    # рядом с дашбордом

def fetch_from_google_sheets() -> dict:
    # … здесь ваш вызов Sheets API …
    return {"tenders": [...], "updatedAt": "2026-05-30T12:00:00Z"}

DASHBOARD_DATA.write_text(
    json.dumps(fetch_from_google_sheets(), ensure_ascii=False),
    encoding="utf-8",
)
print("ok")   # только статус — держите stdout крошечным

Два правила, которым нужно следовать:

  • Размещайте JSON-файл рядом с HTML. Если tenders-dashboard.html лежит в curated/, запишите curated/tenders.json. Тогда fetch('./tenders.json') дашборда «просто работает».
  • Не выводите набор данных. print() только короткую строку успеха/ошибки. Любые данные больше 4 КБ обрежутся, и дашборд сочтёт запуск неудачным.

Сторона дашборда — загрузка и отрисовка

<!-- curated/tenders-dashboard.html -->
<button id="refresh">Обновить</button>
<div id="rows"></div>
<script>
  const REFRESH_SCRIPT = "scripts/local/sync-tenders.py";

  async function loadData() {
    // Обходите кеш при каждом fetch — ответы несут Cache-Control: max-age=300.
    const res = await fetch("./tenders.json?t=" + Date.now(), { cache: "no-store" });
    if (!res.ok) throw new Error("No data file yet — click Refresh");
    const data = await res.json();
    document.getElementById("rows").textContent = JSON.stringify(data, null, 2);
  }

  function runScript(path) {
    const msg = { type: "plank:run-script", path };
    if (window.ReactNativeWebView) {
      window.ReactNativeWebView.postMessage(JSON.stringify(msg));
    } else {
      window.parent.postMessage(msg, "*");
    }
  }

  window.addEventListener("message", (e) => {
    if (e.data?.type !== "plank:script-result") return;
    if (e.data.path !== REFRESH_SCRIPT) return;
    if (e.data.exitCode === 0) {
      loadData().catch((err) => console.error(err));   // заново загрузить файл
    } else {
      console.error("sync failed:", e.data.stderr);
    }
  });

  document.getElementById("refresh").addEventListener("click", () => runScript(REFRESH_SCRIPT));

  // Холодный старт: загрузить кешированный файл. Если его ещё нет — запустить скрипт.
  loadData().catch(() => runScript(REFRESH_SCRIPT));
</script>

Вот и весь паттерн. Дашборд работает при холодном старте (файл уже кеширован), работает после обновления (скрипт записывает файл, дашборд заново его загружает) и работает в самый первый раз (файла нет → запускается скрипт → отклик с результатом запускает загрузку).

Три мелочи, благодаря которым всё «просто работает»

  1. Обходите кеш при загрузке JSON. ?t=${Date.now()} плюс { cache: 'no-store' }. Подписанная ссылка несёт Cache-Control: private, max-age=300 — без обхода кеша обновления в пределах этого 5-минутного окна отдадут устаревший файл.
  2. Используйте относительные пути, а не абсолютные. fetch('./tenders.json') разрешается через подписанную ссылку Plank. fetch('/data') или fetch('/sync') попытаются обратиться напрямую к хосту API Plank и завершатся ошибкой — таких эндпоинтов нет.
  3. Не пытайтесь вернуть набор данных через stdout. Всегда записывайте файл. Даже 10 КБ JSON обрежутся; даже корректный JSON у границы может прийти повреждённым.

Зависимости Python, которые нужны скрипту

Если скрипт импортирует библиотеку, которая не предустановлена (клиентские библиотеки Google, stripe, requests-oauthlib…), добавьте запасной механизм самоустановки, чтобы он работал на свежем контейнере. Используйте --break-system-packages, а не --user--user устанавливает в ~/.local, который часто не входит в путь системного Python, и повторный импорт всё равно не удастся:

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ModuleNotFoundError:
    import subprocess, sys
    subprocess.check_call([
        sys.executable, "-m", "pip", "install", "--break-system-packages",
        "google-api-python-client", "google-auth", "google-auth-oauthlib",
    ])
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

Для Node/TypeScript запускайте pnpm add <dep> внутри скрипта через subprocess или закоммитьте package.json рядом со скриптом с его зависимостями.

Ограничения по параллелизму и защите от злоупотреблений

  • 3 параллельных запуска на рабочее пространство. 4-й быстрый клик возвращает статус «занято»; пользователь видит «Рабочее пространство занято — попробуйте через мгновение».
  • 5-секундная защита от дребезга на скрипт. Двойной быстрый клик по одной кнопке возвращает результат предыдущего запуска, а не стартует новый.
  • Лимит выполнения 90 секунд. Скрипты, превысившие его, завершаются с кодом 124.

Эти ограничения действуют в пределах одного рабочего пространства. Они защищают контейнер рабочего пространства от вышедших из-под контроля дашбордов; на другие рабочие пространства или платформу они не влияют.

Журнал аудита для расследований

Каждый завершённый запуск записывает строку в журнал аудита рабочего пространства (рабочее пространство + пользователь + путь скрипта + путь вызывающего HTML + код выхода + обрезанные stdout/stderr + метки времени). Участники рабочего пространства могут запрашивать аудит только своего рабочего пространства; межпространственные запросы блокируются на уровне базы данных.

Если кнопка «молча не сработала» — дашборд не обновился, нет уведомления — журнал аудита стоит проверить первым делом.