-
Notifications
You must be signed in to change notification settings - Fork 0
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(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(
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 ofSetItemUpdateLanguage,SetItemTitle,SetItemDescription,SetItemContent,SetItemPreview,SetItemVisibilityorSetItemTagsreturnsfalse. -
SteamResultErrorwithoperation: '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(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(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(
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}`);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.
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.
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 | nulllistSubscribed 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);
}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.
Since v0.6.0.
onInstalled(listener: (event: { fileId: bigint; appId: number }) => void): () => voidItemInstalled_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.
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.
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.
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.
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).
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. |
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.
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 |
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.
| 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. |
| Field | Type | Meaning |
|---|---|---|
items |
WorkshopItem[] |
Items on this page. |
totalResults |
number |
m_unTotalMatchingResults for the whole query. |
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. |
UserItemsPage plus nextCursor: string | null, null after the last page.
One boolean per EItemState bit: subscribed, legacy, installed,
needsUpdate, downloading, downloadPending, disabledLocally.
| 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. |
bytesDownloaded and bytesTotal, both bigint. bytesTotal is 0n before
Steam knows the size.
version, accepted, needsAction, and actionTime (Unix seconds, or 0).
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 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.
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.
- 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
ISteamRemoteStoragepublishing calls, which Valve deprecated.
Those calls stay on steam.ugc, the raw generated ISteamUGC class. Flat
API explains the calling convention.