**English** · [简体中文](zh/02-protocol.md) · [繁體中文](zh-TW/02-protocol.md) · [日本語](ja/02-protocol.md) · [한국어](ko/02-protocol.md) · [Deutsch](de/02-protocol.md) · [Español](es/02-protocol.md) · [Français](fr/02-protocol.md) · [Русский](ru/02-protocol.md)
[README](../README.md) · [Tutorial](01-tutorial.md) · **Protocol** · [Architecture](03-architecture.md) · [Command reference](command-reference.md)
# Protocol reference
The UnrealAgentLink wire protocol. This plugin is the **server**; the Unreal Editor is the **client**.
Every conclusion here comes from the C++ under `.ua-ref/plugin/UnrealAgentLink/Source/UnrealAgentLink/`, with file and line references at the load-bearing points.
---
## 1. Connection
| Item | Value | Evidence |
|---|---|---|
| Direction | UE dials out | `Network/UAL_NetworkManager.cpp:77` `CreateWebSocket(TargetUrl)` |
| Default address | `ws://127.0.0.1:17860` | `Core/UnrealAgentLink.cpp:88` |
| Changing it | command line `-UALServer=ws://host:port` | `Core/UnrealAgentLink.cpp:90` |
| Authentication | **none** — no token, header, or subprotocol | `CreateWebSocket` takes only a URL |
| Reconnect | a fixed 5-second cycle, no backoff | `Network/UAL_NetworkManager.cpp:116` |
| Connect-stall protection | yes (`ConnectTimeoutSeconds`) | `Network/UAL_NetworkManager.cpp:145-155` |
This plugin listens on 17860 with a `maxPayload` of 64 MB.
### Origin checking
Only loopback origins are accepted (`127.*`, `::1`, `::ffff:127.*`). It widens only when the configured `host` has been **deliberately** set to something other than `127.0.0.1` / `localhost`. The reason is in "no authentication" — the port is the credential.
### Handshake
**There is no handshake.** An editor pushes `project.info` immediately on connect, and this plugin uses that to mark it identified (`editor-identified`). Before it arrives the connection is "connected but unidentified" — such a record in `ual_status` has only `sessionId` / `connectedAt` / `lastSeenAt` / `heartbeats` / `stale`, with no `project` and friends.
---
## 2. Message shapes
```
UE → plugin: { ver:"1.0", type:"req", method, id, params }
plugin → UE: { ver:"1.0", type:"res", id, code, result }
UE → plugin: { ver:"1.0", type:"evt", method, payload }
```
### `req` (this plugin → editor)
| Field | Notes |
|---|---|
| `ver` | always `"1.0"` |
| `type` | `"req"` |
| `method` | the command name, e.g. `actor.get_info` |
| `id` | generated by the **server**, shaped `dsh-` |
| `params` | optional. **The whole key is omitted when empty**, not sent as `null` |
`id` must be non-empty: the first line of `SendResponse` is `if (RequestId.IsEmpty()) { return; }` (`Utils/UAL_CommandUtils.cpp:2503-2506`) — **a request without an id never gets a reply**.
On the editor side, parsing (`Commands/UAL_CommandHandler.cpp:87-94`) reads `params` first and falls back to the legacy field `payload`. This plugin sends only `params`.
### `res` (editor → this plugin)
| Field | Notes |
|---|---|
| `ver` | `"1.0"` |
| `type` | `"res"` |
| `id` | echoes the request `id`, used for matching |
| `code` | a **number**, not a string |
| `result` | optional. **The whole key is omitted when there is no content** |
The omission logic is in `Utils/UAL_CommandUtils.cpp:2513-2516`: `result` is written only when `Data.IsValid()`.
On the editor side, response-body parsing (`Commands/UAL_CommandHandler.cpp:98-104`) tries `result` → `data` → `payload` in order. This plugin sends `result`.
### `evt` (editor → this plugin, one-way push)
| Field | Notes |
|---|---|
| `ver` | present when the event is sent via `SendEvent`; **the import events from the content-browser extension do not carry it** |
| `type` | `"evt"` |
| `method` | the event name |
| `payload` | optional |
| `id` | **does not exist** — events cannot be replied to |
---
## 3. How failure is expressed
**By `code` alone.** There is **no** envelope-level `error` field in the message.
| Code | Meaning | This plugin's kind |
|---|---|---|
| `200` | success | — |
| `207` | partial success | `partial: true` |
| `400` | a parameter is missing or mistyped | `bad-params` |
| `404` | no such method or asset | `not-found` |
| `409` | conflict (e.g. an exclusive profiler is already running) | `conflict` |
| `410` | removed | `not-found` |
| `412` | precondition not met | `precondition` |
| `413` | over a cap (e.g. batch creation past a cvar limit) | `precondition` |
| `422` | input valid but nothing was written | `precondition` |
| `500` | editor internal error | `engine-error` |
| `501` | unsupported on this version | `unsupported` |
| `503` | the asset registry is still scanning | `registry-busy` |
| `504` | editor-side timeout | `timeout` |
`0` is **never used**.
These 13 codes are defined in `protocol/types.ts` as `UAL_CODES`, and check 4 of `scripts/check-catalog.mjs` queries the C++ source directly for them — including a 503 emitted by shared gates like `FUAL_RegistryReady::Ensure`, and expression forms like `Code = bOk ? 200 : 207`. One missing or extra code fails the check.
### Where the error text lives
```
result.message ← for humans, Chinese or English by editor culture, never parse it
result.details ← for machines (suggestions / expected_type / registry progress)
```
`SendError` builds `{message: ...}`, and the 4-argument overload attaches `details` as well (`Utils/UAL_CommandUtils.cpp:2525-2539`).
### 200 can still be a failure
**This is the easiest trap to fall into.** Many handlers return `200` with `result.ok === false`:
- `editor.undo` — when there is nothing to undo
- `blueprint.compile` — when compilation has errors
- `pie.observe` — when observation fails
So this plugin judges success with `judge(result, code)` rather than code alone:
| Situation | Verdict |
|---|---|
| `code === 200`, `result.ok !== false`, `result.error` empty | success |
| `code === 207` | success, but `partial: true` |
| `result.ok === false` | **failure** |
| `result.error` is a non-empty string | **failure** |
| `result` is not an object (or absent) | judge by code; do not guess |
This rule is **necessary**: there are 55 `SetStringField(TEXT("error"), ...)` sites across 14 C++ files. In other words, "200 with a non-empty error" is the norm, not an anomaly.
---
## 4. Events
| Event | When | Payload |
|---|---|---|
| `project.info` | immediately on connect, standing in for a handshake | project name, path, engine version, plugin version, run mode, current level, Python availability, and more |
| `project.closed` | the editor closes the project | — |
| `system.heartbeat` | every **10 seconds** | `{}` (**always present**, not omitted) |
| `log.entry` | UE log output (requires the cvar `ual.ForwardLogs 1`) | `text` / `category` / `level` |
| `content.import_folder` | the user picks "import to UnrealBox asset library" from the content browser context menu | `paths` / `real_paths` / `asset_metadata` |
| `content.import_assets` | as above, initiated from the level viewport | `asset_paths` / `asset_real_paths` / `asset_metadata` |
### Heartbeats and declaring an editor dead
Heartbeats are an **independent 10-second ticker** (`Network/UAL_NetworkManager.cpp:173`), unrelated to the 5-second reconnect. This plugin marks an editor `stale` after **45 seconds** without a heartbeat — matching the front end (`Network/UAL_NetworkManager.cpp:172`).
### The two import events are fire-and-forget
They carry **no `id`**, and the editor **does not wait for a reply**. The editor-side `Handle_Response` (`Commands/UAL_CommandHandler.cpp:185-297`) pops a Slate notification only when it receives `type:"res"`, reading `ok` / `count` / `importedPath` / `error`.
This plugin is a pure bridge with no ability to import assets, so it turns the events into **logs and agent notifications** and **does not fabricate a `res`** — replying `ok:false` would only show the user a false "import failed" dialog, which is worse than silence.
---
## 5. Serialization rules
Outbound parameters pass through `toSerializable()` in `protocol/serialization.ts` before going on the wire. It is stricter than `JSON.stringify`, because **UE treats a field it cannot read as "not sent"** — silently doing nothing is harder to diagnose than an error.
| Input | Handling |
|---|---|
| `null` / boolean / string | as-is |
| finite number | as-is |
| **non-finite number** (`NaN` / `Infinity`) | **throws** — UE would read it as "field absent" |
| `undefined` (top level, or inside an array) | **throws** |
| `undefined` (as an object property value) | **the key is dropped** |
| function / symbol / bigint | **throws** |
| circular reference | **throws** |
| `Uint8Array` | converted to a base64 string |
| `Date` | converted to an ISO string |
| `Map` (keys must be strings) | converted to a plain object |
| `Set` | converted to an array |
| other non-plain objects (class instances, etc.) | **throws** |
Only plain objects, arrays, and JSON scalars get through.
---
## 6. Timing and concurrency
**Commands for one editor are serial.** This plugin keeps one send queue per editor, chaining each command onto the previous one's `then`. This is not to satisfy the protocol — it is because the editor executes synchronously to completion on the game thread anyway.
**Queues are independent across editors.** When several editors connect, each gets its own queue.
### Timeouts
The default is 180 seconds, and blocking commands raise their own floors (`ual_schema`'s timeout column marks them "blocking").
**A timeout does not mean the command did not run.** It means only that this plugin stopped waiting. When that happens:
1. The editor is marked as having an orphaned command (`orphaned` in `ual_status`).
2. Subsequent **short commands to the same editor are refused outright** (`queue-stalled`), rather than queued behind work that may never return.
3. A `long: true` call can still be sent — that is how "I am willing to wait" is expressed.
**The same applies to aborted calls**: killing a `ual_task` job goes through abort, and abort stops only this plugin's wait while the engine keeps running. So an abort also records an orphan.
### Disconnection
When an editor disconnects, every in-flight request for it fails with `editor-lost`, and the error carries all the method names.
---
## 7. What this plugin does not send
To be explicit about the boundary:
- **No token** — there is no authentication mechanism.
- **No `GET /api/ual/action`** — old documentation described this as an HTTP service; that is wrong.
- **No task polling** — `query_task` / `cancel_task` do not exist.
- **No fabricated `res`** — see the import-events section.
- **No active reconnection to the editor** — the direction is reversed; the editor retries every 5 seconds on its own.
---
## 8. Related files
| File | Contents |
|---|---|
| `src/protocol/types.ts` | message types, `UAL_CODES`, `UalError` |
| `src/protocol/bridge.ts` | WebSocket server, queues, timeouts, orphans, log ring |
| `src/protocol/serialization.ts` | every serialization rule in the table above |
| `src/service.ts` | `judge()`, the gate chain, the ledger |
| `src/errors/translate.ts` | status code / class name → 16 kinds |
C++ counterparts: `Utils/UAL_CommandUtils.cpp` (`SendResponse` / `SendError` / `SendEvent`), `Commands/UAL_CommandHandler.cpp` (inbound dispatch), `Network/UAL_NetworkManager.cpp` (connection, heartbeat, reconnect).