Skip to content

Recipes

Joël Deffner edited this page Sep 4, 2026 · 2 revisions

Recipes

Working snippets for the tasks a mod tool actually performs. Every block is a complete file: copy it, change the app id and the paths, run it with tsx. The Steam client must be running and logged in. steam.close() stops the dispatch pump and shuts the API down, so call it before the process ends.

Upload a new item with progress

createItem gives you an empty item, submitUpdate fills it. Upload it as private first so a half finished mod never appears in the public listing, then flip the visibility when you are done. onProgress polls GetItemUpdateProgress every 500 ms by default and p.status is an EItemUpdateStatus member. Keep the returned fileId, a bigint: it is the only handle to the item from now on.

import { init, flat, type UpdateProgress } from 'steamwand.js';

const VISIBILITY = flat.ERemoteStoragePublishedFileVisibility;
const onProgress = (p: UpdateProgress) => {
  const pct = p.bytesTotal > 0n ? Number((p.bytesProcessed * 100n) / p.bytesTotal) : 0;
  console.log(`status ${p.status} ${pct}%`);
};

async function main() {
  const steam = init({ appId: 480 });
  try {
    const created = await steam.workshop.createItem();
    if (created.legalAgreementRequired) console.warn('accept the Workshop legal agreement first');
    await steam.workshop.submitUpdate(
      created.fileId,
      {
        title: 'Custom Name Lists',
        description: 'Adds name lists for every culture.',
        contentPath: 'C:/mods/name-lists',
        previewPath: 'C:/mods/name-lists/preview.png',
        tags: ['gameplay', 'localization'],
        visibility: VISIBILITY.k_ERemoteStoragePublishedFileVisibilityPrivate,
        changeNote: 'first upload',
      },
      { onProgress, progressIntervalMs: 250 },
    );
    console.log('published as', created.fileId.toString());
  } finally {
    steam.close();
  }
}

void main();

Update an existing item

submitUpdate only writes the fields you pass, so a content-only update leaves title, description and tags alone. Do not pass contentPath when you only change text, because it re-uploads the whole folder. A non-OK result throws SteamResultError, which carries the operation and the raw EResult.

import { init, SteamResultError, eResultName } from 'steamwand.js';

const FILE_ID = 3786319531n;

async function main() {
  const steam = init({ appId: 480 });
  try {
    // Text only. The uploaded content is untouched.
    await steam.workshop.submitUpdate(FILE_ID, {
      description: 'Adds name lists for every culture, now including Iberia.',
      changeNote: '1.4.0: Iberian name lists',
    });
    // Content only, later.
    await steam.workshop.submitUpdate(FILE_ID, {
      contentPath: 'C:/mods/name-lists',
      changeNote: '1.4.1: fixed a typo in the Castilian list',
    });
  } catch (err) {
    if (err instanceof SteamResultError) console.error(err.operation, eResultName(err.result));
    else throw err;
  } finally {
    steam.close();
  }
}

void main();

Add translations for several languages

Steam keeps one title and one description per language, and an update handle writes to exactly one language. A translated item therefore needs one submitUpdate per language, run in sequence, never in parallel. Submit the default text first, because that is what Steam shows to anyone whose client language has no translation.

import { init } from 'steamwand.js';

const FILE_ID = 3786319531n;

const translations: Record<string, { title: string; description: string }> = {
  german: { title: 'Eigene Namenslisten', description: 'Namenslisten fuer jede Kultur.' },
  french: { title: 'Listes de noms', description: 'Des listes de noms pour chaque culture.' },
  schinese: { title: '自定义名称列表', description: '为每种文化添加名称列表。' },
};

async function main() {
  const steam = init({ appId: 480 });
  try {
    // Default (English) text first.
    await steam.workshop.submitUpdate(FILE_ID, {
      title: 'Custom Name Lists',
      description: 'Adds name lists for every culture.',
      changeNote: 'text update',
    });
    for (const [language, text] of Object.entries(translations)) {
      await steam.workshop.submitUpdate(FILE_ID, { language, ...text });
      console.log('wrote', language);
    }
    // Read one back to confirm.
    const de = await steam.workshop.getItem(FILE_ID, { language: 'german' });
    console.log(de?.title, '|', de?.description);
  } finally {
    steam.close();
  }
}

