HomeDeveloper docsBuild an actor-directory module
Implementation tutorials 10 min read

Build an actor-directory module

Create, organize, rename and authorize actors using system-owned types.

Installed Build an actor-directory module module after running its inspect recipe. Gravewright 0.3.0, English interface.
Installed Build an actor-directory module module after running its inspect recipe. Gravewright 0.3.0, English interface. · Open full screenshot ↗

The project you will build

Build a GM actor organizer. Start by inspecting actual types, create one disposable actor and folder, move/rename them and grant one player read access. The sheet tutorial covers full sheet data and independent tokens.

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 actor-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
actor-workshop/
  manifest.json
  main.mjs
  recipes.mjs
  styles.css
  LICENSE.txt

2. Package, install and activate

From the project folder, run in PowerShell:

powershell
python -m json.tool manifest.json
python -m zipfile -c ../actor-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../actor-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 Build an actor-directory module. Close that menu and click Build an actor-directory module. 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 actor types, documents and folders

Run this first. The actorType comes from the selected system; copy an existing actor ID for later steps.

javascript
async ({api,signal}) => {
  const state=await api.actors.state({}, {signal});
  return {actorTypes:state.actorTypes,actors:state.actors,folders:state.folders,players:state.players,isGM:state.is_gm};
}

Expected result: A projected directory, registered type IDs and player IDs. Sheet bodies are read separately.

How to verify: Compare with Actors as GM and as player. Listing-only entries have canOpen:false.

Create an actor using the selected system

Enter a fictional name; create only a disposable actor. This uses the first real registered type, not an assumed RPG type.

