HomeDeveloper docsHow to create a token tools module
Implementation tutorials 16 min read

How to create a token tools module

Place, move, duplicate, configure and remove tokens; control native vision/visibility and timed conditions.

Installed Token workshop module after running its inspect recipe. Gravewright 0.3.0, English interface.
Installed Token workshop module after running its inspect recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Build Token workshop as one browser module. Place two disposable tokens in the scene from the previous tutorial, then test movement, snapshot duplication, rotation/elevation, vision, hidden state and an original condition. Keep the tokens for the combat tutorial before removal.

Target host: Gravewright 0.3.0. Actions run only when you click Run action, using the signed-in account. The demonstration panel is English. Each recipe specifies inputs and an observable result.

1. Create the files

Create a folder named tokens-workshop. Save the complete files below as UTF-8, or download the project. manifest.json belongs at the root. No npm dependencies or JavaScript build is required.

text
tokens-workshop/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

Complete Scene workshop and keep its scene ID. Create a disposable actor with the selected system through the native Actors directory, or follow the actor tutorial. Copy actual IDs from Inspect. The module calls public api.tokens methods. Its gridX/gridY are cell-space placement coordinates; native wall geometry uses scene pixels.

2. Package, install and activate

From the project folder, run in PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../tokens-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../tokens-workshop-1.0.0.zip

On Linux/macOS, replace python with python3 in the same commands. The archive listing must show manifest.json at its root. As installation owner, open Inside → Modules → Install module → Install from ZIP, select the ZIP and click Install. As GM, open the campaign, select its Gravewright button → Extensions and Activate version 1.0.0 of Token workshop. Close that menu and click Token workshop. See your first module for screen-by-screen instructions.

The package stays inactive until selected for the table. Test one demonstration extension at a time because their launchers share a position. For changed bytes, increment version to 1.0.1, build another ZIP, install and activate that version; do not edit installed files.

3. Run each recipe

Inspect available actors and scene tokens

Paste the test Scene ID from the maps tutorial. Copy a readable actor ID for Place, then a token ID for later actions.

javascript
async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    return {actors:await api.actors.list({},read),tokens:await api.tokens.list({mapId:v.mapId},read)};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: Authorized actor metadata and token IDs, versions, positions, vision and visible conditions.

How to verify: Compare GM and player: hidden tokens and GM-only conditions are filtered before delivery.

Place an actor token in the test scene

Create an original disposable actor using the native/system sheet first. Coordinates use cell-space gridX/gridY for token placement, not scene pixels. The host validates bounds and token size.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.actors.get(v.actorId,{},read);
    const created=await api.tokens.place({mapId:v.mapId,actorId:v.actorId,gridX:v.x,gridY:v.y},write());
    return {createdIds:created.createdIds,tokens:created.tokens};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: A created token ID and current token projection. The host decides linked/snapshot mode.

How to verify: Find the token at grid2,2. A first token may be linked; another placement of the same actor may turn existing links into independent snapshots through the native service.

Move a controlled unlocked token

Enter the token ID and a nearby destination. path:[] requests the direct segment; intermediate waypoints must use the same grid coordinates. Player movement still checks walls and ownership.

javascript
async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!token.canControl||token.locked) throw new Error('Choose a controlled unlocked token');
    return await api.tokens.move({mapId:v.mapId,id:token.id,expectedVersion:token.version,gridX:v.x,gridY:v.y,path:[]},write());
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: The same token at its accepted grid position with an incremented version.

How to verify: Test as its controlling player: an unlocked open path succeeds, a wall-blocked path fails and leaves the position unchanged. An old expectedVersion conflicts.

Duplicate one tutorial token

Use the token placed above. The native service creates an independent snapshot and chooses its bounded offset.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    const result=await api.tokens.duplicate({mapId:v.mapId,tokenIds:[v.id]},write());
    return {createdIds:result.createdIds,tokens:result.tokens};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: A different token ID, usually offset by one cell, with linkMode snapshot.

How to verify: Inspect both IDs and open their sheets. Copy the duplicate ID for cleanup or combat.

Configure rotation, elevation and disposition

