Skip to content

Workshop

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

Workshop

steam.workshop is the curated layer over ISteamUGC. It covers both sides of the Steam Workshop. For a creator: create an item, upload content, write per-language title and description, query items back, delete an item. For a player, since v0.6.0: browse the workshop, subscribe, download, find the installed content on disk, vote, and favorite.

The Workshop instance is created lazily and cached on the Steam object. It is bound to the app id the Steam instance was initialized with (steam.appId), so every method takes that app id by default.

All 64-bit values (published file ids, Steam ids, byte counts) are bigint. Every method is async and resolves through Valve's manual dispatch pump, see How It Works.

createItem

createItem(appId?: number, fileType?: number):
  Promise<{ fileId: bigint; legalAgreementRequired: boolean }>

Wraps CreateItem and waits for CreateItemResult_t. The new item is empty: no title, no description, no content. Give it content with submitUpdate.

Parameter Type Default Meaning
appId number steam.appId Consumer app id the item belongs to.
fileType number EWorkshopFileType.k_EWorkshopFileTypeCommunity See Visibility and file type.

Returns fileId (the PublishedFileId_t) and legalAgreementRequired. When legalAgreementRequired is true, the user has not accepted the Steam Workshop legal agreement yet and the item stays hidden until they do. Throws SteamResultError with operation: 'CreateItem' if the result is not k_EResultOK.

const created = await steam.workshop.createItem();
console.log(created.fileId, created.legalAgreementRequired);

submitUpdate

submitUpdate(
  fileId: bigint,
  update: WorkshopItemUpdate,
  opts?: {
    appId?: number;
    onProgress?: (p: UpdateProgress) => void;
    progressIntervalMs?: number;
  },
): Promise<{ legalAgreementRequired: boolean }>

One call does the whole update handle dance: StartItemUpdate, then only the setters for the fields you passed, then SubmitItemUpdate, then it waits for SubmitItemUpdateResult_t. Fields you leave out are not touched on the item.

Before any native call it checks contentPath and previewPath with fs.existsSync. The native layer aborts the process on a missing path, so this check turns a hard crash into a normal thrown Error.

opts field Type Default Meaning
appId number steam.appId Consumer app id passed to StartItemUpdate.
onProgress (p: UpdateProgress) => void none Polls GetItemUpdateProgress on a timer while the upload runs.
progressIntervalMs number 500 Poll interval. The timer is unref'd and always cleared in a finally block.

Returns { legalAgreementRequired } from SubmitItemUpdateResult_t.

await steam.workshop.submitUpdate(
  fileId,
  {
    title: 'My mod',
    description: 'What the mod does.',
    contentPath: 'C:/mods/my-mod',
    previewPath: 'C:/mods/my-mod/preview.png',
    tags: ['gameplay'],
    changeNote: 'first upload',
  },
  { onProgress: (p) => console.log(p.status, p.bytesProcessed, p.bytesTotal) },
);

Throws:

  • Error: steamwand: content folder does not exist: <path>
  • Error: steamwand: preview image does not exist: <path>
  • Error: steamwand: <setter> returned false (invalid handle or argument?) when one of SetItemUpdateLanguage, SetItemTitle, SetItemDescription, SetItemContent, SetItemPreview, SetItemVisibility or SetItemTags returns false.
  • SteamResultError with operation: 'SubmitItemUpdate' on a non-OK result.

changeNote is optional. When you leave it out, null is passed to SubmitItemUpdate, which Steam reads as "no change note".

deleteItem

deleteItem(fileId: bigint): Promise<void>

Wraps DeleteItem and waits for DeleteItemResult_t. The deletion is permanent. Throws SteamResultError with operation: 'DeleteItem' on a non-OK result.

await steam.workshop.deleteItem(3786319531n);

getItem

getItem(fileId: bigint, opts?: QueryOptions): Promise<WorkshopItem | null>

Runs a CreateQueryUGCDetailsRequest for one file id and returns the first result, or null when the item does not exist. Results whose m_eResult is k_EResultFileNotFound are dropped, so a bad id gives null instead of a half-filled item. The query handle is released in a finally block, so an error does not leak it. The item does not have to belong to your app id, see the cross-app recipe in Recipes.

