# 02 — Lua client (`trapos-sandbox`) > Sub-plan of [trap-sandbox-gateway-spec](./trap-sandbox-gateway-spec.md). Implements the > **Protocol**, **Handshake**, and **os-event contract** from the spec — reference them, don't > redefine. M2; depends on the protocol contract (not on M1 code existing). ## Scope The Lua side: `apis/libsandbox.lua`, `servers/sandbox.lua`, `programs/sandbox.lua`, and Lua **unit** tests. Package/meta changes are [03-packages](./03-packages.md); integration/e2e is [04-integration-tooling](./04-integration-tooling.md). ## Task checklist - [ ] `apis/libsandbox.lua` (template: `apis/libmcpcomputer.lua`). - [ ] `servers/sandbox.lua` (template: `servers/mcp-computer-server.lua`). - [ ] `programs/sandbox.lua` (template: `programs/ai.lua`). - [ ] Lua unit tests (`tests/sandbox.lua`). - [ ] Gate: `just _craftos-test` runs `tests/sandbox.lua` green. ## `apis/libsandbox.lua` — template: `apis/libmcpcomputer.lua` `createSandbox()` returns: - `traposId(osLike)` → `id .. ":" .. (label or "")`. - Envelope builders for the 4 events + hello. **Every `computer_request` (including `hello`) must carry a non-empty `traposSenderId` = the computer's `traposId`** — the TS gateway's zod schema requires it (decided in the 01-ts-gateway grill). The `hello` identity used for registration is still `payload.traposId`, but the envelope `traposSenderId` must also be present. - `onMessage(content, …)`: decode (pcall); `gateway_request`→ built-in `ping` (else `ok:false unknown_type`) replied as `computer_response` via pcall-send; `gateway_response`→ if `type=="hello"` drive the handshake state, else `queueEvent('trapos_sandbox_reply', messageId, ok, payload, error)`. - `startSession(opts)` (daemon; injectable `http`/`eventloop`/`encode`/`decode`/`os`): builds the connect URL from base `opts.url`, computes `traposId`; `websocketAsync`; on `websocket_success` send hello; on hello `ok:true` set state=ready + `queueEvent('trapos_sandbox_connected', traposId)`; on hello `ok:false` set state=unauthorized (**terminal**, store `lastError`, close, no reconnect); on `websocket_failure`/`_closed` reconnect via `el.setTimeout(connect, 5)` (unless unauthorized); hello with no ack in ~10s → close + reconnect; `trapos_sandbox_send` handler builds `computer_request`, **pcall-send** only when ready, else fast `trapos_sandbox_reply(messageId, false, nil, {code:"not_connected"})`. Returns `{ isReady, lastError }`. - `request(type, payload, opts)` (injectable `queueEvent`/`pullEvent`/`startTimer`): mint messageId, queue `trapos_sandbox_send`, wait for matching `trapos_sandbox_reply` vs a timer (**default 10s**, overridable). Returns `(ok, payloadOrError)`. ## `servers/sandbox.lua` — template: `servers/mcp-computer-server.lua` Read `sandbox.url` (unset/`http.websocket` missing → "daemon inactive", return) and `sandbox.password`; `startSession{ eventloop=_G.bootEventLoop, url, password(secret), os }`; print version. ## `programs/sandbox.lua` — template: `programs/ai.lua` - Reads `sandbox.url` first; unset → immediate `set sandbox.url first` (no 10s wait). - `health` command → `request('probe_gateway')` (wire `type` renamed from `health` during the 01-ts-gateway grill; the in-game command name stays `health`); print `sandboxOk`/`opencodeOk` (+ `opencode: ` when false); on `not ok` print `sandbox health failed: ` (e.g. `unauthorized`, `not_connected`, `timeout`). - `--version` / `--help`. ## Lua unit tests (`tests/sandbox.lua`, libtest + fakes) Envelope builders, `traposId`, `onMessage` dispatch (ping ok / unknown_type / hello), empty-payload coercion, `startSession` wiring (fake eventloop+http: hello sent on open, ready on ack, terminal on unauthorized, reconnect on drop), `request()` resolve + timeout via injected event primitives. ## Verification gate `just _craftos-test` runs the new `tests/sandbox.lua` green.