Plank help · updated 2026-07-27
Interactive HTML dashboards
How HTML files in a Plank workspace can run scripts, open files, and chat — via the plank:run-script postMessage and the @plank-button header.
Agents: fetch the raw markdown of this page at /en/help/html-buttons.md
Interactive HTML dashboards
HTML files in a Plank workspace render inside a sandboxed iframe. The iframe can ask the parent app to do three things by sending a postMessage:
- Send a chat message —
plank:send-chat-message - Open another file —
plank:open-file - Run a workspace script —
plank:run-script
This page covers all three and the @plank-button opt-in that gates direct script execution.
When to use which command
| Command | Use it when |
|---|---|
plank:send-chat-message | The action benefits from the agent narrating the result (errors, edge cases, "I noticed X"). |
plank:run-script | The script is deterministic, the dashboard owns the success/failure UI, and the user wants instant feedback without a chat round-trip. |
plank:open-file | Linking from one dashboard to another file in the workspace. |
You can use both plank:run-script and plank:send-chat-message on the same dashboard. A "Sync" button might run directly; a "Resolve mismatches" button might drop into chat.
The postMessage shapes
// Send to chat — the agent receives 'text' as if the user typed it.
// This always opens a FRESH chat (each button begins its own self-contained
// task), rather than appending to whatever chat is currently open.
window.parent.postMessage(
{ type: "plank:send-chat-message", text: "Run the weekly tender report" },
"*",
);
// Open another file in the file tab.
window.parent.postMessage(
{ type: "plank:open-file", path: "reports/q4.md" },
"*",
);
// Run a workspace script directly. The path is workspace-relative and
// must live under scripts/ (see "Script paths are workspace-relative"
// and "Opting a script into @plank-button" below).
window.parent.postMessage(
{ type: "plank:run-script", path: "scripts/google/gmail/sync.py" },
"*",
);
Script paths are workspace-relative
The path you send with plank:run-script is relative to the workspace root and must live under scripts/ — for example scripts/local/tender-sync-server.py. Don't send an absolute path like /spaces/your-workspace/scripts/sync.py or /home/coder/…; the runner rejects it with a 400 before the script ever runs.
The path has to match this shape:
- starts with
scripts/ - ends in
.py,.ts, or.sh - contains no
..segments
You never reference the container's mount path yourself — Plank resolves scripts/… against your active workspace for you.
Images and other assets
To show an image stored in your workspace, reference it by a path relative to the HTML file. Plank loads it for you through a short-lived signed link — no setup, no exposed credentials:
<img src="chart.png" /> <!-- same folder as the dashboard -->
<img src="images/q4.png" /> <!-- a subfolder -->
<img src="../shared/logo.png" /> <!-- up one level -->
Paths resolve against the folder the dashboard lives in, not the workspace root. A leading slash is treated the same way: src="/chart.png" loads chart.png from the dashboard's own folder, not from the workspace root. Keep asset paths relative to where the HTML file sits.
External and inline images are left untouched and load as written:
- Full URLs —
https://… - Data URIs —
data:image/png;base64,… - Object URLs —
blob:… - Protocol-relative —
//host/img.png
The same resolution applies to the other assets a dashboard loads from the workspace: stylesheets (<link href>), scripts (<script src>), a video poster, a CSS background. If you set your own <base href> in the page, Plank steps back and respects it — your paths resolve against your base instead. The signed link refreshes automatically while the dashboard is open (it lasts about five minutes), so you never manage it.
Two path rules — don't mix them up. An image
srcis relative to the HTML file's folder. Aplank:run-scriptpath(above) is relative to the workspace root and must start withscripts/. Different systems resolve them.
Mobile (React Native WebView) fallback
On mobile, the iframe is a React Native WebView rather than a browser iframe. The same code works in both with one extra line:
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, "*");
}
Opting a script into @plank-button
The plank:run-script API only accepts scripts that have explicitly opted in via a @plank-button header. This is the security perimeter — without the header, the API returns 403.
Add this to the top of the script (# for Python/Shell, // for TypeScript/JavaScript):
# @plank-button
# label: Sync Gmail → Tender Sheet
# … rest of your script
label is optional and falls back to the script's filename. In v1 there is no per-caller scoping — any HTML in the workspace may call any opted-in script. Per-caller globs are tracked as a follow-up.
Passing arguments (one button per row)
A dashboard that renders a table usually wants a button on every row — "Archive this ticket", "Re-run this order" — all calling the same script, each telling it which record was clicked. Declare the parameters in the header and send values with the message.
1. Declare the parameters
Add an args: line listing the parameter names, in order:
# scripts/backlog/archive.py
# @plank-button
# label: Archive ticket
# 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 ""
A script without an args: line accepts no arguments. Sending some returns a 400 rather than silently dropping them — so a script written before you needed arguments can't be fed input it isn't expecting. Up to 8 parameters; names are lowercase letters, digits and underscores, starting with a letter.
2. Send the values
Either shape works. Named is clearer and order-independent:
// Named — recommended
window.parent.postMessage({
type: "plank:run-script",
path: "scripts/backlog/archive.py",
args: { code: "PB27", reason: "duplicate" },
}, "*");
// Positional — same thing, in declaration order
window.parent.postMessage({
type: "plank:run-script",
path: "scripts/backlog/archive.py",
args: ["PB27", "duplicate"],
}, "*");
Both arrive as ordinary command-line arguments in the order you declared them. Omitted values arrive as empty strings and never shift the others — with args: { note: "urgent" } against args: code, action, note, the script still sees note as the third argument. So sys.argv[3] always means the same thing.
3. Wire it to a row
<tr>
<td>PB27</td>
<td>Ship the archive button</td>
<td><button data-code="PB27">Archive</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>
The result message echoes the args back, so you can tell which row finished — every row's button reports the same path:
window.addEventListener("message", (e) => {
if (e.data?.type !== "plank:script-result") return;
// Works for both shapes: named ({ code: "PB27" }) and positional (["PB27"]).
const sent = e.data.args;
const code = Array.isArray(sent) ? sent[0] : sent?.code;
if (!code) return;
// CSS.escape because a value may legitimately contain quotes (see below);
// interpolating one straight into a selector throws and would leave the
// button stuck disabled.
const row = document.querySelector(`button[data-code="${CSS.escape(code)}"]`);
if (row) row.disabled = false;
});
What the values may contain
Send strings. Numbers and booleans are rejected with a 400 — write { count: "27" }, not { count: 27 } — so a stray undefined can't reach your script looking like real data.
Each value can be up to 512 characters and may contain anything except control characters (tabs, newlines, NULs) and unpaired surrogates. Spaces, quotes, $, ;, backticks and accented or non-Latin text are all fine: Plank quotes every value so it reaches your script as a single argument exactly as you sent it, with no shell interpretation. O'Brien & Co arrives as O'Brien & Co.
Your script still owns meaning. Plank guarantees the value arrives intact; whether PB27 is a ticket that exists, and whether this user should be archiving it, is your script's job to check.
Repeat clicks, and how many can run at once
Clicking the same button twice within five seconds returns the first run's result instead of running again — once that first run has finished. If it is still running, the second click starts a second run. Scripts can take up to 90 seconds, so for anything that isn't safe to run twice (archiving, charging, sending), keep the button disabled until plank:script-result arrives rather than relying on the five-second window.
Clicking a different row always starts a real run — the arguments are part of what makes a click distinct, so row PB44 never shows you row PB27's result.
Three runs per workspace at a time. A fourth concurrent click returns 429 and the result comes back with exitCode: -1 and a "too many concurrent" message in stderr. On a long table, run the rows in sequence — wait for each plank:script-result before firing the next — rather than looping over every row at once:
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 },
}, "*");
});
}
}
The script runs in the workspace container with the same credentials it has when the chat agent invokes it. It has a 90-second exec budget.
Receiving the result
When the script finishes, the parent posts plank:script-result back to the iframe. Listen for it to refresh the dashboard's data:
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);
}
});
The reply shape:
{
type: "plank:script-result";
path: string; // the script path that ran
args?: unknown; // the args you sent, echoed back — use it to tell which row finished
exitCode: number; // 0 = success; 124 = exec timeout; non-zero = script error
stdout: string; // STATUS ONLY — truncated to 4 KB. See "Data-driven dashboards" below.
stderr: string; // truncated to 4 KB
}
stdoutis for status, not data. It is hard-capped at 4 KB and silently truncated past that —JSON.parse(stdout)on a real dataset will throw. To return data from a script to a dashboard, write it to a JSON file in the workspace andfetchit from the dashboard. The recipe is in the next section.
Data-driven dashboards (the JSON pattern)
The reliable pattern for a dashboard that displays data fetched from outside (Google Sheets, Gmail, Stripe, an internal API…):
- The script does the outside fetch and writes a JSON file next to the dashboard. It doesn't try to return the dataset through
stdout. - The dashboard
fetches that JSON file via a relative URL, with a cache-busting query string, and renders. - A button posts
plank:run-scriptto trigger a refresh. The dashboard listens forplank:script-resultand, on success, re-fetches the JSON file.
This works because the iframe's <base href> resolves relative URLs through a signed link Plank manages for you. No CORS, no token plumbing, no truncation cap.
The script side — write a JSON file
# 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] # workspace root
DASHBOARD_DATA = ROOT / "curated" / "tenders.json" # next to the dashboard
def fetch_from_google_sheets() -> dict:
# … your Sheets API call here …
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") # status only — keep stdout tiny
Two conventions to follow:
- Co-locate the JSON file with the HTML. If
tenders-dashboard.htmllives incurated/, writecurated/tenders.json. The dashboard'sfetch('./tenders.json')then "just works". - Don't print the dataset.
print()only a short success/error string. Any data over 4 KB gets cut off and the dashboard will treat the run as failed.
The dashboard side — fetch and render
<!-- curated/tenders-dashboard.html -->
<button id="refresh">Refresh</button>
<div id="rows"></div>
<script>
const REFRESH_SCRIPT = "scripts/local/sync-tenders.py";
async function loadData() {
// Cache-bust each fetch — responses carry 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)); // re-fetch the file
} else {
console.error("sync failed:", e.data.stderr);
}
});
document.getElementById("refresh").addEventListener("click", () => runScript(REFRESH_SCRIPT));
// Cold open: fetch the cached file. If it doesn't exist yet, kick off the script.
loadData().catch(() => runScript(REFRESH_SCRIPT));
</script>
That's the entire pattern. The dashboard works on cold open (file already cached), works after a refresh (script writes the file, dashboard re-fetches), and works the very first time (file missing → script runs → result echo triggers fetch).
Three small things that make it "just work"
- Cache-bust the JSON fetch.
?t=${Date.now()}plus{ cache: 'no-store' }. The signed link carriesCache-Control: private, max-age=300— without busting, refreshes inside that 5-minute window serve the stale file. - Use relative paths, not absolute.
fetch('./tenders.json')resolves through Plank's signed link.fetch('/data')orfetch('/sync')will try to hit Plank's API host directly and fail — there are no such endpoints. - Don't try to return the dataset via stdout. Always write a file. Even 10 KB of JSON gets truncated; even valid JSON near the boundary may arrive broken.
Python dependencies the script needs
If the script imports a library that isn't preinstalled (Google client libs, stripe, requests-oauthlib…), include a self-install fallback so it works on a fresh container. Use --break-system-packages, not --user — --user installs into ~/.local which often isn't on the system Python's path and the re-import will still fail:
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
For Node/TypeScript, run pnpm add <dep> inside the script via subprocess, or commit a package.json next to the script with its deps.
Concurrency and abuse bounds
- 3 concurrent runs per workspace. A 4th rapid click returns busy; the user sees "Workspace busy — try again in a moment".
- 5-second per-script debounce. Clicking the same button twice fast returns the previous run's result instead of starting a new one.
- 90-second exec budget. Scripts that exceed it exit with code 124.
These bounds apply per workspace. They protect the workspace container from runaway dashboards; they do not affect other workspaces or the platform.
Forensic audit log
Every completed run writes a row to a per-workspace audit log (workspace + user + script path + caller HTML path + exit code + truncated stdout/stderr + timestamps). Workspace members can query their own workspace's audit; cross-workspace queries are blocked at the database level.
If a button "silently failed" — the dashboard didn't refresh, no toast — the audit log is the first thing to check.