const item = await steam.workshop.getItem(3786319531n, {
  language: 'japanese',
  longDescription: true,
});
if (item) console.log(item.title, item.previewUrl, item.statistics.numSubscriptions);

Throws SteamResultError with operation: 'SendQueryUGCRequest' on a non-OK query result, and Error: steamwand: SetLanguage returned false (...) or ... SetReturnLongDescription returned false (...) if those setters fail.

getUserItems

getUserItems(
  page: number,
  accountId: number,
  opts?: QueryOptions & {
    appId?: number;
    listType?: number;
    matchingType?: number;
    sortOrder?: number;
  },
): Promise<UserItemsPage>

One page of a user's items through CreateQueryUserUGCRequest.

Parameter Type Default Meaning
page number none 1-based page number. A page holds up to flat.kNumUGCResultsPerPage (50) items.
accountId number none 32-bit account id. Use steam.accountId() for the local user.
appId number steam.appId Passed as both creator app id and consumer app id.
listType number EUserUGCList.k_EUserUGCList_Published Which list to read.
matchingType number EUGCMatchingUGCType.k_EUGCMatchingUGCType_Items Which content types to match.
sortOrder number EUserUGCListSortOrder.k_EUserUGCListSortOrder_LastUpdatedDesc Sort order.

QueryOptions (language, longDescription) apply here too. Returns { items, totalResults }, where totalResults is m_unTotalMatchingResults, the size of the whole list, not of this page.

const page = await steam.workshop.getUserItems(1, steam.accountId());
console.log(`${page.items.length} of ${page.totalResults}`);

browse

Since v0.6.0.

browse(opts?: BrowseOptions): Promise<BrowsePage>

Searches the whole workshop of an app through CreateQueryAllUGCRequestCursor: the query behind an in-game mod browser. queryType picks the ranking (EUGCQuery, default k_EUGCQuery_RankedByVote), requiredTags, excludedTags, matchAnyTag, and searchText filter, and trendDays sets the window for the trend rankings. Pages are cursor based: pass '*' (or nothing) for the first page and the returned nextCursor for the next one, until it is null. Cursors do not stop at the 1000-page limit that the page-number query has.

let cursor: string | null = '*';
while (cursor) {
  const page = await steam.workshop.browse({
    queryType: flat.EUGCQuery.k_EUGCQuery_RankedByTrend,
    trendDays: 7,
    requiredTags: ['gameplay'],
    cursor,
  });
  for (const item of page.items) console.log(item.title, item.statistics.numSubscriptions);
  cursor = page.nextCursor;
}

QueryOptions (language, longDescription, children, ...) apply here too. Throws SteamResultError with operation: 'SendQueryUGCRequest' on a non-OK result, and a plain Error if a filter setter returns false.

subscribe and unsubscribe

Since v0.6.0.

subscribe(fileId: bigint): Promise<void>
unsubscribe(fileId: bigint): Promise<void>

SubscribeItem and UnsubscribeItem, awaited through their result structs. After subscribe, Steam downloads the item on its own; await download when the game needs the content now, or listen with onInstalled. After unsubscribe, Steam removes the content once the app exits, and the local list behind listSubscribed catches up on the client's next sync, so it can still carry the item for a moment. Both throw SteamResultError on a non-OK result, for example k_EResultFileNotFound.

listSubscribed, getState, getInstallInfo, getDownloadInfo

Since v0.6.0. Four local reads, no round trip.

listSubscribed(includeLocallyDisabled?: boolean): bigint[]
getState(fileId: bigint): ItemState
getInstallInfo(fileId: bigint): InstallInfo | null
getDownloadInfo(fileId: bigint): DownloadProgress | null

listSubscribed is the list a game walks at startup to find its mods. Pass true to include items the user disabled in the Steam client. getState turns the EItemState bit field into named booleans. getInstallInfo is null while the item is not on disk; otherwise it carries the absolute content path, which is the one value most games need. getDownloadInfo is null unless a download is running.

for (const fileId of steam.workshop.listSubscribed()) {
  const state = steam.workshop.getState(fileId);
  const info = steam.workshop.getInstallInfo(fileId);
  console.log(fileId, state.needsUpdate ? 'update pending' : 'current', info?.path);
}