Configure a single disposable token. Keep locked false for the movement recipe. Elevation can affect region eligibility. A player may rotate a controlled token only with the rotation-only values variant; this multi-field recipe is GM-only.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.configure({mapId:v.mapId,tokenIds:[token.id],expectedVersion:token.version,values:{rotation:v.rotation,elevation:v.elevation,disposition:v.disposition,locked:v.locked}},write());
    return await api.tokens.get(token.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: Requested native rotation, elevation, disposition and locked state, retaining actor/sheet data.

How to verify: Inspect the native token configuration and confirm locked prevents a later move until explicitly cleared.

Enable and configure token vision

Use the placed token; range is a nonnegative cell distance. To observe line-of-sight, configure dynamic lighting in the native scene and use the token controlling player.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.setVision({mapId:v.mapId,tokenIds:[v.id],enabled:v.enabled,range:v.range},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: visionEnabled and visionRange match the form.

How to verify: Toggle enabled false/true and verify player visibility using the native lighting setup. A GM omniscient view alone does not prove player vision.

Hide and show the tutorial token

Run with Hidden selected, inspect the player account, then clear it and run again. This changes token visibility, not the actor permission map.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.setHidden({mapId:v.mapId,tokenIds:[v.id],hidden:v.hidden},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: GM retains the token; players receive it only when not hidden and otherwise authorized.

How to verify: Compare Inspect outputs in two accounts. Hidden presence still follows server presence-filter rules.

Add an original timed token condition

Use an original label. This is a generic marker, not a bundled rule. Copy condition_id from the result for Remove condition. Duration advances through real forward combat events when the token participates.

javascript
async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!['everyone','gm'].includes(v.visibility)) throw new Error('Use everyone or gm');
    if(!Number.isInteger(v.rounds)||v.rounds<1) throw new Error('Use a positive integer duration');
    if(v.visibility==='gm'&&context.role!=='gm') throw new Error('GM required for hidden conditions');
    await api.tokens.addCondition({mapId:v.mapId,tokenIds:[v.id],label:v.label,description:'Original tutorial marker with no RPG rules.',kind:'positive',visibility:v.visibility,duration:{type:'rounds',value:v.rounds,expires:'end'}},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: A new condition with unique condition_id and an explicit rounds/end duration.

How to verify: As player, add only to a controlled token with visibility everyone. A private GM condition must be absent from player state.

Remove the chosen token condition

Paste the exact condition_id, not its display label. Remove only the marker created in this tutorial.

javascript
async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!token.conditions.some(item=>item.condition_id===v.conditionId)) throw new Error('Choose a visible condition from this token');
    await api.tokens.removeCondition({mapId:v.mapId,tokenIds:[v.id],conditionId:v.conditionId},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: The selected condition disappears while other conditions remain.

How to verify: Inspect before/after. A player cannot discover/remove a GM-only condition by guessing its ID.

Remove the disposable token

Use only the tutorial token/duplicate. Run after combat cleanup. Removing a token does not delete its actor.

javascript
async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable token');
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.remove({mapId:v.mapId,tokenIds:[v.id]},write());
    return await api.tokens.list({mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}

Expected result: The selected token is absent; its actor remains in Actors.

How to verify: Check the scene and actor directory, and confirm other token IDs remain.

4. Understand and adapt the complete files

main.mjs renders the launcher and form. recipes.mjs owns behavior: change the chosen recipe’s run function while keeping its signature. The form passes real number, boolean and File values, blocks overlapping requests and renders using textContent. It does not evaluate user-provided code. api, host, assets and storage belong to the mount; signal cancels work on deactivation. The modal stays open during pending work and is released when the mount aborts. Each business function remains responsible for revisions, permissions and transaction sequencing.

manifest.json

json
{
  "id": "tutorial.tokens-workshop",
  "name": "Token workshop",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Place, move, duplicate, configure and remove tokens; control native vision/visibility and timed conditions.",
  "author": "Gravewright documentation",
  "license": "MIT"
}

main.mjs

javascript
import {recipes} from './recipes.mjs';
export default {
  start({styles}) { styles.use('styles.css'); },
  register({register}) {
    register("table.interface", ({root,api,host,assets,storage,context,events,viewport,signal,onDispose}) => {
      const doc=root.ownerDocument;
      const button=doc.createElement('button');
      button.type='button';button.className='tutorial-launcher';
      button.textContent="Token workshop";
      root.append(button);
      for(const type of ['pointerdown','mousedown'])button.addEventListener(type,event=>event.stopPropagation(),{signal});
      let modal,pending=false;
      button.addEventListener('click',event=>{
        event.stopPropagation();
        if(modal?.isConnected) { modal.focus();return; }
        const panel=doc.createElement('section');
        panel.className="tutorial-workbench-tokens-workshop";
        panel.setAttribute('role','dialog');panel.setAttribute('aria-modal','true');
        panel.setAttribute('aria-label',button.textContent);panel.tabIndex=-1;
        const title=doc.createElement('h2');title.textContent=button.textContent;panel.append(title);
        const choose=doc.createElement('select');choose.setAttribute('aria-label','Action');
        for(const recipe of recipes) {
          const option=doc.createElement('option');option.value=recipe.id;option.textContent=recipe.label;choose.append(option);
        }
        const form=doc.createElement('form'),fields=doc.createElement('div');
        const description=doc.createElement('p');
        const run=doc.createElement('button');run.type='submit';run.textContent='Run action';
        const close=doc.createElement('button');close.type='button';close.textContent='Close';
        const output=doc.createElement('pre');output.setAttribute('role','status');output.setAttribute('aria-live','polite');
        form.append(description,fields,run,close,output);panel.append(choose,form);
        function renderFields(){
          const recipe=recipes.find(item=>item.id===choose.value);fields.replaceChildren();
          description.textContent=recipe.description;
          for(const field of recipe.fields||[]) {
            const label=doc.createElement('label');label.textContent=field.label;
            const input=doc.createElement('input');input.name=field.name;input.type=field.type||'text';
            if(field.type==='number')input.step=String(field.step??'any');
            if(field.type==='checkbox')input.checked=!!field.value;
            else if(field.type!=='file')input.value=String(field.value??'');
            input.required=!!field.required;
            if(field.accept)input.accept=field.accept;
            label.append(input);fields.append(label);
          }
          output.textContent='';
        }
        choose.addEventListener('change',renderFields,{signal});renderFields();
        form.addEventListener('submit',async event=>{
          event.preventDefault();if(pending)return;
          const recipe=recipes.find(item=>item.id===choose.value),values={};
          for(const input of fields.querySelectorAll('input')) {
            values[input.name]=input.type==='file'?input.files[0]:input.type==='checkbox'?input.checked:input.type==='number'?Number(input.value):input.value;
          }
          pending=true;choose.disabled=true;run.disabled=true;close.disabled=true;
          for(const input of fields.querySelectorAll('input'))input.disabled=true;
          output.textContent='Running…';
          try {
            const value=await recipe.run({api,host,assets,storage,context,events,viewport,signal,onDispose},values);
            if(!signal.aborted)output.textContent=typeof value==='string'?value:JSON.stringify(value??null,null,2);
          } catch(error) {
            if(!signal.aborted)output.textContent=String(error.code||error.message||'unavailable');
          } finally {
            pending=false;
            if(!signal.aborted){choose.disabled=false;run.disabled=false;close.disabled=false;for(const input of fields.querySelectorAll('input'))input.disabled=false;}
          }
        },{signal});
        close.addEventListener('click',()=>void modal.close(),{signal});
        modal=api.ui.openModal(panel,{canClose:()=>!pending||signal.aborted});
      },{signal});
      onDispose(()=>{button.remove();if(modal)void modal.close();});
    });
  },
  stop() {}
};

recipes.mjs

javascript
export const recipes=[
{
  id:"inspect",
  label:"Inspect available actors and scene tokens",
  description:"Paste the test Scene ID from the maps tutorial. Copy a readable actor ID for Place, then a token ID for later actions.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    return {actors:await api.actors.list({},read),tokens:await api.tokens.list({mapId:v.mapId},read)};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"place",
  label:"Place an actor token in the test scene",
  description:"Create an original disposable actor using the native/system sheet first. Coordinates use cell-space gridX/gridY for token placement, not scene pixels. The host validates bounds and token size.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "actorId",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "x",
    "label": "Grid x",
    "value": 2,
    "type": "number",
    "required": true
  },
  {
    "name": "y",
    "label": "Grid y",
    "value": 2,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.actors.get(v.actorId,{},read);
    const created=await api.tokens.place({mapId:v.mapId,actorId:v.actorId,gridX:v.x,gridY:v.y},write());
    return {createdIds:created.createdIds,tokens:created.tokens};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"move",
  label:"Move a controlled unlocked token",
  description:"Enter the token ID and a nearby destination. path:[] requests the direct segment; intermediate waypoints must use the same grid coordinates. Player movement still checks walls and ownership.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "x",
    "label": "Destination grid x",
    "value": 3,
    "type": "number",
    "required": true
  },
  {
    "name": "y",
    "label": "Destination grid y",
    "value": 2,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!token.canControl||token.locked) throw new Error('Choose a controlled unlocked token');
    return await api.tokens.move({mapId:v.mapId,id:token.id,expectedVersion:token.version,gridX:v.x,gridY:v.y,path:[]},write());
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"duplicate",
  label:"Duplicate one tutorial token",
  description:"Use the token placed above. The native service creates an independent snapshot and chooses its bounded offset.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    const result=await api.tokens.duplicate({mapId:v.mapId,tokenIds:[v.id]},write());
    return {createdIds:result.createdIds,tokens:result.tokens};
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"configure",
  label:"Configure rotation, elevation and disposition",
  description:"Configure a single disposable token. Keep locked false for the movement recipe. Elevation can affect region eligibility. A player may rotate a controlled token only with the rotation-only values variant; this multi-field recipe is GM-only.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "rotation",
    "label": "Rotation degrees",
    "value": 90,
    "type": "number",
    "required": true
  },
  {
    "name": "elevation",
    "label": "Elevation",
    "value": 0,
    "type": "number",
    "required": true
  },
  {
    "name": "disposition",
    "label": "friendly, neutral, hostile or unknown",
    "value": "friendly",
    "type": "text",
    "required": true
  },
  {
    "name": "locked",
    "label": "Lock token movement",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.configure({mapId:v.mapId,tokenIds:[token.id],expectedVersion:token.version,values:{rotation:v.rotation,elevation:v.elevation,disposition:v.disposition,locked:v.locked}},write());
    return await api.tokens.get(token.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"vision",
  label:"Enable and configure token vision",
  description:"Use the placed token; range is a nonnegative cell distance. To observe line-of-sight, configure dynamic lighting in the native scene and use the token controlling player.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "enabled",
    "label": "Enable vision",
    "value": true,
    "type": "checkbox",
    "required": false
  },
  {
    "name": "range",
    "label": "Vision range in cells",
    "value": 6,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.setVision({mapId:v.mapId,tokenIds:[v.id],enabled:v.enabled,range:v.range},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"hidden",
  label:"Hide and show the tutorial token",
  description:"Run with Hidden selected, inspect the player account, then clear it and run again. This changes token visibility, not the actor permission map.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "hidden",
    "label": "Hide from players",
    "value": true,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.setHidden({mapId:v.mapId,tokenIds:[v.id],hidden:v.hidden},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"condition-add",
  label:"Add an original timed token condition",
  description:"Use an original label. This is a generic marker, not a bundled rule. Copy condition_id from the result for Remove condition. Duration advances through real forward combat events when the token participates.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "label",
    "label": "Condition label",
    "value": "Workshop focus",
    "type": "text",
    "required": true
  },
  {
    "name": "visibility",
    "label": "everyone or gm",
    "value": "everyone",
    "type": "text",
    "required": true
  },
  {
    "name": "rounds",
    "label": "Duration in rounds",
    "value": 2,
    "type": "number",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!['everyone','gm'].includes(v.visibility)) throw new Error('Use everyone or gm');
    if(!Number.isInteger(v.rounds)||v.rounds<1) throw new Error('Use a positive integer duration');
    if(v.visibility==='gm'&&context.role!=='gm') throw new Error('GM required for hidden conditions');
    await api.tokens.addCondition({mapId:v.mapId,tokenIds:[v.id],label:v.label,description:'Original tutorial marker with no RPG rules.',kind:'positive',visibility:v.visibility,duration:{type:'rounds',value:v.rounds,expires:'end'}},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"condition-remove",
  label:"Remove the chosen token condition",
  description:"Paste the exact condition_id, not its display label. Remove only the marker created in this tutorial.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "conditionId",
    "label": "condition_id from Inspect",
    "value": "",
    "type": "text",
    "required": true
  }
],
  run:async ({api,context,signal},v) => {

  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    const token=await api.tokens.get(v.id,{mapId:v.mapId},read);
    if(!token.conditions.some(item=>item.condition_id===v.conditionId)) throw new Error('Choose a visible condition from this token');
    await api.tokens.removeCondition({mapId:v.mapId,tokenIds:[v.id],conditionId:v.conditionId},write());
    return await api.tokens.get(v.id,{mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
},
{
  id:"remove",
  label:"Remove the disposable token",
  description:"Use only the tutorial token/duplicate. Run after combat cleanup. Removing a token does not delete its actor.",
  fields:[
  {
    "name": "mapId",
    "label": "Scene ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "id",
    "label": "Token ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I selected disposable tutorial data",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,context,signal},v) => {
  if(context.role!=='gm') throw new Error('GM required');
  const read={signal};
  const write=()=>{
    if(signal?.aborted) throw Object.assign(new Error('Mount closed'),{code:'cancelled'});
    return {signal,requestId:crypto.randomUUID()};
  };
  try {
    if(!v.confirm) throw new Error('Confirm the disposable token');
    await api.tokens.get(v.id,{mapId:v.mapId},read);
    await api.tokens.remove({mapId:v.mapId,tokenIds:[v.id]},write());
    return await api.tokens.list({mapId:v.mapId},read);
  } catch(problem) {
    if(problem.code==='conflict'&&!signal?.aborted) await api.tokens.state({mapId:v.mapId},read);
    throw problem;
  }
}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.tokens-workshop"] .tutorial-launcher { position: fixed; left: 16px; bottom: 112px; z-index: 10; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface); color: var(--text); font-size: 14px; pointer-events: auto; max-width: min(440px, calc(100vw - 32px)); white-space: normal; }
.tutorial-workbench-tokens-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-tokens-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-tokens-workshop input, .tutorial-workbench-tokens-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-tokens-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-tokens-workshop button { margin-right: var(--space-3); }

LICENSE.txt

text
MIT License

Copyright (c) 2026 Gravewright contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5. Permissions, failures and final test

Every write is an explicit Run action. Reads use recipient projections; writes keep native permissions and fresh operation UUIDs. Scene update, object update, token move/configuration and combat use their real revision fields. Some native selection commands have no expected revision parameter in this release; the examples do not invent one. On conflict the recipe rereads current state and stops. Review it, then intentionally run a new operation. Multiple API calls are separate transactions: a completed earlier call is not rolled back if a later read fails. Deactivation aborts scoped work; it cannot undo a server commit.

Use GM and controlling/noncontrolling player accounts. Test a locked token, blocked player movement, hidden token filtering, private condition filtering, a stale expectedVersion and a token from another scene. Condition advancement belongs to real combat events, not a local UI timer. Inspect after reconnect instead of replaying mutation events as side effects.

Public operations covered by recipe

APIPanel recipe
api.tokens.placeplace
api.tokens.movemove
api.tokens.removeremove
api.tokens.duplicateduplicate
api.tokens.setHiddenhidden
api.tokens.setVisionvision
api.tokens.configureconfigure
api.tokens.addConditioncondition-add
api.tokens.removeConditioncondition-remove

How to use AI to extend this project

text
Extend Token workshop for Gravewright0.3.0 to [task]. Inspect tokens/services.py and frontend.json. Keep grid-cell positions distinct from scene pixels, use real expectedVersion for move/configure, preserve linked/snapshot semantics and never bypass control/locked/hidden filters. Deliver full code and concrete GM/player, wall-blocked, stale-version and abort tests.

All tutorials · API