
What it does
When you type !handout with one or more tokens selected , the script looks for a handout whose name matches the token’s Name .
If it finds one, it whispers you a clickable link to that handout.
It never uses character sheets and never uses “Show to Players.” It’s just a fast, reliable DM/Player link dropper .
Setup (one-time)
Paste & save the script in your game’s API sandbox.
For every thing you want to open quickly, create a Handout and name it exactly the same as the token’s Name (e.g., token “World Map” → handout “World Map”).
(Optional) Set the handout’s In Player’s Journals :
All → any player can open from the link.
Specific players → only those players can open; others still get the link but may see a note that they lack permission.
Using it at the table
GM or Player:
Select one or more tokens.
Type !handout .
You’ll get whispered links:
GM: link goes to GM only.
Player: link is whispered to that player; if they don’t have access, the whisper warns them.
How matching works
Case-insensitive exact match first , then fuzzy “contains” fallback.
Example: token “Goblin” will match handout “Goblin” first; if that doesn’t exist, it might match “Goblin Boss Tactics.”
If you select multiple tokens that point to the same handout , it de-duplicates and sends one link.
Permissions logic (important)
The script can’t override handout permissions.
Players can only open what’s in In Player’s Journals for them (or All ).
The GM always sees the link and can open everything.
Troubleshooting quickies
Nothing happens: make sure you actually selected a token before running !handout .
“No handout found” whisper: the token’s Name doesn’t match any handout. Check spelling, spaces, punctuation.
Player can’t open: add that player (or All) in the handout’s In Player’s Journals .
Wrong handout opens (fuzzy match): make the names exact to avoid the fuzzy fallback, or rename so the intended handout is the only one that “contains” the token name.
Limits (by Roll20)
The API cannot force-open a handout window; links are the reliable path .
The script searches handouts only (no characters, no journals of other types).
That’s it, name your handouts to match token Names, select, run !handout , click link. /*
Token Handout Opener (Token-Name Only)
Version: 1.3.1
Author: Surok & GPT-5 Thinking
Command:
!handout — Select one or more tokens, then run the command.
Behavior:
- Uses the selected token's Name to find a handout.
- Exact (case-insensitive) match first, then fuzzy "contains".
- GM: whispers a direct link to GM.
- Player: whispers a link; notes if they may lack permission.
Note:
- Roll20 can't force-open handouts; the chat link is the reliable path.
*/
on('ready', () => {
const CMD = '!handout';
const safe = (s) => String(s||'')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
const logErr = (e) => log(`HandoutOpener ERR: ${e && e.stack ? e.stack : e}`);
const handoutUrl = (h) => `<a href="https://journal.roll20.net/handout/${h.id}`" rel="nofollow">https://journal.roll20.net/handout/${h.id}`</a>;
const getSelGraphics = (msg) =>
(msg.selected || [])
.filter(s => s && s._type === 'graphic')
.map(s => getObj('graphic', s._id))
.filter(Boolean);
const findHandoutByTokenName = (token) => {
const nm = (token.get('name') || '').trim();
if (!nm) return null;
const needle = nm.toLowerCase();
const all = findObjs({type:'handout'}) || [];
// exact
const exact = all.find(h => (h.get('name')||'').trim().toLowerCase() === needle);
if (exact) return exact;
// fuzzy contains
return all.find(h => (h.get('name')||'').toLowerCase().includes(needle)) || null;
};
const playerCanSee = (handout, playerid) => {
const viewers = (handout.get('inplayerjournals') || '')
.split(',').map(s=>s.trim()).filter(Boolean);
return viewers.includes('all') || viewers.includes(playerid);
};
const who = (msg) => (getObj('player', msg.playerid)?.get('displayname')) || 'Player';
const whisperGM = (html) => sendChat('Handout', `/w gm ${html}`);
const whisperTo = (msg, html) => sendChat('Handout', `/w "${who(msg)}" ${html}`);
const handleOpen = (msg) => {
const isGM = playerIsGM(msg.playerid);
const sel = getSelGraphics(msg);
if (!sel.length) {
if (!isGM) whisperTo(msg, `Select a token, then run <code>${safe(CMD)}</code>.`);
return;
}
const sent = new Set();
sel.forEach(g => {
try {
const h = findHandoutByTokenName(g);
if (!h) {
if (!isGM) whisperTo(msg, `No handout found matching <b>${safe(g.get('name')||'(unnamed)')}</b>.`);
return;
}
if (sent.has(h.id)) return;
sent.add(h.id);
const link = `<a href="${safe(handoutUrl(h))}">${safe(h.get('name'))}</a>`;
if (isGM) {
whisperGM(link);
} else {
const note = playerCanSee(h, msg.playerid) ? '' : `<div style="color:#777;font-size:90%;">(You might not have permission to view this yet.)</div>`;
whisperTo(msg, `${link}${note}`);
}
} catch (e) { logErr(e); }
});
};
on('chat:message', (msg) => {
try {
if (msg.type !== 'api' || !msg.content) return;
if (msg.content.startsWith(CMD)) handleOpen(msg);
} catch (e) { logErr(e); }
});
});