download

Since v0.6.0.

download(
  fileId: bigint,
  opts?: { highPriority?: boolean; onProgress?: (p: DownloadProgress) => void; progressIntervalMs?: number },
): Promise<void>

DownloadItem, then waits for the matching DownloadItemResult_t. Resolves once the content is on disk, so getInstallInfo answers right after. Use it for a subscribed item the game needs before it can continue, or for an item the user is not subscribed to, which Steam then keeps only until the app exits. highPriority (default true) puts it in front of the client's other downloads. onProgress polls getDownloadInfo on a timer, 500 ms by default, and never runs after the promise settles.

Throws a plain Error if Steam refuses to queue the download (unknown item, or no access to that workshop), and SteamResultError with operation: 'DownloadItem' if the transfer ends with a non-OK result.

onInstalled

Since v0.6.0.

onInstalled(listener: (event: { fileId: bigint; appId: number }) => void): () => void

ItemInstalled_t, for every install and update for this app, whoever started it. This is how a running game learns that a subscribed mod just updated. Returns the unsubscribe function.

vote and getVote

Since v0.6.0.

vote(fileId: bigint, up: boolean): Promise<void>
getVote(fileId: bigint): Promise<'up' | 'down' | 'skipped' | null>

SetUserItemVote and GetUserItemVote. getVote is null while the user has not voted, and 'skipped' when they chose not to. Both throw SteamResultError on a non-OK result; voting on your own item gives k_EResultAccessDenied.

addToFavorites and removeFromFavorites

Since v0.6.0.

addToFavorites(fileId: bigint, appId?: number): Promise<void>
removeFromFavorites(fileId: bigint, appId?: number): Promise<void>

AddItemToFavorites and RemoveItemFromFavorites, awaited through UserFavoriteItemsListChanged_t. appId defaults to steam.appId.

startPlaytimeTracking and stopPlaytimeTracking

Since v0.6.0.

startPlaytimeTracking(fileIds: bigint[]): Promise<void>
stopPlaytimeTracking(fileIds?: bigint[]): Promise<void>

Tell Steam which items a session uses, so their playtime statistics grow (the numSecondsPlayed and numPlaytimeSessions counters, and the playtime rankings in browse). Steam caps one call at 100 items. Leave the argument off stopPlaytimeTracking to stop every item at once.

showEula and getEulaStatus

Since v0.6.0.

showEula(): boolean
getEulaStatus(): Promise<WorkshopEulaStatus>

A user who has not accepted the Steam Workshop legal agreement cannot publish, which createItem reports through legalAgreementRequired. showEula opens the agreement in the overlay and returns false when the overlay is disabled. getEulaStatus reads the current version and whether the user accepted it, and throws SteamResultError with k_EResultInvalidParam for an app that has no workshop agreement configured (Spacewar is one).

Types

WorkshopItemUpdate

Every field is optional. Only the fields you set are written.

Field Type Setter behind it Notes
title string SetItemTitle Per language when language is set.
description string SetItemDescription Per language when language is set.
language string SetItemUpdateLanguage Steam API language code, for example german, schinese.
changeNote string SubmitItemUpdate argument Omit for no change note.
contentPath string SetItemContent Absolute path to the content folder. Checked with fs.existsSync first.
previewPath string SetItemPreview Absolute path to the preview image. Checked with fs.existsSync first.
tags string[] SetItemTags Passed through stringArray(), with bAllowAdminTags false. Replaces the whole tag list.
visibility number SetItemVisibility ERemoteStoragePublishedFileVisibility member.

UpdateProgress

Passed to onProgress.

Field Type Meaning
status number EItemUpdateStatus member, the return value of GetItemUpdateProgress.
bytesProcessed bigint punBytesProcessed out param.
bytesTotal bigint punBytesTotal out param.

EItemUpdateStatus members, in order: k_EItemUpdateStatusInvalid (0), ...PreparingConfig (1), ...PreparingContent (2), ...UploadingContent (3), ...UploadingPreviewFile (4), ...CommittingChanges (5). Status 0 means the handle is no longer in flight, which is what you see after the upload finishes.

