Board
OpenEng Board is a local-first visual canvas. A thin web client at board.openeng.app renders the editor; a sealed engine running on your own machine at 127.0.0.1:50055 owns every byte of persistence. Each board is a single JSON document written to disk under ~/.openeng/board/state/ — nothing about your board content ever leaves the machine, and the only network call the engine makes is authenticating you at boot.
Overview
Board follows the same spine as the other OpenEng apps: a browser UI that is pure rendering and editing, talking to a per-user local engine over a per-frame-encrypted gRPC-Web channel. The engine is the source of truth for boards and workspaces; the browser never persists anything of consequence itself.
Local engine
The openeng-board binary binds 127.0.0.1:50055 and serves the openeng.board.v1.Board gRPC service. The port is fixed and not configurable — this is a zero-config product.
Thin web client
board.openeng.app connects to https://engine.openeng.app:50055 (a hostname that resolves to loopback) and issues sealed RPCs. All editing happens in the browser; all storage happens in the engine.
Documents as JSON
Every board is one opaque JSON document (document_json). The engine stores it verbatim and never interprets it, except to count elements for the list view. The canvas schema is owned entirely by the UI.
Boards and workspaces
Boards are grouped into lightweight named workspaces. A Default workspace always exists. Boards and workspaces survive engine restarts and are shared across every browser tab on the machine.
Architecture and transport
The engine is the single encrypted boundary between the web app and local state. Every RPC except the handshake is sealed frame-by-frame.
Session handshake
CreateSession is the only unsealed RPC. The client offers a protocol version and an X25519 public key; the engine replies with its own public key, a session id, compatibility flags, and boot metadata:
CreateSessionResponse {
session_id // "ses_<uuid>"
engine_version // ProtocolVersion { major: 1, minor: 2 }
compatible // false ⇒ mismatch_reason is populated
engine_pub // engine X25519 public key (per session)
user // OS username, display only
default_workspace // "default"
engine_app_version // e.g. "0.0.2", for display
}
Per-frame sealing
After the handshake, both sides seal every payload to the other's static session public key. The construction is byte-identical across all OpenEng engines and the web client (@noble), so a browser and the engine interoperate:
X25519 (ephemeral sender key) → HKDF-SHA256 → ChaCha20-Poly1305 AEAD
shared = X25519(sender_priv, recipient_pub)
key = HKDF-SHA256(ikm=shared, salt=epk||recipient_pub, info="openeng-seal-v1")
Each seal mints a fresh ephemeral keypair and nonce; no derived session secret is stored. The wire envelope is {"v":1,"alg":..,"epk":..,"n":..,"ct":..}.
Loopback TLS and the handshake token
The socket is pure loopback, but it is served over HTTPS because Safari refuses plain-HTTP loopback (Mixed Content). The engine binds 127.0.0.1 while presenting a certificate for engine.openeng.app — a hostname with a global A record pointing at 127.0.0.1, so a publicly-trusted (Let's Encrypt) certificate validates in every browser with no manual trust step. At boot the authenticated engine fetches that cert from the cloud (/v1/engine/cert); it falls back to a persisted self-signed leaf if the fetch fails.
On top of TLS, the engine mints a 24-byte handshake token at ~/.openeng/board/runtime/handshake.token (mode 0600). Every RPC must present it in the x-openeng-local-token metadata header. Authorization is default-deny: a request with no token is Unauthenticated; a wrong token is PermissionDenied; a constant-time compare resolves the local principal.
One engine per machine
The engine holds a PID spawn lock at runtime/appserver.lock. On start it reclaims the port only from a process it can positively identify as another openeng-board engine (verified via /proc/<pid>/comm or ps); a foreign process holding the port yields a clean, attributable error and is never signalled.
Workspaces
A workspace is a lightweight named scope a board belongs to — an id and a name, with a derived board count. There is no folder hierarchy and no nesting.
| RPC | Request | Behaviour |
|---|---|---|
ListWorkspaces | session_id | Returns every workspace, the Default workspace first. Each carries a live board_count computed by filtering boards on workspace_id. |
CreateWorkspace | session_id, name | Mints an id (ws_<uuid>) and appends it to the index. An empty name becomes "Untitled workspace". Duplicate names are allowed — ids are always unique. |
The default workspace has the fixed id default and name Default. It is seeded automatically if the index is missing or does not contain it, so ListWorkspaces is never empty. The index persists to state/workspaces.json.
Boards
A board is a persisted whiteboard document. On the wire it is a BoardDoc message (named BoardDoc rather than Board only to avoid a protoc symbol collision with the Board service — the field numbers are unchanged):
BoardDoc {
id // "brd_<uuid>"
workspace_id // owning workspace ("default" if unset)
name
document_json // the opaque canvas document (blank in list responses)
created_at // RFC3339
updated_at // RFC3339
element_count // surfaced for the list view
}
The full CRUD surface
| RPC | Carries document? | Behaviour |
|---|---|---|
ListBoards | No — metadata only | Returns boards in a workspace, most-recently-updated first. document_json is intentionally blank; element_count is filled so the list view renders item counts without transferring any full documents. An empty workspace_id lists every board across all workspaces. |
CreateBoard | Yes (empty) | Creates an empty board; the document starts as an empty string. An empty name becomes "Untitled"; an empty workspace_id falls back to default. |
GetBoard | Yes — full | Reads the full board, including document_json, from disk by id. |
UpdateBoard | Yes — full | Full-document atomic save (see below). Replaces the whole document and stamps updated_at. |
RenameBoard | Yes — full | Renames a board; the document is untouched. An empty name is ignored (keeps the old one). Returns the full board. |
DeleteBoard | — | Deletes the board file and index entry. Idempotent: returns deleted=true only if it existed, false otherwise. |
Full-document atomic save
There is no partial-patch or diff protocol. Every save sends the entire document and replaces the prior one wholesale. The write is atomic — the engine writes <id>.json.tmp and then renames it over the target (rename is atomic on the same filesystem), so a crash mid-write can never leave a torn document. The in-memory index holds metadata only; the full document lives on disk exclusively, keyed by id at state/boards/<id>.json.
The web client debounces saves: a burst of drag or edit events collapses into a single UpdateBoard after 500 ms of quiescence (or an explicit flush when a board closes), so rapid interaction produces one atomic persist rather than a storm of writes.
How element_count is resolved
element_count exists so the list view can show board size without loading documents. On UpdateBoard the engine resolves it as follows:
- If the client supplies a non-zero
element_count, that value is trusted verbatim — the client may know about items the engine would not parse. - If the client supplies
0, the engine derives the count itself by parsingdocument_jsonand taking the length of its top-levelelementsarray. Any document that is not an object with anelementsarray counts as0— the parse never errors.
elements parsing path is a fallback, not the normal case. This matters because the UI's document uses a notes array, not an elements array — see the document model below.The canvas document model
The engine treats document_json as an opaque blob. The proto documents the generic contract as { "elements": [...], "viewport": {...} } — an elements list plus a viewport — and the one and only peek the engine ever takes is counting that elements array. Everything else about the schema is owned by the UI.
What the shipped UI actually stores
The current web client does not persist an elements/viewport pair. It serializes a richer Scene object — a sticky-note + kanban model with three interchangeable views over one shared set of items:
interface Scene {
version: 1
view: "freeform" | "columns" | "rough" // the open board's editor mode
columns: Column[] // kanban lane definitions
zones: Zone[] // dashed labelled rectangles on the freeform canvas
notes: Note[] // the items — the same set renders in all three views
}
Each Note carries a stable id, a title, one of five sticky colours, freeform world coordinates (x, y, rotate), a column id (for the kanban and list views), and optional meta, labels, an assignee, and a checklist. The three views are just three renderings of the same notes: freeform positions them absolutely as sticky notes, columns groups them by column, and rough shows a flat dotted list.
notes rather than an elements array, the engine's built-in element counter would return 0 for these documents. That is why the UI passes notes.length as the explicit element_count on every save — the engine trusts the client's count and never has to understand the schema.Defensive parsing and fresh boards
The client parses a persisted document defensively: a corrupt or unparseable save opens as an empty scene rather than crashing the editor, and older snapshots missing newer fields load with defaults filled in. A freshly created board opens empty — the kanban lanes exist so the Columns view works, but there are no demo notes and no pre-drawn zones for the user to fill.
home and boards) is backed by the engine today; the rest render as graceful "coming soon" placeholders.Client state and the SWR cache
Beyond boards and workspaces, the engine exposes two generic key/value facilities. Both store opaque JSON strings the UI encodes and decodes; the engine never interprets the values, and both are file-based and shared across every browser on the machine.
Client state (preferences)
GetClientState / SetClientState back a durable UI-preferences store keyed by string. The board UI persists its active sticky-note colour here under the key prefs, so every browser on the machine reads back the same setting. Ephemeral bits (selection, palette visibility) stay in memory.
Data cache (stale-while-revalidate)
CacheGet / CacheSet / CacheDelete store the last result of a costly read — the workspace's board list, workspace rollups — each stamped by the engine with the wall-clock millisecond it was refreshed. On open the UI renders the cached value instantly with a "refreshing" hint, fetches fresh, then writes it back.
CacheGet returns the stored value, a found flag, and refreshed_at (ms). CacheSet returns the new refreshed_at stamp. Because these are engine-owned and file-based, every browser sees the same last-known state — there is no per-tab cache to go stale independently.
Reading a board from Chat
The OpenEng Chat app can attach a board to a chat group so its agent can read the board's contents as context. This works through the same sealed loopback transport the browser uses — Chat's engine acts as a client of Board's engine.
Attaching a board to a group
A chat group carries a list of AppConnection entries — resources in sibling OpenEng apps the group's agent may act on. A board connection is:
AppConnection {
app: "board"
connection_id: "brd_<uuid>" // the board id
label: "<board name>" // for display
}
To populate the picker, the Chat engine calls Board's ListBoards with an empty workspace_id (all boards) and turns each into a selectable connection, labelling it with the board name and item count.
The board_get tool
When a group has one or more board connections, the agent is offered a board_get {connection} tool. Invoking it:
- Resolves and authorizes the requested
connectionagainst the group's configured board connections — an agent cannot reach a board that was not explicitly attached to the group. An empty request resolves to the sole connection when unambiguous. - Opens a fresh sealed session to the Board engine at
127.0.0.1:50055(serviceopeneng.board.v1.Board), using Board's handshake token and the identical seal construction. - Calls
GetBoardand returns the full document to the model as text:# <name> (<N> items)followed by the rawdocument_json(or(empty board)for a blank board).
The integration is read-only — Chat can read a board via board_get, but there is no cross-app tool to create, mutate, or delete boards. If the Board engine is not running, Chat reports the app as unavailable rather than failing fatally.
Configuration and storage
Board is deliberately close to zero-config. The port is fixed and there are no tuning knobs; the only meaningful override is the root of the OpenEng home directory.
Boot and authentication
The engine authenticates the user on every boot — there is no offline mode. It accepts exactly one credential flag:
openeng-board [serve] --oauth=<one-time key>
--oauth=<K>is a short-lived, single-use key copied from the Connect screen after Google sign-in. The engine presents it as a bearer token to the App Backend atapi.openeng.app/v1/oauth-key/exchange, which consumes it and returns a backend-minted ES256 JWT plus the principal. Without a valid key the engine printscannot startand exits.- There is no
--key/OPENENG_KEYpath — it was removed. After sign-in the engine talks only toapi.openeng.app(exchange, refresh, verify, and the engine cert); there is no other cloud egress. --version/--helpprint and exit. A background self-update checks the release registry at boot and stages a newer stable build for the next start; there is no opt-out.
On-disk layout
Everything roots at ~/.openeng/board/. Set OPENENG_HOME to relocate the whole OpenEng home; the board tree then lives under <OPENENG_HOME>/board/. This layout intentionally coexists with the AI engine (~/.openeng/runtime/, port 50051) and the terminal engine (~/.openeng/terminal/, port 50052) — separate port, lock, token, and state per app.
~/.openeng/board/
runtime/
appserver.lock # PID spawn lock (one engine per machine)
appserver.endpoint # advertised endpoint, written only after bind
handshake.token # 0600 local auth token
leaf-fullchain.pem # self-signed leaf + chain (fallback TLS)
leaf-key.pem # 0600
engine-fullchain.pem # publicly-trusted engine.openeng.app cert (when fetched)
engine-key.pem # 0600
state/
workspaces.json # the workspace index: [{ id, name }, …]
boards/<id>.json # one full board per file (incl. document_json)
State directories are created 0700 and secrets (token, private keys) are 0600 on Unix. The board store is resilient on load: an unreadable or corrupt board file is skipped, not fatal, and the default workspace is re-seeded if the index is missing.
| Setting | Value |
|---|---|
| Loopback bind | 127.0.0.1:50055 (fixed, not configurable) |
| Browser endpoint | https://engine.openeng.app:50055 |
| gRPC service | openeng.board.v1.Board |
| Protocol version | 1.2 |
| Home override | OPENENG_HOME (defaults to ~/.openeng) |
| Web app | board.openeng.app |
Security model and scopes
Board inherits the OpenEng engine security invariants. The scope of what the engine can hold and what it exposes is deliberately narrow.
No-secrets schema invariant
The proto carries no field for a secret, credential, API key, password, private key, or token, and no product-prompt carrier — enforced by a schema test. The only legitimate payload is the user's own board content.
Sealed by default
Every RPC except CreateSession is sealed per-frame with a fresh ephemeral key and nonce. There is no unencrypted data path once a session is established.
Default-deny auth
A principal-less request is rejected. Only a request presenting the correct 0600 handshake token (constant-time compared) resolves the local principal. The token is minted fresh per process.
Loopback only
The socket binds 127.0.0.1. The publicly-trusted certificate is for a hostname that resolves to loopback — it exists to satisfy browser TLS, not to expose the engine on the network.
Data scoping is workspace-based rather than permission-based: boards belong to a workspace, and ListBoards filters by workspace_id (empty = all). There are no per-board ACLs — this is a single-user, single-machine engine, and the machine's file permissions are the boundary. Cross-app access from Chat is gated at the group level: the agent can only reach boards explicitly attached to its group as connections, and only for reading via board_get.