TypeScript bindings for the Steamworks SDK with no native build step. The
binding layer is generated from steam_api.json, Valve's own machine-readable
description of the flat C API, and called through koffi
FFI. When a new SDK ships, you regenerate; when you need a function nobody
wrapped yet, it is already there.
Every existing Node binding makes you compile someone else's native code to
add a function. greenworks is NAN-era C++ built on the pre-2014 RemoteStorage
workshop API. steamworks.js is solid but every contribution means Rust,
napi-rs, and a three-platform build matrix; adding one setter
(SetItemUpdateLanguage) took two forked repos and a patch file. steamwand
has no native code of its own. One generic FFI dependency, one generated TS
layer, and the whole flat API surface: 25 interfaces, 807 functions,
191 callback structs with per-platform offset tables.
pnpm add steamwand.js
Node 18+. Windows x64, Linux x64, Linux ARM64, and macOS (x64 and Apple
silicon, one universal library) are wired up; the Steam client must be
running. The steam_api redistributables ship in the package. koffi's own
native binary comes as a separate @koromix/koffi-<os>-<arch> package next to
koffi in node_modules, so a bundler or Electron packager rule that only
keeps steamwand.js/** must also keep koffi/** and @koromix/**.
Full docs are in the wiki: getting started, the core API, the curated workshop, stats, cloud, leaderboards, and lobbies, social, overlay, auth, system, capture, controllers, and DLC layers, the raw flat API, recipes, and troubleshooting. Every generated function also carries its original C signature, its out-buffer sizes, and a link to Valve's documentation, so hovering it in an editor is usually enough.
import { init } from 'steamwand.js';
const steam = init({ appId: 480 });
// Curated workshop layer
const { fileId } = await steam.workshop.createItem();
await steam.workshop.submitUpdate(fileId, {
title: 'My mod',
description: 'Full description',
contentPath: 'C:/mods/my-mod',
tags: ['gameplay'],
changeNote: 'English:\nFirst upload.\n\nDeutsch:\nErster Upload.',
}, { onProgress: (p) => console.log(p.status, p.bytesProcessed) });
// Per-language text, the thing that started this project
await steam.workshop.submitUpdate(fileId, {
language: 'german',
title: 'Mein Mod',
description: 'Deutsche Beschreibung',
});
const item = await steam.workshop.getItem(fileId, { language: 'german' });
// Workshop item wiring: required DLC, collection children, extra previews,
// metadata and key/value tags, plus the running app's DLC list
await steam.workshop.addAppDependency(fileId, 1_234_567);
await steam.workshop.addDependency(9_876_543_210n, fileId); // collection, child
await steam.workshop.submitUpdate(fileId, {
metadata: JSON.stringify({ buildId: 42 }),
keyValueTags: { engineVersion: '1.14' },
previewImages: ['C:/mods/my-mod/screenshot.png'],
previewVideos: ['dQw4w9WgXcQ'],
});
const full = await steam.workshop.getItem(fileId, { children: true, additionalPreviews: true });
const dlc = steam.dlc.listDlc();
// Achievements, stats, cloud saves, leaderboards, lobbies: same treatment
steam.stats.unlock('ACH_WIN_ONE_GAME');
await steam.cloud.writeFile('save01.json', JSON.stringify({ level: 3 }));
const board = await steam.leaderboards.findOrCreate('Fastest Lap', 1, 3);
await steam.leaderboards.uploadScore(board.handle, 91_240);
const lobbyId = await steam.lobbies.create(2, 4);
steam.lobbies.setData(lobbyId, 'map', 'de_dust2');
// Friends, rich presence, overlay, auth tickets, system facts, Steam Input
const friends = steam.social.listFriends();
steam.social.setRichPresence('status', 'In the menu');
steam.overlay.activateInviteDialog(lobbyId);
const { hex } = await steam.auth.getWebApiTicket('my-backend');
if (steam.system.isSteamDeck()) steam.controllers.init();
// The raw generated layer, when the curated ones stop
const ticket = steam.apps.GetAppOwner();
steam.on('ItemInstalled_t', (data) => console.log('installed', data));
const found = await steam.async.userStats.FindLeaderboard('Fastest Lap');
steam.close();Twelve curated layers (workshop, stats, cloud, leaderboards,
lobbies, social, overlay, auth, system, capture, controllers,
dlc) cover the flows most games need, with typed errors that carry the
EResult. For everything else, steam.async wraps each of the 76 call-result
functions as a promise, steam.on and steam.once give typed callbacks by
struct name, and the out helpers make the flat API's out-buffers safe. 64-bit values (Steam ids, file
ids, handles) are bigint everywhere. The dispatch pump checks every async
result against the callback id the caller expected, so a mixed-up completion
rejects instead of decoding garbage.
Workshop queries skip rows that Steam marks as file-not-found. Other row errors reject with a SteamResultError that retains the result code and row context. Failed row getters also reject. With additionalPreviews: true, every preview must be read successfully or the query rejects. Each returned preview has its native index for use in removePreviewIndexes. Without that option, additionalPreviews stays empty and does not describe the gallery.
Use getAppDependenciesResult when you need a complete set of required apps. Steam returns at most 32 IDs and provides no pagination for this call. The older getAppDependencies still returns an array for compatibility, but is deprecated because it discards the total count.
const requirements = await steam.workshop.getAppDependenciesResult(fileId);
if (!requirements.complete) {
throw new Error(`Only ${requirements.appIds.length} of ${requirements.totalCount} required apps returned`);
}
// A complete snapshot is now available in requirements.appIds.Upload paths must be absolute. Content must be a readable directory; main and additional previews must be readable regular files. Symlinks are followed and broken links fail. Validation errors identify the field and path, with the original filesystem error in cause where applicable. These checks do not validate image formats or sizes, inspect every content file, or prevent files from changing before Steam reads them.
language selects translated titles and descriptions. It does not select a change-note translation. The documented native update API, PublishedFile Web API, and RemoteStorage Web API provide no supported method to attach translated notes to the same history entry. Steamwand therefore does not expose that capability.
Send one changeNote with language-labeled sections together with the content upload, as in the example above. Do not submit once per language to translate that entry. Issue #15 reports that note-only and later translation submissions returned success without a visible note in its tested flow. A successful submission is not proof of visible change history. If the outcome is uncertain, inspect the item's Change Notes in Steam before retrying.
scripts/generate.ts reads steam_api.json and emits src/generated/:
enums, consts, one class per interface with its versioned accessor
(SteamAPI_SteamUGC_v021, so an SDK bump is a regeneration, not an
archaeology project), and struct layouts as explicit per-platform offset
tables. Windows packs callback structs at 8 bytes, Linux and macOS at 4,
CSteamID is pack(1); the generator encodes those rules and its output has
been verified against steamworks-sys's bindgen layouts, 428 comparisons with
zero differences. test/offsets.test.ts pins the workshop set so a future
SDK bump cannot silently shift an offset.
Handwritten and small: the library loader, the dispatch pump, the struct
decoder, and the twelve curated layers under src/api/. After close(),
every call through the session throws instead of reaching the unloaded API.
The SDK itself is not in this repo and must not be committed; Valve's license
does not allow redistributing the headers or steam_api.json. To regenerate,
download the SDK from
partner.steamgames.com,
unpack it to sdk/ (gitignored apart from the STEAMWAND.md file), and run
pnpm generate. sdk/STEAMWAND.md has the exact paths, the
hash checks, and what to update on a version bump. sdk.lock.json records
which SDK version and steam_api.json hash the committed output came from.
Shipping the redistributable binaries (steam_api64.dll and friends) is
normal practice and allowed.
- 12 of the 819 flat functions are skipped: 9 that take C function pointers
(debug hooks, netsockets status callbacks) and 3 whose by-value struct the
generator cannot prove safe (
SteamIPAddress_tis a C union,SteamPartyBeaconLocation_tpacks differently per platform). The Steam Input action-data calls, skipped before 0.3, are bound. The generator lists every remaining skip when it runs. - Structs containing C unions (
SteamNetworkingIdentityand relatives) get no layout table, becausesteam_api.jsoncannot express unions and a guessed layout would read garbage. They are excluded loudly, not wrongly. - An FFI mistake crashes the process instead of throwing. If you embed this in something that must survive (a VS Code extension, an editor), run it in a child process. That is how the CK3 modding toolkit uses it.
- Game server APIs are not wired up. There is no curated networking layer
either:
ISteamNetworkingSocketsandISteamNetworkingMessagestakeSteamNetworkingIdentity, one of the union structs above, so only the generated methods that avoid it are usable today. - The Steam overlay draws into the game's own renderer. Node has none, so an Electron app gets no overlay from this package; that needs a native hook.
Run pnpm typecheck, pnpm test, and pnpm build for the offline checks. The test command excludes live suites even when live-test environment variables are set. Typecheck also enforces the compile-time API contracts.
pnpm test:live runs live reads against a logged-in Steam client on app ID 480. pnpm test:live:write adds curated write tests, including a unique cloud file, private lobby, presence key, auth tickets, and local controller and screenshot-hook changes. It excludes Workshop uploads. Write tests require STEAM_TEST_STEAM_ID to match the logged-in account's SteamID64.
pnpm test:workshop separately tests a private item's creation, uploads, translations, metadata, previews, downloaded bytes, and deletion. It also requires an explicit STEAM_TEST_APP_ID. Set 480 explicitly for the Spacewar development example, or use an authorized app you control. Agreement acceptance remains a manual step. Live tests use real Valve services and attempt cleanup; they are not part of ordinary CI.
An unfinished Workshop run leaves a gitignored recovery record in .steamwand-live/workshop/ and blocks the next run. A timeout does not cancel a Steam upload. See the testing workflow for account and app setup, coverage limits, Valve's source documentation, and recovery steps.
MIT for the code here. The Steamworks redistributables in runtime/ are
Valve's, under the Steamworks SDK Access Agreement.
