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.

Note The proto header describes an aspirational infinite-canvas whiteboard (shapes, connectors, sticky notes, text, pen, images, frames). The shipped UI is deliberately narrower: a sticky-note + kanban model with three views over one shared set of items. The engine contract accommodates either — it treats the document as opaque either way.

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.

RPCRequestBehaviour
ListWorkspacessession_idReturns every workspace, the Default workspace first. Each carries a live board_count computed by filtering boards on workspace_id.
CreateWorkspacesession_id, nameMints 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.

Note The proto and engine expose list and create for workspaces only. There is no RPC to rename or delete a workspace — those operations are not part of the current contract.

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

RPCCarries document?Behaviour
ListBoardsNo — metadata onlyReturns 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.
CreateBoardYes (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.
GetBoardYes — fullReads the full board, including document_json, from disk by id.
UpdateBoardYes — fullFull-document atomic save (see below). Replaces the whole document and stamps updated_at.
RenameBoardYes — fullRenames a board; the document is untouched. An empty name is ignored (keeps the old one). Returns the full board.
DeleteBoardDeletes 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 parsing document_json and taking the length of its top-level elements array. Any document that is not an object with an elements array counts as 0 — the parse never errors.
Tip The shipped UI always supplies its own count (the number of notes), so the engine's 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.

Warn Because the shipped Scene uses 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.

Note The left-nav has sections beyond the board editor (whiteboard, sticky, notes, markdown, calendar, templates). Only the board editor (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:

  1. Resolves and authorizes the requested connection against 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.
  2. Opens a fresh sealed session to the Board engine at 127.0.0.1:50055 (service openeng.board.v1.Board), using Board's handshake token and the identical seal construction.
  3. Calls GetBoard and returns the full document to the model as text: # <name> (<N> items) followed by the raw document_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 at api.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 prints cannot start and exits.
  • There is no --key / OPENENG_KEY path — it was removed. After sign-in the engine talks only to api.openeng.app (exchange, refresh, verify, and the engine cert); there is no other cloud egress.
  • --version / --help print 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.

SettingValue
Loopback bind127.0.0.1:50055 (fixed, not configurable)
Browser endpointhttps://engine.openeng.app:50055
gRPC serviceopeneng.board.v1.Board
Protocol version1.2
Home overrideOPENENG_HOME (defaults to ~/.openeng)
Web appboard.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.