WorkshopItem

Decoded from SteamUGCDetails_t, plus two things that are not in that struct: previewUrl (from GetQueryUGCPreviewURL, buffer of 256 bytes) and statistics (from GetQueryUGCStatistic).

Field Type Source field
fileId bigint m_nPublishedFileId
title string m_rgchTitle
description string m_rgchDescription (truncated unless longDescription)
fileType number m_eFileType, an EWorkshopFileType member
creatorAppId number m_nCreatorAppID
consumerAppId number m_nConsumerAppID
ownerSteamId bigint m_ulSteamIDOwner
timeCreated number m_rtimeCreated, Unix seconds
timeUpdated number m_rtimeUpdated, Unix seconds
visibility number m_eVisibility
banned boolean m_bBanned
acceptedForUse boolean m_bAcceptedForUse
tags string[] m_rgchTags split on ,, empty array when blank
tagsTruncated boolean m_bTagsTruncated
fileName string m_pchFileName
fileSize number m_nFileSize
previewFileSize number m_nPreviewFileSize
url string m_rgchURL
votesUp number m_unVotesUp
votesDown number m_unVotesDown
score number m_flScore
numChildren number m_unNumChildren
totalFilesSize bigint m_ulTotalFilesSize
previewUrl string | null GetQueryUGCPreviewURL, null when it returns false
statistics Partial<Record<WorkshopStatistic, bigint>> see below

WorkshopStatistic

statistics holds one bigint per statistic that GetQueryUGCStatistic answered for. A statistic Steam did not return is absent, so read it as item.statistics.numFavorites ?? 0n.

Key EItemStatistic member
numSubscriptions k_EItemStatistic_NumSubscriptions
numFavorites k_EItemStatistic_NumFavorites
numFollowers k_EItemStatistic_NumFollowers
numUniqueSubscriptions k_EItemStatistic_NumUniqueSubscriptions
numUniqueFavorites k_EItemStatistic_NumUniqueFavorites
numUniqueWebsiteViews k_EItemStatistic_NumUniqueWebsiteViews
numSecondsPlayed k_EItemStatistic_NumSecondsPlayed
numPlaytimeSessions k_EItemStatistic_NumPlaytimeSessions
numComments k_EItemStatistic_NumComments

Other EItemStatistic members exist in the generated enum and are not read by this layer. Call steam.ugc.GetQueryUGCStatistic yourself for those.

QueryOptions

Field Type Setter Meaning
language string SetLanguage Which language the returned title and description are in.
longDescription boolean SetReturnLongDescription Return the full description instead of the truncated one.

UserItemsPage

Field Type Meaning
items WorkshopItem[] Items on this page.
totalResults number m_unTotalMatchingResults for the whole query.

BrowseOptions

QueryOptions plus the fields below. Every field is optional.

Field Type Meaning
appId number Workshop to search. Default steam.appId.
queryType number EUGCQuery ranking. Default k_EUGCQuery_RankedByVote.
matchingType number EUGCMatchingUGCType. Default k_EUGCMatchingUGCType_Items.
searchText string Text the title or description must contain.
requiredTags string[] Tags every item must carry, or any of them with matchAnyTag.
excludedTags string[] Tags no item may carry.
matchAnyTag boolean Match any of requiredTags instead of all.
trendDays number Window for the trend rankings, 1 to 180.
cursor string '*' or omitted for the first page, then nextCursor.

BrowsePage

UserItemsPage plus nextCursor: string | null, null after the last page.

ItemState

One boolean per EItemState bit: subscribed, legacy, installed, needsUpdate, downloading, downloadPending, disabledLocally.

InstallInfo

Field Type Meaning
path string Absolute content folder (or file, for legacy items).
sizeOnDisk bigint Bytes on disk.
timestamp number Last update of the installed content, Unix seconds.

DownloadProgress

bytesDownloaded and bytesTotal, both bigint. bytesTotal is 0n before Steam knows the size.

WorkshopEulaStatus

version, accepted, needsAction, and actionTime (Unix seconds, or 0).

Per-language text

