Roll20 uses cookies to improve your experience on our site. Cookies enable you to enjoy certain features, social sharing functionality, and tailor message and display ads to your interests on our site and others. They also help us understand how our site is being used. By continuing to use our site, you consent to our use of cookies. Update your cookie preferences .
×
Create a free account

[Script] [Token Handout Opener]

1756598204

Edited 1756598410
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 &amp; 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 &amp; 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', () =&gt; { const CMD = '!handout'; const safe = (s) =&gt; String(s||'') .replace(/&amp;/g,'&amp;amp;').replace(/&lt;/g,'&amp;lt;') .replace(/&gt;/g,'&amp;gt;').replace(/"/g,'&amp;quot;') .replace(/'/g,'&amp;#39;'); const logErr = (e) =&gt; log(`HandoutOpener ERR: ${e &amp;&amp; e.stack ? e.stack : e}`); const handoutUrl = (h) =&gt; `<a href="https://journal.roll20.net/handout/${h.id}`" rel="nofollow">https://journal.roll20.net/handout/${h.id}`</a>; const getSelGraphics = (msg) =&gt; (msg.selected || []) .filter(s =&gt; s &amp;&amp; s._type === 'graphic') .map(s =&gt; getObj('graphic', s._id)) .filter(Boolean); const findHandoutByTokenName = (token) =&gt; { 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 =&gt; (h.get('name')||'').trim().toLowerCase() === needle); if (exact) return exact; // fuzzy contains return all.find(h =&gt; (h.get('name')||'').toLowerCase().includes(needle)) || null; }; const playerCanSee = (handout, playerid) =&gt; { const viewers = (handout.get('inplayerjournals') || '') .split(',').map(s=&gt;s.trim()).filter(Boolean); return viewers.includes('all') || viewers.includes(playerid); }; const who = (msg) =&gt; (getObj('player', msg.playerid)?.get('displayname')) || 'Player'; const whisperGM = (html) =&gt; sendChat('Handout', `/w gm ${html}`); const whisperTo = (msg, html) =&gt; sendChat('Handout', `/w "${who(msg)}" ${html}`); const handleOpen = (msg) =&gt; { const isGM = playerIsGM(msg.playerid); const sel = getSelGraphics(msg); if (!sel.length) { if (!isGM) whisperTo(msg, `Select a token, then run &lt;code&gt;${safe(CMD)}&lt;/code&gt;.`); return; } const sent = new Set(); sel.forEach(g =&gt; { try { const h = findHandoutByTokenName(g); if (!h) { if (!isGM) whisperTo(msg, `No handout found matching &lt;b&gt;${safe(g.get('name')||'(unnamed)')}&lt;/b&gt;.`); return; } if (sent.has(h.id)) return; sent.add(h.id); const link = `&lt;a href="${safe(handoutUrl(h))}"&gt;${safe(h.get('name'))}&lt;/a&gt;`; if (isGM) { whisperGM(link); } else { const note = playerCanSee(h, msg.playerid) ? '' : `&lt;div style="color:#777;font-size:90%;"&gt;(You might not have permission to view this yet.)&lt;/div&gt;`; whisperTo(msg, `${link}${note}`); } } catch (e) { logErr(e); } }); }; on('chat:message', (msg) =&gt; { try { if (msg.type !== 'api' || !msg.content) return; if (msg.content.startsWith(CMD)) handleOpen(msg); } catch (e) { logErr(e); } }); });
1756598561
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Interesting! Is this a map pin solution?
keithcurtis said: Interesting! Is this a map pin solution? I suppose it is, it just requires one more click than the ideal one click and then it opens the handout.
Love this! Def a map pin workaround! Thank you for the script!
1756773925

Edited 1756775163
Pat
Pro
API Scripter
Could you put the "Macro" for !handout in a bar at the bottom? Click-clicktoken? Use the (target) feature to prompt when you press the button?&nbsp; Edited to add: I guess I mean do it in kind of reverse order: a macro with&nbsp; !handout|@{target|token_name} Or somesuch.&nbsp;
1756774760
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Another option: Have a handout for each player, named after their character. When they click on "Dewdrop Inn" and run the macro, the script replaces the contents of the handout "Bobby the Barbarian" with the contents of "Dewdrop Inn". This allows everyone to have their own "info handout" they can keep open (possibly minimized) all the time, and skips the step of clicking on the handout link in chat.
1756774807
keithcurtis
Forum Champion
Marketplace Creator
API Scripter
Come to think of it, this can be done with Supernotes, but using token GMnotes. I'll have to give that some thought.
Pat said: Could you put the "Macro" for !handout in a bar at the bottom? Click-clicktoken? Use the (target) feature to prompt when you press the button?&nbsp; You could put it at the bottom by making a macro in the Macro section of the Collections tab. I think flipping the target order might enable players to target tokens they don't have access to. Either way it's the same amount of clicks.
1756775286

