Reading the docs…
Stopped.
A custom widget is a little JavaScript that draws whatever you like from the board’s data: a burndown, a label cloud, who’s contributing. Its code runs in a sandbox that can’t reach the network, Tack, or anything else on the page, and it reads data through a small API that can search and read cards, but never change anything.


Custom widgets are off on every board until its owner turns them on: Board menu → Custom widgets…, then Run custom widgets on this board. While they’re off, a custom widget on the board shows “Custom widgets are off here” and nothing runs. Turning them off again stops every custom widget on the board at once, for everyone looking.
Add one from the widget picker (W; it’s only there on boards with custom widgets on, and for their owners), then start from an example (Burndown, Label cloud, Contributors) or a blank one. The code button in its header opens the editor:
await works.tack.config, so one widget’s code can serve several searches. Settings that aren’t a JSON object can’t be applied.Nothing reaches the board until you press Apply (⌘ Enter), which is one undoable change. People who can only view the board can read the code, but not change it.
On the board, a custom widget only takes the pointer once it’s selected, so the board pans and selects around it as usual. Its footer counts the errors its code has thrown; click it to open the editor.
The code gets a global tack:
tack.config |
The widget’s settings, parsed (frozen). |
tack.widget |
{ id, title }. |
tack.search(q, options?) |
A GitHub issue search, like a list’s, run with the permissions of whoever’s looking. Resolves to { total, items, incomplete }, where items are cards. Options: limit (at most 100), sort (updated, created, comments, reactions, or top: the most upvoted discussions), order (asc or desc), host. |
tack.cards(keys) |
Cards by key (github.com/owner/name#12), as the board has them for this person: null for any it doesn’t have, or they can’t read. At most 100 at once. |
tack.avatar(url, size?) |
A GitHub avatar (from avatars.githubusercontent.com only), as a data: URL to put in an <img>. |
tack.onUpdate(fn) |
Calls fn when something it searched for has new results (searches stay live and refresh like any widget’s). Returns a function that stops it. |
tack.theme, tack.onTheme(fn) |
{ mode, motion }: light or dark, and how much to scale durations by (near 0 with reduced motion). |
Errors from tack are TackErrors, with a reason (invalid, no_access, rate_limited, timeout, signed_out…) and a message fit to show.
A card has key, repo, item (#12), kind (issue or pull), title, state, labels ({ name, color }), assignees and author ({ login, avatarUrl }), comments, reactions, url, createdAt, updatedAt, closedAt, milestone ({ title, dueOn }) and, for pull requests, pull (reviews and checks).
The page starts with the board’s look: its type, and its colours as CSS custom properties that follow light and dark mode (var(--ink), --muted, --surface, --line, --accent, --open, --closed, --merged, --pass, --fail and more), plus --motion to scale animations by. .muted and .empty are there for quiet text and empty states.
// Open items per label, live.const { items } = await tack.search(tack.config.query);const counts = Object.groupBy( items.flatMap((item) => item.labels), (label) => label.name,);document.body.innerHTML = Object.entries(counts) .map(([name, labels]) => `<p>${name}: ${labels.length}</p>`) .join("");(Writing HTML from data with innerHTML is fine here: whatever it runs runs in the sandbox, with nothing to reach.)
timeout.await on a timer, say) are fine.Custom widgets’ searches refresh every 5 minutes while they’re on screen, and when GitHub says something changed in a repository they search.
Each widget runs in an iframe with sandbox="allow-scripts" and nothing else, loading a page Tack serves with a policy that loads nothing from anywhere. So its code:
fetch, WebSockets, images, fonts, frames or forms from anywhere but data: and blob:, and WebRTC is taken away;What’s left, honestly: a widget sees what the person looking can see, and browsers have side channels no policy closes (timing, say, or DNS prefetching in some browsers). That’s why it’s the owner’s choice, per board, and off by default. The full threat model is in the security review.