void main();

language takes Steam's API language code (german, schinese, brazilian), not the display name and not an ISO code. See Workshop.

Walk a user's published items page by page

getUserItems takes a 1-based page and a 32-bit account id, which is the lower half of the Steam id. steam.accountId() gives you the local user's. Each page holds at most flat.kNumUGCResultsPerPage (50) items, and totalResults counts the whole list, so stop when you have collected that many or when a page comes back empty.

import { init, flat, type WorkshopItem } from 'steamwand.js';

async function main() {
  const steam = init({ appId: 480 });
  try {
    const all: WorkshopItem[] = [];
    let total = 0;
    for (let page = 1; ; page++) {
      const result = await steam.workshop.getUserItems(page, steam.accountId(), {
        sortOrder: flat.EUserUGCListSortOrder.k_EUserUGCListSortOrder_CreationOrderDesc,
      });
      total = result.totalResults;
      all.push(...result.items);
      if (result.items.length === 0 || all.length >= total) break;
    }
    console.log(`${all.length} of ${total} items`);
    for (const item of all) {
      const updated = new Date(item.timeUpdated * 1000).toISOString().slice(0, 10);
      console.log(item.fileId.toString(), updated, item.title, item.statistics.numSubscriptions);
    }
  } finally {
    steam.close();
  }
}

void main();

Change listType to read another list, for example flat.EUserUGCList.k_EUserUGCList_Subscribed or k_EUserUGCList_Favorited.

Read an item that belongs to another app

getItem queries by published file id, and the file id is global. A tool initialized on Spacewar (480) can therefore read a Crusader Kings III item without owning or running that game. longDescription: true gives the full description instead of the truncated preview text, and language picks which translation you get back.

import { init } from 'steamwand.js';

const CK3_ITEM = 3786319531n;

async function main() {
  const steam = init({ appId: 480 });
  try {
    const item = await steam.workshop.getItem(CK3_ITEM, {
      language: 'japanese',
      longDescription: true,
    });
    if (!item) return console.log('no such item');
    console.log(item.title, 'in app', item.consumerAppId);
    console.log('owner', item.ownerSteamId.toString());
    console.log('preview', item.previewUrl);
    console.log('subscriptions', item.statistics.numSubscriptions ?? 0n);
  } finally {
    steam.close();
  }
}

void main();

For a user's items from another app, pass appId to getUserItems. It is used as both the creator app id and the consumer app id of the query.

Load the mods a player subscribed to

listSubscribed is a local read, so it is cheap to call at every startup. getState says whether the content is on disk and whether Steam has a newer version, and getInstallInfo gives the absolute content path. An item that is subscribed but not installed yet is one Steam is still downloading; download forces it now and resolves once the files are there.

import { init } from 'steamwand.js';

async function main() {
  const steam = init({ appId: 480 });
  try {
    const mods: string[] = [];
    for (const fileId of steam.workshop.listSubscribed()) {
      const state = steam.workshop.getState(fileId);
      if (!state.installed || state.needsUpdate) {
        await steam.workshop.download(fileId, {
          onProgress: (p) => console.log(fileId, `${p.bytesDownloaded} / ${p.bytesTotal}`),
        });
      }
      const info = steam.workshop.getInstallInfo(fileId);
      if (info) mods.push(info.path);
    }
    console.log('mod folders', mods);
    await steam.workshop.startPlaytimeTracking(steam.workshop.listSubscribed());
  } finally {
    steam.close();
  }
}

void main();

onInstalled fires for downloads the Steam client runs on its own, which is how a running game notices that a subscribed mod just updated. See Workshop.

Build an in-game mod browser

browse is the cursor-paged query behind a mod browser. Rank by trend, votes, or date, filter by tags and text, and hand the nextCursor back for the next page. subscribe is one call; after it the item behaves like any other subscribed mod in the recipe above.

import { init, flat } from 'steamwand.js';

async function main() {
  const steam = init({ appId: 480 });
  try {
    const page = await steam.workshop.browse({
      queryType: flat.EUGCQuery.k_EUGCQuery_RankedByTrend,
      trendDays: 7,
      searchText: 'map',
      requiredTags: ['gameplay'],
    });
    for (const item of page.items) {
      console.log(item.title, item.statistics.numSubscriptions ?? 0n, item.previewUrl);
    }
    console.log(`${page.items.length} of ${page.totalResults}, next cursor ${page.nextCursor}`);
    if (page.items[0]) await steam.workshop.subscribe(page.items[0].fileId);
  } finally {
    steam.close();
  }
}