javascript
async ({api,signal},v) => {
  const state=await api.actors.state({}, {signal});
  if(!state.is_gm) throw new Error('GM required');
  const type=state.actorTypes[0]?.id;
  if(!type) throw new Error('Select a system declaring actor types first');
  const created=await api.actors.create({name:v.name,type,data:{}},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(created.id,{}, {signal});
}

Expected result: The created actor metadata including its ID.

How to verify: Open Actors, find Workshop scout and open its native/system sheet.

Rename an actor with an explicit revision

Paste the ID created above and a new name. Only metadata changes; use saveSheet when editing system data.

javascript
async ({api,signal},v) => {
  const before=await api.actors.get(v.id,{}, {signal});
  await api.actors.update(v.id,{name:v.name,version:before.version},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(v.id,{}, {signal});
}

Expected result: The same ID, new name and a new version.

How to verify: Refresh the directory. An old version must return conflict, never a silent forced update.

Create and identify an actor folder

Choose an original folder name. The service does not promise an ID in every command result, so find the newly added folder in state.

javascript
async ({api,signal},v) => {
  const before=await api.actors.state({}, {signal});
  if(!before.is_gm) throw new Error('GM required');
  await api.actors.createFolder({name:v.name,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
  const after=await api.actors.state({}, {signal});
  return after.folders.filter(folder=>!before.folders.some(old=>old.id===folder.id));
}

Expected result: A newly created folder and its real UUID.

How to verify: Find the same folder in Actors; copy its ID.

Rename a folder and move an actor into it

Use the disposable folder and actor IDs. parentId:null moves the folder to the root. Nesting is bounded by the service.

javascript
async ({api,signal},v) => {
  await api.actors.updateFolder({id:v.folderId,name:v.name,parentId:null,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
  const actor=await api.actors.get(v.actorId,{}, {signal});
  await api.actors.update(actor.id,{folderId:v.folderId,version:actor.version},{requestId:crypto.randomUUID(),signal});
  return api.actors.state({}, {signal});
}

Expected result: Actor folderId now matches the folder.

How to verify: Expand the folder in Actors and find the actor inside.

Grant one player read access

Copy a real player ID from Inspect, not an actor ID. Merge the existing permission map to preserve other grants.

javascript
async ({api,signal},v) => {
  const actor=await api.actors.get(v.actorId,{}, {signal});
  if(!actor.permissions) throw new Error('GM projection required');
  await api.actors.setPermissions({id:actor.id,version:actor.version,permissions:{...actor.permissions,[v.userId]:'read'}},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(actor.id,{}, {signal});
}

Expected result: The GM sees the retained permission map with the new read grant.

How to verify: Open a second player session: reading succeeds, saving the sheet remains denied.

Remove only the disposable actor folder

Confirm a folder you created in this tutorial. recursive:false preserves actors; never use recursive:true as an unrelated cleanup shortcut.

javascript
async ({api,signal},v) => {
  if(!v.confirm) throw new Error('Select the disposable folder confirmation');
  await api.actors.deleteFolder({id:v.folderId,recursive:false},{requestId:crypto.randomUUID(),signal});
  return api.actors.state({}, {signal});
}

Expected result: The folder disappears; its actors survive with native folder-removal behavior.

How to verify: Check that your disposable actor remains in Actors.

Delete the disposable actor

Use only the actor created above. Deleting an actor can also remove its tokens through native domain services.

javascript
async ({api,signal},v) => {
  if(!v.confirm) throw new Error('Select the disposable actor confirmation');
  await api.actors.delete({id:v.id},{requestId:crypto.randomUUID(),signal});
  return api.actors.list({}, {signal});
}

Expected result: The actor no longer appears in the authorized list.

How to verify: Refresh Actors and the test scene. No user data outside this test should be selected.

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.actor-workshop",
  "name": "Build an actor-directory module",
  "version": "1.0.0",
  "sdk": {
    "requires": ">=1.0.0 <2.0.0",
    "tested": "1.0.0"
  },
  "entry": "main.mjs",
  "description": "Create, organize, rename and authorize actors using system-owned types.",
  "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="Build an actor-directory module";
      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-actor-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 actor types, documents and folders",
  description:"Run this first. The actorType comes from the selected system; copy an existing actor ID for later steps.",
  fields:[],
  run:async ({api,signal}) => {
  const state=await api.actors.state({}, {signal});
  return {actorTypes:state.actorTypes,actors:state.actors,folders:state.folders,players:state.players,isGM:state.is_gm};
}
},
{
  id:"create",
  label:"Create an actor using the selected system",
  description:"Enter a fictional name; create only a disposable actor. This uses the first real registered type, not an assumed RPG type.",
  fields:[
  {
    "name": "name",
    "label": "Actor name",
    "value": "Workshop scout",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},v) => {
  const state=await api.actors.state({}, {signal});
  if(!state.is_gm) throw new Error('GM required');
  const type=state.actorTypes[0]?.id;
  if(!type) throw new Error('Select a system declaring actor types first');
  const created=await api.actors.create({name:v.name,type,data:{}},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(created.id,{}, {signal});
}
},
{
  id:"rename",
  label:"Rename an actor with an explicit revision",
  description:"Paste the ID created above and a new name. Only metadata changes; use saveSheet when editing system data.",
  fields:[
  {
    "name": "id",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "name",
    "label": "New name",
    "value": "Workshop navigator",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},v) => {
  const before=await api.actors.get(v.id,{}, {signal});
  await api.actors.update(v.id,{name:v.name,version:before.version},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(v.id,{}, {signal});
}
},
{
  id:"folder-create",
  label:"Create and identify an actor folder",
  description:"Choose an original folder name. The service does not promise an ID in every command result, so find the newly added folder in state.",
  fields:[
  {
    "name": "name",
    "label": "Folder name",
    "value": "Workshop actors",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},v) => {
  const before=await api.actors.state({}, {signal});
  if(!before.is_gm) throw new Error('GM required');
  await api.actors.createFolder({name:v.name,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
  const after=await api.actors.state({}, {signal});
  return after.folders.filter(folder=>!before.folders.some(old=>old.id===folder.id));
}
},
{
  id:"folder-edit",
  label:"Rename a folder and move an actor into it",
  description:"Use the disposable folder and actor IDs. parentId:null moves the folder to the root. Nesting is bounded by the service.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "actorId",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "name",
    "label": "Folder name",
    "value": "Workshop party",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},v) => {
  await api.actors.updateFolder({id:v.folderId,name:v.name,parentId:null,color:'#b49a5a'},{requestId:crypto.randomUUID(),signal});
  const actor=await api.actors.get(v.actorId,{}, {signal});
  await api.actors.update(actor.id,{folderId:v.folderId,version:actor.version},{requestId:crypto.randomUUID(),signal});
  return api.actors.state({}, {signal});
}
},
{
  id:"permissions",
  label:"Grant one player read access",
  description:"Copy a real player ID from Inspect, not an actor ID. Merge the existing permission map to preserve other grants.",
  fields:[
  {
    "name": "actorId",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "userId",
    "label": "Player user ID",
    "value": "",
    "type": "text",
    "required": true
  }
],
  run:async ({api,signal},v) => {
  const actor=await api.actors.get(v.actorId,{}, {signal});
  if(!actor.permissions) throw new Error('GM projection required');
  await api.actors.setPermissions({id:actor.id,version:actor.version,permissions:{...actor.permissions,[v.userId]:'read'}},{requestId:crypto.randomUUID(),signal});
  return api.actors.get(actor.id,{}, {signal});
}
},
{
  id:"delete-folder",
  label:"Remove only the disposable actor folder",
  description:"Confirm a folder you created in this tutorial. recursive:false preserves actors; never use recursive:true as an unrelated cleanup shortcut.",
  fields:[
  {
    "name": "folderId",
    "label": "Folder ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I chose the disposable folder",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,signal},v) => {
  if(!v.confirm) throw new Error('Select the disposable folder confirmation');
  await api.actors.deleteFolder({id:v.folderId,recursive:false},{requestId:crypto.randomUUID(),signal});
  return api.actors.state({}, {signal});
}
},
{
  id:"delete-actor",
  label:"Delete the disposable actor",
  description:"Use only the actor created above. Deleting an actor can also remove its tokens through native domain services.",
  fields:[
  {
    "name": "id",
    "label": "Actor ID",
    "value": "",
    "type": "text",
    "required": true
  },
  {
    "name": "confirm",
    "label": "I chose the disposable actor",
    "value": false,
    "type": "checkbox",
    "required": false
  }
],
  run:async ({api,signal},v) => {
  if(!v.confirm) throw new Error('Select the disposable actor confirmation');
  await api.actors.delete({id:v.id},{requestId:crypto.randomUUID(),signal});
  return api.actors.list({}, {signal});
}
}
];

styles.css

css
.gw-module-root[data-module-id="tutorial.actor-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-actor-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-actor-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-actor-workshop input, .tutorial-workbench-actor-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-actor-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-actor-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

Perform writes only in a new development campaign with original resources. The server remains authoritative when controls are hidden. Test GM, read-only player, a missing UUID, another campaign’s ID, network failure and deactivation during a pending request. A conflict requires a fresh read and a user decision; do not silently force retries. Every new logical write needs a new requestId. Only reuse one to retry that exact operation when the service supports receipts. Read and write steps are separate transactions, so inspect persisted state after a partial failure.

How to use AI to extend this project

text
Adapt this complete actor-workshop module for [specific goal] on Gravewright 0.3.0. Read the corresponding public service and recipe. Preserve authorized projections, opaque revisions, request UUIDs, cancellation and cleanup. Provide complete files and a new immutable version, plus explicit GM/player and failure tests.

All tutorials · API

Source in the project

gravewright/actors/services.py