SetItemUpdateLanguage is the reason this library exists. Steam stores one title and one description per language, and the update handle writes to exactly one language at a time. The order matters: submit the default text first, then one submitUpdate per translation. A translation update carries language, title and description and nothing else, so leave contentPath out and the content is not uploaded again.

import { init } from 'steamwand.js';

async function main() {
  const steam = init({ appId: 480 });
  const { fileId } = await steam.workshop.createItem();

  // 1. Default text plus the content itself.
  await steam.workshop.submitUpdate(fileId, {
    title: 'Custom Name Lists',
    description: 'Adds name lists for every culture.',
    contentPath: 'C:/mods/name-lists',
    changeNote: 'first upload',
  });

  // 2. One update per language. Text only.
  await steam.workshop.submitUpdate(fileId, {
    language: 'german',
    title: 'Eigene Namenslisten',
    description: 'Fuegt Namenslisten fuer jede Kultur hinzu.',
  });

  // 3. Read it back. Without `language` you get the default text.
  const de = await steam.workshop.getItem(fileId, { language: 'german' });
  console.log(de?.title); // Eigene Namenslisten

  steam.close();
}

void main();

Language codes are Steam's own API language strings, for example english, german, french, russian, schinese, tchinese, brazilian. Use the API language code column of Valve's supported languages table, not the display name and not an ISO code.

language changes only where title and description go. tags, visibility, contentPath and previewPath are properties of the item, not of a language, so setting them inside a translation update writes them once for the whole item.

Visibility and file type

visibility takes an ERemoteStoragePublishedFileVisibility member.

Member Value
k_ERemoteStoragePublishedFileVisibilityPublic 0
k_ERemoteStoragePublishedFileVisibilityFriendsOnly 1
k_ERemoteStoragePublishedFileVisibilityPrivate 2
k_ERemoteStoragePublishedFileVisibilityUnlisted 3

fileType takes an EWorkshopFileType member. k_EWorkshopFileTypeCommunity (0) is the default and the only one most mod tools need. The enum also carries k_EWorkshopFileTypeMicrotransaction (1), k_EWorkshopFileTypeCollection (2), k_EWorkshopFileTypeArt (3), k_EWorkshopFileTypeVideo (4), k_EWorkshopFileTypeScreenshot (5) and the rest up to k_EWorkshopFileTypeMax (17).

Both enums live in the generated layer, reachable as flat, for example flat.EWorkshopFileType.k_EWorkshopFileTypeCommunity. Uploading a new item as private first is the safe pattern. It keeps a half finished item out of the public listing until you flip it to ...VisibilityPublic.

Errors

Two error shapes come out of this layer. SteamResultError (exported from the package root) carries operation and result, and its message is `${operation} failed: ${eResultName(result)}`. The operations it uses are CreateItem, SubmitItemUpdate, DeleteItem and SendQueryUGCRequest. Common results: k_EResultAccessDenied (15) and k_EResultInsufficientPrivilege (24) when the account may not publish for this app, k_EResultBanned (17), k_EResultLimitExceeded (25), k_EResultTimeout (16). eResultName is exported too, so you can name any EResult yourself.

A plain Error with a steamwand: prefix means a boolean flat function returned false or a path check failed, and names the setter or the path.

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

async function rename(steam: Steam, fileId: bigint, title: string) {
  try {
    await steam.workshop.submitUpdate(fileId, { title });
  } catch (err) {
    if (err instanceof SteamResultError) {
      console.error(err.operation, err.result, eResultName(err.result));
    } else {
      console.error((err as Error).message);
    }
  }
}

An in-flight call also rejects with SteamApiCallError if the dispatch pump reports an IO failure, or with Error: steamwand: dispatch stopped while call was in flight if you call steam.close() while an upload is running.

What this layer does not do

  • Key/value tag filters on queries (AddRequiredKeyValueTag), date range filters, the cloud file name filter, and admin queries.
  • Playtime statistics per user, content descriptors, SetItemsDisabledLocally, SetSubscriptionsLoadOrder, and the game server workshop calls (BInitWorkshopForGameServer).
  • The legacy ISteamRemoteStorage publishing calls, which Valve deprecated.

Those calls stay on steam.ugc, the raw generated ISteamUGC class. Flat API explains the calling convention.

Clone this wiki locally