Edited 1756775330
keithcurtis said: Another option: Have a handout for each player, named after their character. When they click on "Dewdrop Inn" and run the macro, the script replaces the contents of the handout "Bobby the Barbarian" with the contents of "Dewdrop Inn". This allows everyone to have their own "info handout" they can keep open (possibly minimized) all the time, and skips the step of clicking on the handout link in chat. Funny you should say that lol, I added some features but wasn't sure I wanted to post it (didn't want to overcomplicate the functionality). I made a player handout and a GM handout that extracts the text in the GM notes and updates it as you click on the target. It transfers all the formatted text to the handout with links and all (which GM notes doesn't allow the clicking of links...though it used to many years ago).
1756775332
Pat
Pro
API Scripter
Surok said: Pat said: Could you put the "Macro" for !handout in a bar at the bottom? Click-clicktoken? Use the (target) feature to prompt when you press the button?&nbsp; You could put it at the bottom by making a macro in the Macro section of the Collections tab. I think flipping the target order might enable players to target tokens they don't have access to. Either way it's the same amount of clicks. Right, but if there is no matching handout it won't matter (fail silently), and they don't have to own the tokens to target them - which means you could put tokens on say a world map and then they can just query whatever to see if there is a handout.&nbsp;
Oh what the heck here's the more robust version: Handout Helper — How to Use What it does Quickly open a handout that matches a selected token’s Name . Copy a token’s GM Notes into a handout for the GM or for players (formatting preserved). Posts handy chat links (Roll20 can’t force-open handouts). Commands !handout Select one or more tokens → whispers you a link to the handout whose name matches the token’s Name (exact, then fuzzy). !handout --test Sanity check: confirms the script is listening. !gmnotes (GM only) Select one token → copies that token’s GM Notes into the “GM Notes” handout (GM-only). Overwrites contents. Whispers the link to the GM. If the handout doesn’t exist, it’s created automatically. !pnotes (player-facing) Select one token → copies that token’s GM Notes into “Player GM Notes” (visible to all players). Overwrites contents. Posts a public chat link so everyone can click it. Created automatically if missing. Notes &amp; tips Players must control the token they’re selecting to use these commands. If !handout says “No handout found,” make sure there’s a handout whose name matches the token’s Name , or rename one to match. If a notes copy looks empty, check that the token actually has content in its GM Notes tab. Links/headers contained in GM Notes are preserved when copied. /* Handout Helper: open-by-token-name + GM/Player Notes copier Version: 1.5.1 Author: Surok &amp; GPT-5 Thinking Commands: !handout — Select token(s); whispers link to a handout whose NAME matches the token’s Name (exact, then fuzzy). !handout --test — Sanity check. !gmnotes — GM only. Select ONE token. Copies that token’s GM Notes into "GM Notes" (GM-only), overwriting it. !pnotes — Player-facing. Select ONE token. Copies that token’s GM Notes into "Player GM Notes" (visible to ALL players), overwriting it, then POSTS A PUBLIC CHAT MESSAGE with the link. Note: Roll20 can’t force-open handouts; chat links are the reliable path. */ on('ready', () =&gt; { const CMD_HANDOUT = '!handout'; const CMD_GMNOTES = '!gmnotes'; const CMD_PNOTES = '!pnotes'; const safe = (s) =&gt; String(s||'') .replace(/&amp;/g,'&amp;amp;').replace(/&lt;/g,'&amp;lt;') .replace(/&gt;/g,'&amp;gt;').replace(/"/g,'&amp;quot;') .replace(/'/g,'&amp;#39;'); const whoName = (pid) =&gt; (getObj('player', pid)?.get('displayname')) || 'Player'; const whisperGM = (html) =&gt; sendChat('Handout', `/w gm ${html}`); const whisperTo = (pid, html) =&gt; sendChat('Handout', `/w "${whoName(pid)}" ${html}`); const sayAll = (html) =&gt; sendChat('Handout', html); // &lt;-- public message const handoutUrl = (h) =&gt; `<a href="https://journal.roll20.net/handout/${h.id}`" rel="nofollow">https://journal.roll20.net/handout/${h.id}`</a>; const getSelGraphics = (msg) =&gt; (msg.selected || []) .filter(s =&gt; s &amp;&amp; s._type === 'graphic') .map(s =&gt; getObj('graphic', s._id)) .filter(Boolean); // --- find handout by selected token's *Name* --- const findHandoutByTokenName = (token) =&gt; { const nm = (token.get('name') || '').trim(); if (!nm) return null; const needle = nm.toLowerCase(); const all = findObjs({type:'handout'}) || []; const exact = all.find(h =&gt; (h.get('name')||'').trim().toLowerCase() === needle); if (exact) return exact; return all.find(h =&gt; (h.get('name')||'').toLowerCase().includes(needle)) || null; }; const playerCanSee = (handout, playerid) =&gt; { const viewers = (handout.get('inplayerjournals') || '') .split(',').map(s=&gt;s.trim()).filter(Boolean); return viewers.includes('all') || viewers.includes(playerid); }; // ---------- robust decoder for Roll20 gmnotes ---------- const decodeRoll20Notes = (raw) =&gt; { let s = String(raw || ''); s = s.replace(/\+/g, ' '); for (let i = 0; i &lt; 6; i++) { const before = s; try { s = unescape(s); } catch (e) {} try { s = decodeURIComponent(s); } catch (e) {} if (s === before) break; } s = s.replace(/&amp;#(\d+);/g, (_, n) =&gt; String.fromCharCode(parseInt(n,10))); s = s.replace(/&amp;#x([0-9a-fA-F]+);/g, (_, h) =&gt; String.fromCharCode(parseInt(h,16))); return s; }; // ---------- !handout ---------- const handleOpenByTokenName = (msg) =&gt; { const isGM = playerIsGM(msg.playerid); const sel = getSelGraphics(msg); if (!sel.length) { if (!isGM) whisperTo(msg.playerid, `Select a token, then run &lt;code&gt;${safe(CMD_HANDOUT)}&lt;/code&gt;.`); return; } const sent = new Set(); sel.forEach(g =&gt; { const h = findHandoutByTokenName(g); if (!h) { if (!isGM) whisperTo(msg.playerid, `No handout found matching &lt;b&gt;${safe(g.get('name')||'(unnamed)')}&lt;/b&gt;.`); return; } if (sent.has(h.id)) return; sent.add(h.id); const link = `&lt;a href="${safe(handoutUrl(h))}"&gt;${safe(h.get('name'))}&lt;/a&gt;`; if (isGM) whisperGM(link); else { const note = playerCanSee(h, msg.playerid) ? '' : `&lt;div style="color:#777;font-size:90%;"&gt;(You might not have permission to view this yet.)&lt;/div&gt;`; whisperTo(msg.playerid, `${link}${note}`); } }); }; // ---------- helpers for notes-handouts ---------- const ensureGMNotesHandout = () =&gt; { let h = findObjs({type:'handout', name:'GM Notes'})[0]; if (h) return h; return createObj('handout', { name:'GM Notes', inplayerjournals:'gm' }); }; const ensurePlayerNotesHandout = () =&gt; { let h = findObjs({type:'handout', name:'Player GM Notes'})[0]; if (h) return h; return createObj('handout', { name:'Player GM Notes', inplayerjournals:'all' }); }; const copyTokenGMNotesTo = (token, handout) =&gt; { const raw = token.get('gmnotes') || ''; const html = decodeRoll20Notes(raw).trim(); handout.set('notes', html || '&lt;p&gt;(No GM Notes on that token.)&lt;/p&gt;'); return html.length &gt; 0; }; // ---------- !gmnotes (GM-only) ---------- const handleGMNotes = (msg) =&gt; { if (!playerIsGM(msg.playerid)) { whisperTo(msg.playerid, `GM only.`); return; } const sel = getSelGraphics(msg); if (!sel.length) { whisperGM(`Select a token with content in its &lt;b&gt;GM Notes&lt;/b&gt; tab, then run &lt;code&gt;${safe(CMD_GMNOTES)}&lt;/code&gt;.`); return; } const token = sel[0]; const handout = ensureGMNotesHandout(); copyTokenGMNotesTo(token, handout); const link = `&lt;a href="${safe(handoutUrl(handout))}"&gt;${safe(handout.get('name'))}&lt;/a&gt;`; whisperGM(`Updated ${link} from token &lt;b&gt;${safe(token.get('name')||'(unnamed)')}&lt;/b&gt;.`); }; // ---------- !pnotes (Player-facing, PUBLIC POST) ---------- const handlePlayerNotes = (msg) =&gt; { const sel = getSelGraphics(msg); if (!sel.length) { whisperTo(msg.playerid, `Select a token, then run &lt;code&gt;${safe(CMD_PNOTES)}&lt;/code&gt;.`); return; } const token = sel[0]; const handout = ensurePlayerNotesHandout(); copyTokenGMNotesTo(token, handout); const link = `&lt;a href="${safe(handoutUrl(handout))}"&gt;${safe(handout.get('name'))}&lt;/a&gt;`; // Public announcement so *everyone* can click it sayAll(`Updated ${link} from token &lt;b&gt;${safe(token.get('name')||'(unnamed)')}&lt;/b&gt;.`); }; // ---------- router ---------- on('chat:message', (msg) =&gt; { if (msg.type !== 'api' || !msg.content) return; if (msg.content.startsWith(CMD_HANDOUT)) { if (/\s--test\b/.test(msg.content)) { whisperTo(msg.playerid, `Test OK — script received your command.`); return; } handleOpenByTokenName(msg); return; } if (msg.content.startsWith(CMD_GMNOTES)) { handleGMNotes(msg); return; } if (msg.content.startsWith(CMD_PNOTES)) { handlePlayerNotes(msg); return; } }); whisperGM( `Handout script v1.5.1 loaded. Try &lt;code&gt;${safe(CMD_HANDOUT)} --test&lt;/code&gt;, ` + `&lt;code&gt;${safe(CMD_GMNOTES)}&lt;/code&gt; (GM), or &lt;code&gt;${safe(CMD_PNOTES)}&lt;/code&gt; (public).` ); });