SDK
Build a board, flash real firmware, and run it on the Simulator86 cloud from TypeScript.
Provision simulated hardware the way you provision cloud infrastructure — from TypeScript. You build the board, flash the exact firmware you’d put on real hardware, run it on the Simulator86 cloud, and stream what it does. No lab, no board, same binary.
Install
Section titled “Install”npm i @sim86/sdk # zero runtime dependenciesNode ≥ 22, browsers, Bun, and Deno work out of the box. Get an API key in the
editor under Cloud → API keys (sim86_live_…) and set it as SIM86_API_KEY.
A first run
Section titled “A first run”import { createClient, Components } from "@sim86/sdk";
const client = createClient(process.env.SIM86_API_KEY!);const project = await client.getProject("your-project-id");const graph = project.graph;
const mcu = graph.addComponent(Components.STM32F405_EXPRESS, { id: "mcu1",});const led = graph.addComponent(Components.LED, { id: "led1" });graph.connect(mcu.pin("13"), led.pin("+"));graph.connect(mcu.pin("GND"), led.pin("-"));
await mcu.setFlash("./firmware.elf"); // path or Uint8Array
const run = await graph.run({ duration: 30_000 });run.stream(led.id).subscribe(({ t, value }) => { console.log(`${t}ms`, value); // { intensity: 100 } … blinking});
const { status, reason } = await run.done; // ends with a truthful reasonFor this STM32 board, flash the ELF your linker produced — not an objcopy’d
.bin/.hex. Firmware formats are board-specific; the XIAO ESP32-C3 instead
takes the merged flash .bin that esptool would write.
Firmware that already lives in the project
Section titled “Firmware that already lives in the project”setFlash() uploads the bytes with every run. When the same image is started
over and over — a soak test, a CI matrix, an agent iterating — put it in the
project’s workspace once and reference it by path instead:
await project.putFile("build/merged.bin", "./merged-flash.bin");c3.setFlashPath("build/merged.bin");putFile() replaces whatever is at that path, and the file is part of the
project, so it also shows up in the editor’s file tree. A firmware you build in
the workspace (from the editor, or with the project’s build command) needs no
upload at all — just the path.
Runs are detached
Section titled “Runs are detached”A run lives on the cloud, not in your process. Close your laptop or kill the CI job — it keeps going until it hits your limits, the firmware exits, or you stop it. Unbounded means soak tests measured in days, on purpose.
const run = await graph.run(); // no duration → runs until stoppedconst runId = run.id;run.detach(); // your process can exit// later, anywhere:const again = await client.attach(runId);stream() gives you a component’s current state — an LED’s brightness, a
sensor reading. Logs are the other half: an append-only record of what the
firmware printed and when.
const lines = await run.logs();// [{ t: 120, source: "mcu1:rtt", line: "boot ok" }, …]t is simulated milliseconds, not wall clock, so timings are reproducible
across machines. source is component:channel — one component can have
several channels (a UART, an RTT link, a state change like the onboard LED):
await run.logs({ from: "mcu1" }); // every channel on that boardawait run.logs({ from: "mcu1:uart0" }); // just that UARTawait run.sources(); // e.g. ["mcu1:uart0", …]Follow them as they happen. While you’re attached the lines are pushed to you; detached, it polls — either way the loop ends when the run does:
for await (const l of run.logs({ follow: true, from: "mcu1:uart0" })) { console.log(l.line);}Logs outlive the run, so a CI job can start a simulation, exit, and something else can read what happened afterwards.
Recording more
Section titled “Recording more”Firmware output and state changes are always recorded. Deeper instrumentation is opt-in, because recording it unconditionally would slow the simulation for data nobody reads — a busy board changes pins millions of times a second.
mcu.record("pins"); // before the run
const run = await graph.run({ duration: 5_000 });await run.logs({ from: `${mcu.id}:@pins` });// [{ t: 12, source: "mcu1:@pins", line: "13 = 1" },// { t: 12, source: "mcu1:@pins", line: "SDA = z" }, …]@pins records every level each pin is driven to, and distinguishes a pin
released to high-impedance (z) from one actively driven low (0) — the
difference that usually explains bus contention.
Channels you asked for are prefixed @ so they never collide with a board’s
own output, and turning one on changes only what is recorded, never what is
simulated: the same seed still replays identically.
Inject Wi-Fi frames
Section titled “Inject Wi-Fi frames”For robustness tests, inject arbitrary 802.11 MPDU bytes into the same RF path as a real radio. This can represent a rogue AP, deauthentication traffic, an imported capture, or a deliberately malformed frame:
const room = graph.addComponent(Components.RF_ENVIRONMENT);const c3 = graph.addComponent(Components.XIAO_ESP32C3);room.joinRfEnvironment(c3);await c3.setFlash("./merged-flash.bin");
const run = await graph.run({ seed: 42, duration: 10_000 });await run.at(2_000).injectWifiFrame(room, { sourceId: "rogue-ap", sequence: 1, channel: 6, txPowerDbm: -20, payloadHex: "c000", // exact bytes; malformed frames are allowed});sourceId plus sequence is the stable event identity used by deterministic
RF decisions. The rate defaults to robust 1 Mbit/s DSSS; set rate or
durationNs when the test needs exact airtime.
Browse an ESP32 setup page
Section titled “Browse an ESP32 setup page”A Wi-Fi Network is a pinless infrastructure AP, DHCP/DNS service, and browser point of view in one component. Put it in the same RF room as a XIAO ESP32-C3:
const room = graph.addComponent(Components.RF_ENVIRONMENT);const c3 = graph.addComponent(Components.XIAO_ESP32C3);const network = graph.addComponent(Components.WIFI_NETWORK);room.joinRfEnvironment(c3, network);
await c3.setFlash("./softap-web-server.bin");const run = await graph.run({ duration: 60_000 });
const page = await network.fetch({ run, ssid: "ESP32-Setup", url: "http://192.168.4.1/status",});console.log(page.status, page.text());
const browser = await network.openBrowser({ run, ssid: "ESP32-Setup", url: "http://192.168.4.1/",});console.log(browser.url); // one-use URL for a real browser tabThis is not a direct server request to 192.168.4.1. The browser client inside
the network scans, associates over the simulated air, gets a DHCP lease, then
uses the simulated DNS/TCP stack. RF loss, interference and collisions still
apply. The firmware SoftAP is the access point.
Firmware station mode can connect directly to the SSID configured on the same
Wi-Fi Network. Hardware MAC identities are generated, so the basic config is
normally just { ssid: "myhome", internetAccess: true }.
The initial browser release supports open networks and plain HTTP. WPA2/CCMP and device-side HTTPS fail explicitly until those layers are modeled. See the Wi-Fi Network component for both traffic flows and the complete configuration.
Which components exist
Section titled “Which components exist”Every MCU, sensor, display, and peripheral you can place is in the Components overview, including its wiring, configuration, and limitations. If you can drag it onto a diagram, you can add it from the SDK with the same name.
Have a project open in the editor? Grab a key from Cloud → API keys, paste its ID above, and run the snippet. The first simulation tells you more than any more docs would.