Documentation
API Reference
haas-lab exposes a REST API for programmatic firmware testing. Every request carries a Personal Access Token.
Your token
Sign in and you come back here to create a token in one click; every example below then uses it.
curl
Authentication
Every request carries a Personal Access Token (PAT) in the Authorization header. Create one at the top of this page and every example here uses it; the curl examples read it from PAT.
# Once per terminal:
export PAT=pat_...
# Every request then sends the header
# Authorization: Bearer $PATRun firmware on a board
Upload a .bin (max 4 MB) and it is queued for the next free board matching the profile. The response carries the job id, and while the job is QUEUED it also carries queuePosition, so you know how many are ahead of you without polling for it.
curl -X POST \
-H "Authorization: Bearer $PAT" \
-F firmware=@blink.bin \
-F profile_key=esp32c3-mini-v1 \
https://haas-lab.com/api/v1/jobsList your jobs
Returns your jobs newest first, 20 to a page. The response's meta carries page, pageSize, total and totalPages; there is no parameter that changes the page size.
curl \
-H "Authorization: Bearer $PAT" \
"https://haas-lab.com/api/v1/jobs?page=1"Get job status
Poll for job status. Status progresses: QUEUED → FLASHING → RUNNING → COMPLETED.
curl \
-H "Authorization: Bearer $PAT" \
https://haas-lab.com/api/v1/jobs/<job-id>Stream live serial output
Connect to the SSE stream for a running job. Each event contains one line of serial output.
curl -N \
-H "Authorization: Bearer $PAT" \
-H "Accept: text/event-stream" \
https://haas-lab.com/api/v1/jobs/<job-id>/streamSend input to a running job
Write to the board's serial console while the job is FLASHING or RUNNING; input to a queued job is refused, not held. Send text, which goes as its UTF-8 bytes, or bytes, base64 of exactly what to send. The bytes reach the board's port unchanged, but the console on the board may alter them before the program reads them: ESP-IDF's stdin turns a carriage return (0x0D) into a newline, and MicroPython takes 0x03 as Ctrl-C. So encode binary (base64 or hex) and decode it on the board. A program submitted as C source reads it from stdin, which waits for input as in standard C; set O_NONBLOCK to poll. A prebuilt image must install ESP-IDF's USB-serial-JTAG driver itself to read it. Input sent while the board is still being flashed, before the bench opens its port, is dropped, so send once the program is running. The board holds 4,096 bytes the program has not read yet and drops the rest, so send larger data in pieces and have the program acknowledge each. At most 4,096 bytes a send; the reply says how many were accepted. The token needs the submit scope.
curl -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"text":"print(1)\r\n"}' \
https://haas-lab.com/api/jobs/$JOB_ID/input
# Raw bytes, base64 encoded: a file of at most 4,096 bytes
# (split -b 4096 a larger one and send the pieces in order)
curl -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d "{\"bytes\":\"$(base64 -w0 piece.bin)\"}" \
https://haas-lab.com/api/jobs/$JOB_ID/inputGet archived serial log
After a job completes, retrieve the full serial log as plain text.
curl \
-H "Authorization: Bearer $PAT" \
https://haas-lab.com/api/v1/jobs/<job-id>/serialRoadmap: validate a restricted module
Not a way to run anything yet. This checks a .wasm module (max 128 KiB) against the sandbox policy and returns the verdict; queueing a module on hardware opens once the trusted runtime is provisioned. Native jobs above, a .bin or C source through submit_source, are the way to reach a board today.
curl -X POST \
-H "Authorization: Bearer $PAT" \
-F module=@app.wasm \
https://haas-lab.com/api/v1/sandbox/validateManage API tokens
Create, list, and revoke Personal Access Tokens.
# Create a new token
curl -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name":"ci-token"}' \
https://haas-lab.com/api/v1/tokens
# List tokens (plaintext never returned after creation)
curl -H "Authorization: Bearer $PAT" https://haas-lab.com/api/v1/tokens
# Revoke a token
curl -X DELETE \
-H "Authorization: Bearer $PAT" \
https://haas-lab.com/api/v1/tokens/<token-id>Python SDK
Installation
Install the haaslab SDK from PyPI. Requires Python 3.9+.
pip install haaslabConfigure the client
Create a client with your Personal Access Token. You can also set HAASLAB_TOKEN in your environment.
from haaslab import Client
# Explicit token
client = Client(token="pat_...")
# Or from environment variable HAASLAB_TOKEN
client = Client()Flash and wait
Submit a firmware file and wait for the job to complete. Returns the full serial log.
from haaslab import Client
client = Client(token="pat_...")
job = client.jobs.create("blink.bin")
print(f"Job created: {job.id}")
# Block until complete (default timeout: 120s)
job = client.jobs.wait(job.id, timeout=120)
log = client.jobs.serial(job.id)
print(log)Stream serial output live
Stream serial output line by line as it arrives from the device.
from haaslab import Client
client = Client(token="pat_...")
job = client.jobs.create("blink.bin")
for line in client.jobs.stream_serial(job.id):
print(line)Use in GitHub Actions
Add firmware testing to your CI pipeline with a single step.
# .github/workflows/firmware-test.yml
- name: Flash and verify firmware
env:
HAASLAB_TOKEN: ${{ secrets.HAASLAB_TOKEN }}
run: |
pip install haaslab
python - <<'EOF'
from haaslab import Client
client = Client()
job = client.jobs.create("build/firmware.bin")
job = client.jobs.wait(job.id, timeout=120)
log = client.jobs.serial(job.id)
print(log)
if "ASSERT" in log or job.status == "FAILED":
raise SystemExit("Firmware test failed")
EOFMCP
Install
haas-mcp is a Model Context Protocol server that gives an AI client tools to list boards, submit firmware, watch serial output and check the balance. One line: it opens your browser at the token page (sign in if asked), you click Connect, and the token arrives at the command by itself — nothing to paste. It proves the token and connects every AI client it finds on your machine. It is published on the npm registry as haas-mcp, so npx -y fetches it on first use. Tools can read a file on your machine by path from the folders you name with --file-root; without one, only from the folder your client was launched in, and from none when that is your home folder. config changes the folders later with no new token; then reconnect haas-lab in your client.
npx -y haas-mcp setup
# the same, and tools may read files under <folder> (once for each folder)
npx -y haas-mcp setup --file-root <folder>
# change the folders later; the list replaces the one before
npx -y haas-mcp config --file-root <folder>Give your agent the haas-lab skill
One file an AI agent reads when a task calls for it: this page, as instructions, so it knows the tools, the limits and how to get a token without being told. It is generated from these docs, so fetching it again brings it up to date. Claude Code reads it from ~/.claude/skills/haas-lab/SKILL.md in every project, or from .claude/skills/haas-lab/SKILL.md in one.
# macOS and Linux
mkdir -p ~/.claude/skills/haas-lab && curl -fsSL https://haas-lab.com/docs/skill.md -o ~/.claude/skills/haas-lab/SKILL.md
# Windows PowerShell
New-Item -ItemType Directory -Force "$HOME\.claude\skills\haas-lab" | Out-Null; curl.exe -fsSL https://haas-lab.com/docs/skill.md -o "$HOME\.claude\skills\haas-lab\SKILL.md"Or by hand, into Claude Code or Codex
One line per client; the only value you supply is the token.
# Claude Code
claude mcp add haas-lab -e HAAS_PAT=pat_... -- npx -y haas-mcp
# OpenAI Codex CLI
codex mcp add haas-lab --env HAAS_PAT=pat_... -- npx -y haas-mcpInstall into Claude Desktop or Cursor
Clients that take a JSON server list get the same command in that form. Claude Desktop reads claude_desktop_config.json; Cursor reads .cursor/mcp.json.
{
"mcpServers": {
"haas-lab": {
"command": "npx",
"args": ["-y", "haas-mcp"],
"env": { "HAAS_PAT": "pat_..." }
}
}
}Prove the token before restarting the client
One read of your balance, then exit 0 or 1. A wrong token, a wrong base URL and a client that has not been restarted otherwise look the same.
HAAS_PAT=pat_... npx -y haas-mcp --check
# haas-mcp: connected to https://haas-lab.com — 1,200 credits availableWhat the tools do
Ask the client in plain words; it picks the tool. get_job_status carries queuePosition while a job waits for a board, so there is no need to poll for a place in line.
list_devices boards, whether each takes a job now, and their memory
submit_source compile C on the bench into a FreeRTOS app, then flash — the default
submit_job safety-check and flash one prebuilt firmware image
submit_batch fan out up to 32 variants in one call
get_job_status status, cost, escrow, queuePosition
tail_serial serial output as it arrives
send_serial text or bytes to a running job's console
get_balance balance in micro-credits (thousandths of a credit)
estimate_cost price a job before submitting it
cancel_job cancel and refundUse a board as a local serial port
While a job of yours is running, its live output page has a Use as a local port button. It gives you one command that opens the job's board as a serial port on 127.0.0.1:4000, so idf.py monitor, PlatformIO, PuTTY or pyserial can read it and type to it. The key in the command works for that job's serial only and stops when the job ends, after 4 hours, or when the token that made it is revoked. Without the page, POST /api/jobs/{id}/port-key with your token (read and submit scopes, the job's owner only) returns the same command. It needs Node 22 or later. On macOS and Linux the command also prints a device file (a pseudo-terminal, also linked at ~/.haas/tty-4000) that screen, minicom, pyserial or a platformio.ini monitor_port can open; it needs python3; if that is missing, or the helper cannot start, the command says why and the addresses still work. Only about 4 KiB of replay survives before a tool opens the device, and unread output is dropped, as on a real port. On Windows, tools that only list COM ports, such as the Arduino IDE, need a virtual null-modem pair made once as administrator (com0com 2.2.2.0: setupc.exe install PortName=COM30 PortName=COM31); add --com COM31 to the command and open COM30 in the tool. Output arrives in bursts of up to 2 seconds, flashing is not possible through it, DTR and RTS do nothing, and anything you type goes to the board as console input.
npx -y haas-mcp port <job> --key hk_...
# then connect your tool to it
rfc2217://localhost:4000 idf.py monitor -p, pio device monitor --port, pyserial
telnet localhost 4000 PuTTY, connection type Telnet
socket://localhost:4000 raw TCP
device file (macOS, Linux) printed by the command: screen, minicom, pyserial, monitor_port
COM30 (Windows, --com COM31) Arduino IDE, Termite: needs a null-modem pair such as com0comWhat submit_source builds, and what it cannot do
submit_source compiles one C file on the bench into a fixed FreeRTOS template and flashes it: you write app_main, and ESP-IDF 5.4.3 builds it for the ESP32-C3 and ESP32-S3, 5.5.2 for the ESP32-C5. C++ and more than one source file are not accepted, and the build links five components and nothing else, so no other ESP-IDF or managed component (such as esp-dl) is available, and a C source job has no Wi-Fi, Bluetooth, sockets, HTTP or TLS: it cannot reach any network. A prebuilt image (submit_job) or a MicroPython script can use the board's radio; the terms say what that allows. The source is compiled on the bench, so a compile error comes back as the job's failure with the compiler's last lines.
accepts one C file with app_main, up to 256,000 bytes
links driver, esp_timer, esp_hw_support, freertos, console
network none: no Wi-Fi, Bluetooth, sockets, HTTP or TLS in the build
data files up to 4, 1,500,000 bytes together, embedded in the image;
attach with data_files: [{ path }], a file on your machine
that haas-mcp reads itself, or [{ name, content_b64 }];
read one through _binary_<name>_start and _binary_<name>_end
input send_serial: a piece of a file by path, text, or base64 bytes
to the running job's console, 4,096 bytes a send; the
program reads its console (stdin), which turns 0x0D into a
newline, so encode binary
local files a path is read only inside the folder haas-mcp started in
(wherever the client was launched), or the folders set with
setup or config --file-root, or HAAS_FILE_ROOTS,
never through a link out of them, never a credential's name
(.ssh, .env, *.pem and the like), and never over the limit
chips ESP32-C3, ESP32-S3 and ESP32-C5; list_devices gives each
board's profile_key, whether it takes a job now, and why not
memory esp32c3-mini-v1 400 KB SRAM, no PSRAM; esp32s3-zero-v1
512 KB SRAM and 2 MB PSRAM; esp32c5-n4-v1 384 KB SRAM, its
PSRAM not stated; a program's free heap is less than the SRAM
flash 4 MB; the app partition is 2,031,616 bytes on
esp32c3-mini-v1, esp32s3-zero-v1 and esp32c5-n4-v1
board time at most 4 hours a job; "run until stopped" books 30 minutes
run seconds expected_runtime_s is how long your program runs once it
starts, even when the compile and flash outlast the booked
window; that window adds the flash allowance and opens at
the claim, so the compile counts as board time, and your
balance is debited no more than the estimate for it; a job
the bench does not get running within 690 s past its window
(600 s to compile, then the 90 s grace) ends with
endedReason COMPILE_FLASH_OVERRUN, not charged;
the 4-hour limit still counts from the claim
price per second of board time, set by the board;
estimate_cost prices a job before you submit it
refused a source over the size limit, a board time over 4 hours,
and a compile error (the job fails, with the compiler's
last lines)Web / REST
Base URL
All API v1 endpoints are under /api/v1. Authentication uses Bearer tokens.
https://haas-lab.com/api/v1Create a job from the browser
Use the Fetch API to submit a firmware file from a web application.
const formData = new FormData();
formData.append("firmware", file); // File object from <input type="file">
const res = await fetch("/api/v1/jobs", {
method: "POST",
headers: { Authorization: `Bearer ${pat}` },
body: formData,
});
const job = await res.json();
console.log("Job ID:", job.id);Stream serial output via SSE
Connect to the job stream using the EventSource API.
const es = new EventSource(
`/api/v1/jobs/${jobId}/stream`,
// EventSource doesn't support custom headers natively —
// pass the token as a query param or use fetch with ReadableStream
);
es.onmessage = (e) => {
console.log("Serial:", e.data);
};
es.addEventListener("done", () => {
es.close();
console.log("Job complete");
});OpenAPI specification
The full OpenAPI 3.1 spec is available at /api/v1/openapi.json for import into Insomnia, Postman, or any compatible client.
curl https://haas-lab.com/api/v1/openapi.json | jq .