Upload and use module and campaign files
Use File/Blob, actor images, card assets, native file providers and downloads.

The project you will build
Create a file-handling module, not a URL-writing shortcut. Its forms pass real File objects to the public multipart SDK. Each recipe explains which native library owns the resulting ID. Package assets are covered by the next localization/events tutorial.
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 asset-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.
asset-workshop/
manifest.json
main.mjs
recipes.mjs
styles.css
LICENSE.txt2. Package, install and activate
From the project folder, run in PowerShell:
python -m json.tool manifest.json
python -m zipfile -c ../asset-workshop-1.0.0.zip manifest.json main.mjs recipes.mjs styles.css LICENSE.txt
python -m zipfile -l ../asset-workshop-1.0.0.zipOn 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 Upload and use module and campaign files. Close that menu and click Upload and use module and campaign files. 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
List reusable campaign assets
Use a test campaign with a native-supported asset, such as an original PDF in the PDF system. This operation lists unattached actor-template assets, not every map/audio file.
async ({host,signal}) => host.call('asset.list',{}, {signal})Expected result: items with id, name, contentType and createdAt.
How to verify: Compare the native asset/template library; an empty array is valid.
Upload a real provider-supported file
Choose your own small valid PDF on a PDF campaign. asset.upload selects a registered content provider and its validator; it is not an arbitrary binary file store.
async ({host,signal},v) => {
if(!(v.file instanceof File)) throw new Error('Choose a file');
return host.call('asset.upload',{file:v.file},{signal});
}Expected result: An asset DTO with its new ID; unsupported content fails invalid_data.
How to verify: List again and compare its name. Test a text file and verify rejection.
Read a downloadable file as a Blob
Paste an asset ID from List. The result contains a Blob; it is not a URL string. A UI may create an object URL for download and must revoke it afterward.
async ({host,signal},v) => {
const result=await host.call('asset.download',{id:v.id},{signal});
return {name:result.name,contentType:result.contentType,size:result.size,blobBytes:result.blob.size};
}Expected result: Metadata and equal size/blobBytes without dumping file contents into the page.
How to verify: Compare size with the original. Another campaign’s ID must not be readable.
Upload portrait or token artwork
Use portrait, then token, with an editable disposable actor. Multipart requires a real File. Limits and conversion are enforced by the host.
async ({host,signal},v) => {
if(!['portrait','token'].includes(v.kind))throw new Error('kind must be portrait or token');
if(!(v.file instanceof File))throw new Error('Choose an image');
return host.call('actor.image.upload',{id:v.id,kind:v.kind,file:v.file},{signal});
}Expected result: The saved image URL. actor.version changes; re-read before subsequent writes.
How to verify: Reopen the actor and inspect both images. Read-only player and invalid image must fail.
Upload front/back artwork for a deck
As GM, choose an original image and purpose. Paste the returned assetId into the card-definition tutorial, not an external URL.
async ({host,signal},v) => {
if(!['card_front','card_back'].includes(v.purpose))throw new Error('Choose card_front or card_back');
return host.call('card.asset.upload',{purpose:v.purpose,file:v.file},{signal});
}Expected result: assetId for the native card asset library.
How to verify: Define a deck with front_asset_id/back_asset_id using this ID and inspect it in Cards.
Delete only a reusable tutorial asset
Select only the unattached reusable asset you uploaded. Native deletion checks provider support and updates references. This does not delete arbitrary media paths.
async ({api,signal},v) => {
if(!v.confirm)throw new Error('Confirm the disposable asset');
await api.actors.deleteAsset({id:v.id},{requestId:crypto.randomUUID(),signal});
return {deleted:v.id};
}Expected result: The asset is removed through the native actor domain.
How to verify: List it again; it must be absent. Actor-owned artwork IDs are not accepted here.
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
{
"id": "tutorial.asset-workshop",
"name": "Upload and use module and campaign files",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"entry": "main.mjs",
"description": "Use File/Blob, actor images, card assets, native file providers and downloads.",
"author": "Gravewright documentation",
"license": "MIT"
}main.mjs
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="Upload and use module and campaign files";
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-asset-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
export const recipes=[
{
id:"list",
label:"List reusable campaign assets",
description:"Use a test campaign with a native-supported asset, such as an original PDF in the PDF system. This operation lists unattached actor-template assets, not every map/audio file.",
fields:[],
run:async ({host,signal}) => host.call('asset.list',{}, {signal})
},
{
id:"upload",
label:"Upload a real provider-supported file",
description:"Choose your own small valid PDF on a PDF campaign. asset.upload selects a registered content provider and its validator; it is not an arbitrary binary file store.",
fields:[
{
"name": "file",
"label": "Original supported file",
"value": "",
"type": "file",
"required": true
}
],
run:async ({host,signal},v) => {
if(!(v.file instanceof File)) throw new Error('Choose a file');
return host.call('asset.upload',{file:v.file},{signal});
}
},
{
id:"download",
label:"Read a downloadable file as a Blob",
description:"Paste an asset ID from List. The result contains a Blob; it is not a URL string. A UI may create an object URL for download and must revoke it afterward.",
fields:[
{
"name": "id",
"label": "Reusable asset ID",
"value": "",
"type": "text",
"required": true
}
],
run:async ({host,signal},v) => {
const result=await host.call('asset.download',{id:v.id},{signal});
return {name:result.name,contentType:result.contentType,size:result.size,blobBytes:result.blob.size};
}
},
{
id:"actor-image",
label:"Upload portrait or token artwork",
description:"Use portrait, then token, with an editable disposable actor. Multipart requires a real File. Limits and conversion are enforced by the host.",
fields:[
{
"name": "id",
"label": "Actor ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "kind",
"label": "Image kind",
"value": "portrait",
"type": "text",
"required": true
},
{
"name": "file",
"label": "Original PNG/JPEG/WebP",
"value": "",
"type": "file",
"required": true
}
],
run:async ({host,signal},v) => {
if(!['portrait','token'].includes(v.kind))throw new Error('kind must be portrait or token');
if(!(v.file instanceof File))throw new Error('Choose an image');
return host.call('actor.image.upload',{id:v.id,kind:v.kind,file:v.file},{signal});
}
},
{
id:"card-image",
label:"Upload front/back artwork for a deck",
description:"As GM, choose an original image and purpose. Paste the returned assetId into the card-definition tutorial, not an external URL.",
fields:[
{
"name": "purpose",
"label": "Purpose",
"value": "card_front",
"type": "text",
"required": true
},
{
"name": "file",
"label": "Original PNG/JPEG/WebP",
"value": "",
"type": "file",
"required": true
}
],
run:async ({host,signal},v) => {
if(!['card_front','card_back'].includes(v.purpose))throw new Error('Choose card_front or card_back');
return host.call('card.asset.upload',{purpose:v.purpose,file:v.file},{signal});
}
},
{
id:"delete",
label:"Delete only a reusable tutorial asset",
description:"Select only the unattached reusable asset you uploaded. Native deletion checks provider support and updates references. This does not delete arbitrary media paths.",
fields:[
{
"name": "id",
"label": "Reusable asset ID",
"value": "",
"type": "text",
"required": true
},
{
"name": "confirm",
"label": "This is my disposable asset",
"value": false,
"type": "checkbox",
"required": false
}
],
run:async ({api,signal},v) => {
if(!v.confirm)throw new Error('Confirm the disposable asset');
await api.actors.deleteAsset({id:v.id},{requestId:crypto.randomUUID(),signal});
return {deleted:v.id};
}
}
];styles.css
.gw-module-root[data-module-id="tutorial.asset-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-asset-workshop { width: min(640px, calc(100vw - 48px)); padding: var(--space-5); font-size: 14px; }
.tutorial-workbench-asset-workshop label { display: block; margin: var(--space-3) 0; }
.tutorial-workbench-asset-workshop input, .tutorial-workbench-asset-workshop select { display: block; width: 100%; margin-top: var(--space-2); }
.tutorial-workbench-asset-workshop pre { max-height: 360px; max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.tutorial-workbench-asset-workshop button { margin-right: var(--space-3); }LICENSE.txt
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
Adapt this complete asset-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.