void main();

Unlock an achievement

SetAchievement writes to the local cache only. Nothing reaches Steam, and no toast appears, until StoreStats succeeds. Both return false when the achievement name is not defined for this app id. GetAchievement reports the current state through a one-byte out param, and ClearAchievement plus StoreStats puts it back while you test.

import { init } from 'steamwand.js';

const ACHIEVEMENT = 'ACH_WIN_ONE_GAME';

function main() {
  const steam = init({ appId: 480 });
  try {
    const achieved = Buffer.alloc(1);
    if (steam.userStats.GetAchievement(ACHIEVEMENT, achieved)) {
      console.log('already unlocked?', achieved[0] === 1);
    }
    if (!steam.userStats.SetAchievement(ACHIEVEMENT)) {
      return console.error('unknown achievement name for this app id');
    }
    if (!steam.userStats.StoreStats()) return console.error('StoreStats failed');
    console.log('unlocked', ACHIEVEMENT);
  } finally {
    steam.close();
  }
}

main();

Read DLC, the install directory, and the beta branch

steam.dlc answers the ownership and install questions per DLC and can start an install that resolves once the files are there. steam.system carries the facts about the running app: where it is installed, which build it is, which beta branch is active.

import { init } from 'steamwand.js';

async function main() {
  const steam = init({ appId: 480 });
  try {
    console.log('installed at', steam.system.installDir(), 'build', steam.system.buildId());
    console.log('beta', steam.system.currentBeta() ?? 'default', 'language', steam.system.gameLanguage());
    for (const dlc of steam.dlc.listDlc()) {
      const owned = steam.dlc.isOwned(dlc.appId);
      console.log(dlc.appId, dlc.name, owned ? 'owned' : 'not owned', steam.dlc.isInstalled(dlc.appId));
      if (owned && !steam.dlc.isInstalled(dlc.appId)) await steam.dlc.install(dlc.appId);
    }
  } finally {
    steam.close();
  }
}

void main();

listDlc reports the DLC of the app id the process was initialized with, so run this under the app id you care about.

Run the binding in a child process

An FFI mistake, a wrong buffer size or a bad handle, faults inside the native library and takes the whole process down. There is no exception to catch. If the host must survive (a VS Code extension, an editor plugin, a build server), run steamwand in a forked child and treat a non-zero exit as a failed job. This is how the CK3 modding toolkit uses it. The child does one job and exits:

// steam-worker.ts
import { init } from 'steamwand.js';

type Job = { fileId: string; title: string; contentPath: string };

process.on('message', async (job: Job) => {
  const steam = init({ appId: 1158310 });
  try {
    await steam.workshop.submitUpdate(BigInt(job.fileId), {
      title: job.title,
      contentPath: job.contentPath,
      changeNote: 'published from the toolkit',
    });
    process.send?.({ ok: true });
  } catch (err) {
    process.send?.({ ok: false, error: (err as Error).message });
  } finally {
    steam.close();
    process.exit(0);
  }
});

The parent never loads steamwand at all:

// host.ts
import { fork } from 'node:child_process';

function publish(job: { fileId: string; title: string; contentPath: string }) {
  return new Promise<void>((resolve, reject) => {
    const child = fork(require.resolve('./steam-worker'));
    let settled = false;
    child.on('message', (msg: { ok: boolean; error?: string }) => {
      settled = true;
      if (msg.ok) resolve();
      else reject(new Error(msg.error));
      child.kill();
    });
    child.on('exit', (code) => {
      if (!settled) reject(new Error(`steam worker died (exit code ${code})`));
    });
    child.send(job);
  });
}

publish({ fileId: '3786319531', title: 'Names', contentPath: 'C:/mods/name-lists' })
  .then(() => console.log('published'))
  .catch((err) => console.error(err.message));

Send bigint file ids across the process boundary as strings, because the IPC channel uses JSON, which cannot carry a bigint. Convert back with BigInt(...) in the child. Keep the child short-lived, one job per fork, so a fault can only lose that job.

Clone this wiki locally