# unreadable.gg stream

`GET wss://api.unreadable.gg/v1/stream`

Run lots of decompiles over one WebSocket. Each job is a [JSON request](https://unreadable.gg/docs/api#request) with an id, and you get back the same [response](https://unreadable.gg/docs/api#response) or [error](https://unreadable.gg/docs/api#errors) as over HTTP. It shares your [limits](https://unreadable.gg/docs/api#limits) and free decompiles with HTTP and the site.

## executor client

Run this in your executor to get a `decompile` that uses the stream:

```lua
shared.UNREADABLE_KEY = "ugk_..." -- optional: decompile as your account
loadstring(game:HttpGet("https://unreadable.gg/decompile.lua"))()
```

It replaces your executor's `decompile`. `decompile(script)` waits for the source and returns it as a string. You can pass bytecode as a string too.

Your executor needs `WebSocket.connect` and `getscriptbytecode`. Without `WebSocket.connect`, every call returns an `unsupported-executor` comment.

It never errors. If something fails, you get a comment instead of the source:

```lua
-- unreadable.gg could not decompile this script
-- out-of-credits: …
```

A script with no bytecode returns `-- unreadable.gg: this script has no bytecode`.

All calls share one connection, and the same bytecode is only decompiled once.

### options

Set `shared.UNREADABLE_OPTIONS` before the `loadstring` line to pick [options](https://unreadable.gg/docs/api#options). For example, `readable` without `modelNames`.

```lua
shared.UNREADABLE_OPTIONS = { preset = "readable", modelNames = false }
```

The default is `{ preset = "balanced" }`. It turns on `modelNames`: better names, but slower, and it uses more decompile time.

`{}` gives you the server's default, `faithful`.

### key

Put a key from the [account page](https://unreadable.gg/account) in `shared.UNREADABLE_KEY` to decompile as your account.

Without a membership, you get 100 free decompiles a day. After that, `decompile` returns an `out-of-credits` comment until midnight UTC.

## writing a client

[decompile.lua](https://unreadable.gg/decompile.lua) is a complete client. Use it as a reference.

## connecting

Open the WebSocket with no query string and no `Authorization` header. If the server refuses the upgrade, you get a normal [error](https://unreadable.gg/docs/api#errors) body.

Every message is one JSON object in a text frame, except binary jobs. Send `hello` within 10 seconds:

```json
{"type":"hello","protocol":1,"key":"ugk_..."}
```

The `key` is optional. Without it, you use your connection's free decompiles, like the site. A page on another site needs a key. A wrong key closes the stream with `invalid-key`.

The server answers `ready`:

```json
{"type":"ready","protocol":1,"version":"2026.10.21-926ecd4","auth":"anonymous","member":false,"memberUntil":null,"freeRemaining":97,"limits":{"inFlight":16,"perMinute":120,"maxFileBytes":2097152,"maxSeconds":30}}
```

- `auth`: who the stream runs as. `key`, `anonymous`, or `session` if you're signed in on the site.
- `limits.inFlight`: how many jobs can run at once, across all your streams and HTTP requests.
- `perMinute` and `freeRemaining`: `null` for members. A member's `ready` also has `tier`, like [`GET /v1/account`](https://unreadable.gg/docs/api#accounts).
- `maxFileBytes`: your file limit in bytes. 2 MiB free, 8 MiB for members.

You get a fresh `ready` when a membership starts or ends, when limits change, and at midnight UTC.

## jobs

```json
{"type":"job","id":"17","options":{"preset":"readable"},"bytecode":"DQMMBG1hdGgDYWJz…"}
```

`bytecode` is standard base64 with padding. You pick the `id`: 1 to 128 characters from `!` to `~`, without `"` or `\`. Keep ids unique for the whole run, across reconnects. A counter works.

A binary frame is a quarter smaller. It holds these fields in order:

| bytes | field |
| --- | --- |
| 3 | `UGJ` |
| 1 | format, `1` |
| 1 | the id's length, L |
| L | the id |
| 2 | the options' length H, big-endian, up to 4096, or 0 |
| H | the options, as UTF-8 JSON |
| the rest | the file |

Each job gets stage lines, then one final line: `done` or `error`. Jobs run side by side and finish in any order:

```json
{"id":"17","stage":"decompiling"}
{"id":"17","stage":"renaming","done":0,"total":210}
{"id":"17","stage":"done","source":"local Net = {}\n…","verdict":"decompiled-unverified","notes":[],"setting":{…},"version":"2026.10.21-926ecd4","freeRemaining":96}
{"id":"19","stage":"error","error":{"code":"rate-limited","message":"More than 120 decompiles this minute from this connection."},"retryAfter":12}
```

The final line has what HTTP sends as headers: `freeRemaining`, `memberUntil` and `retryAfter`.

A `done` over 1 MiB is split. `source` lines come first, in order, each with a piece of `text`. Then a `done` arrives whose `parts` says how many pieces to join.

Skip any stage you don't know. An unknown field in a job only fails that job, with `unknown-field`.

### flow control

The server never queues. Keep up to `limits.inFlight` jobs running, largest first. A job over a limit is refused right away with `retryAfter`. Wait that long, then resend it with a new id.

Don't hammer it. Jobs refused right away (before any stage line) with `rate-limited`, `too-many-at-once` or `unavailable` add up. More than 120 in a minute (1000 for members) closes the stream with `rate-limited` and a `retryAfter` of 60.

One stream slows down past a few hundred large jobs at once. For big runs, open more streams as the load grows, and keep `limits.inFlight` as the total across all of them. The site and the executor client open another stream every 256 jobs, up to 8 for a member, and close extras after 10 idle seconds. You can have 16 streams open. Past that, a new one is refused with `too-many-at-once`. Wait its `retryAfter`, then try again.

A job uses a free decompile once it starts, even if the stream closes first.

## errors

Send `{"type":"ping"}` any time and the server answers `pong`. While jobs run, the server pings you after 30 quiet seconds, and you answer `{"type":"pong"}`. After 60 quiet seconds, the stream closes with `timed-out`. An idle stream closes after 10 minutes.

Before closing, the server sends one `{"type":"error",…}` message, then closes with code 4000 plus the status, like 4429.

These close it with `protocol`:

- a text message that isn't one JSON object
- an unknown `type`
- an unknown field in anything but a job
- anything but a job over 4096 bytes
- a bad job `id`
- a job whose `id` is still running
- a job before `ready`
- a second `hello`
- no `hello` within 10 seconds

| close reason | what to do |
| --- | --- |
| `protocol`, `invalid-key`, `unsupported-protocol` | Fix your client. Don't reconnect. |
| `rate-limited`, `too-many-at-once`, `unavailable` | Reconnect after `retryAfter` seconds. |
| anything else, or no error | Reconnect with backoff. |

## reconnecting

The server forgets a closed stream. Resend every job without a final line, with a new id. Also resend jobs that were told to wait: `too-many-at-once`, `rate-limited` and `unavailable` after their `retryAfter`, and `out-of-credits` after midnight UTC.

Back off 1, 2, 4, 8, 16, then 30 seconds, with a bit of randomness, or wait `retryAfter`. Then send `hello` again and use the new `ready`'s limits. Old jobs may still be running, so expect some `too-many-at-once` at first.

A stream lasts through one midnight UTC. At the second, it refuses new jobs with `unavailable` and closes once its jobs finish. Reconnect and resend.

## protocol versions

Protocol 1 may get new fields, messages, stages, codes and options, so skip what you don't know. Anything a client has to send or understand only changes in a new protocol.
