Vault を構築する:セルフホスト AI エージェント向けの二画面端末
ESP32-S3 と画面 2 枚。OLED はリアルタイムの状態、電子ペーパーは読める結果を表示し、推論はマシン上に残す。著者はハードウェア選定の 3 つの制約、4 つのモード、そして「電気的に静かな」失敗を記録した。3.3V レールに焼かれた OLED 2 枚、プロトコル違反の I²C 迂回、sizeof の誤ったバッファでリモートコマンドごとに再起動。
日本語
コピー

タスクは、親指2本で打てるほど小さいキーボードから入力する:check disk space。

Vault の電子ペーパー内容ディスプレイ、OLED ステータスディスプレイ、キーボード、ロータリーエンコーダ。
文字は 128×64 の OLED に表示される。Enter を押す。worker が起動し、4.26 インチの電子ペーパーがゆっくりと全体を1回書き換えて静的な Thinking... カードを表示し、続いて OLED が実行中の OC THINK タイマーを表示し始める。ネットワーク上の別のマシンでは OpenClaw agent がコマンドを実行し、出力を短いレポートにまとめる。待っている間、タイマーが進み続ける。結果が出そろうと電子ペーパーが再び瞬き、カードを答えに置き換える。長い答えはノブでページをめくれる。このディスプレイの電源を切っても、最後のページは見えたままだ。
この一連の流れこそが Vault の意味だ。キーボード、ロータリーエンコーダ、2枚のディスプレイ、ESP32-S3、18650 バッテリーを備えたハンドヘルド端末である。shell であり、LLM クライアントであり、セルフホスト agent のフロントエンドであり、テキスト RPG でもある。小型の Linux マシンではなく、マイコン上で gpt-oss-120b を動かすこともない。モデルと agent は LAN 内のホストで動き、このハンドヘルドはそれらの前に置かれた、意図的に制限された計器だ。
作った理由は、主流の AI インターフェースの多くが同じものに収束してしまったからだ。光る汎用スクリーンの中のチャットバブル。ストリーミング出力ではなく読むことを中心に、app ではなく1つのタスクを中心にインターフェースを設計したらどうなるのかを知りたかった。
答えは、Vault を2つの表示リズムの上に築くことだった。OLED がリアルタイムの活動を追い、電子ペーパーが結果を担う。
ハードウェアを選ぶ前の3つの制約
3つのルールから始めた:
- テキストがインターフェースである。 タスクの投入と結果の理解に、タッチスクリーンも app グリッドもビットマップ装飾も要らない。
- リアルタイムの状態と永続する内容は別の仕事である。 同じ画面に無理に詰め込むべきではない。
- 推論はハンドヘルドに置かない。 デバイスは応答性を保ち、境界を明確に保つ。モデル、agent、バックエンドは常に差し替え可能にする。
これらの制約が最終的な技術スタックを決めた:
| 部品 | プロトタイプの選定 | 役割 |
|---|---|---|
| MCU | ESP32-S3、デュアルコア最大 240 MHz | 入力、状態機械、レンダリング、Wi-Fi |
| リアルタイム表示 | SSD1306 OLED、128×64、イベント駆動の再描画 | キー入力、モード、進捗、ページ送りのヒント |
| 内容表示 | Waveshare 4.26 インチ電子ペーパー、800×480 | アシスタント、agent、ゲームの出力 |
| 入力 | M5Stack CardKB と KY-040 エンコーダ | テキスト入力とページ送り |
| 電源 | 18650 → TP4056 充電 → 3.3 V 昇降圧 | 単電池駆動 |
| ファームウェア | Arduino on FreeRTOS、1.45 MB のフラッシュイメージ | 汎用 OS は動かさない |
最初に画面が点いた日からリリーススナップショットまで、67日と131コミット。公開版は整理して圧縮したスナップショットだ。
インターフェースには2つの時計がある
Vault の2枚の画面はやり取りの異なる段階を担い、リフレッシュ速度の違いがそれぞれの使い方を決める。
OLED は速い面だ。入力と状態変化のたびに再描画し、現在のモード、入力バッファ、接続状態、ホストが作業中のタイマー、返信が複数ページにまたがるときのヒント(READ 1/3 ENC>NEXT など)を表示する。
電子ペーパーは遅い面だ。このバージョンでは全書き換えに3〜4秒かかる。部分書き換えは約600ミリ秒だが、その代わり残像が少しずつ蓄積する。カーソルやアニメーションには向かないが、粗い粒度の段階遷移と、すでに完成した内容には向いている。
2枚の画面の分担はこう要約できる。やり取りの頻度が高いイベントは OLED へ、段階の変化と結果は電子ペーパーへ。

2つの面:OLED がリアルタイムの状態を担い、電子ペーパーが読める結果を担う。
assist と control のリクエストでは、リリース候補8(RC8、このプロトタイプが使うファームウェアのバージョン)は静的な Thinking... 状態のために電子ペーパーを1回全体書き換えし、結果の1ページ目でもう1回書き換える。連続する RPG のターンでは結果を部分書き換えすることがある。これらの遅い切り替えの間、動くタイマーは OLED が担う。ページ送りは部分書き換えを使い、ドライバは6回の部分書き換えの後に強制的に全体書き換えを1回行い、蓄積した残像を消す。不要なちらつきを避けるため、ファームウェアは電子ペーパー画像ごとに軽量な32ビットのフィンガープリントを計算する。直前の表示画像と一致すれば、転送と書き換えをスキップする。
読みやすさのための組版はファームウェアが担う。通常の出力は1行92文字で折り返し、1ページ19行。テキストを走査する際に動的なメモリ確保はしない。ページ送りにはロータリーエンコーダを使う。返信が複数ページあるときは、エンコーダを押すと読書モードを抜ける。RPG のインターフェースはテキスト列がより狭い。パネルの反対側をディザリングで描くシーンに残すからだ。
電子ペーパーのUIは、ターミナルの内容をそのまま流し込めばいいというものではない。小さなデザインシステムが、5種類のテキストスタイル、4ピクセル単位のスペーシングトークン、共有の区切り線、ステータスブロックとフッター、そして固定の50%ディザを定義している。リフレッシュのたびに目に入る画面では、一貫性がものを言う。
電子ペーパーはリフレッシュが遅い。このことが、ブラウザなら奇妙に見えるはずのある選択を説明してくれる。Vaultはモデルのトークンを電子ペーパーへストリーミングしない。ストリーミングは再描画コストの低い画面に向いている。トークンやツールイベントが届くたびにこの画面をリフレッシュすれば、ちらつき、残像、無駄なリフレッシュが延々と続く。現行のプロトタイプも2回はちらつく。フェーズカードで1回、結果で1回だ。だがトークンやツールイベントごとにリフレッシュすることは決してない。遅延だけを追うなら、処理中は前のページの電子ペーパー表示をそのまま残せばいい。それでも静的カードを残したのは、離れた場所からでも大まかなフェーズが読み取れるからだ。
この区別はハードウェアに限った話ではない。多くのAI製品は2種類の情報を同じストリームに混ぜてしまっている。
- 実行状態: 待ち受け中、検索中、ツール呼び出し中、リトライ中;
- 成果物: ユーザーが本当に欲しい回答、計画、パッチ、レポート。
Vaultは実行状態と、ユーザーが本当に欲しい成果物を分けている。状態チャンネルはタスクの進行に合わせて更新され続けてよい。完成した回答、計画、パッチ、レポートは安定したまま、人が読んでレビューできる。
モデルにハードウェアを直接触らせない
ESP32がモデルベンダーのプロトコルを直接話すことはない。HTTPで、LAN内の小さなPythonサーバーにコンパクトなJSONを送る。サーバーは標準ライブラリだけで動き、関連するディスパッチ経路は3つある。
/chatは会話をOpenAI互換のモデルエンドポイントへ送る;/clawは通常 OpenClaw agentを1ターン起動し、その最終応答を返す。成功したのに応答が空だった場合は一度だけリトライする;/rpg/*は決定的なセッション状態と、モデルが生成したナラティブを組み合わせる。

VaultはLAN経由でPython brokerと通信し、brokerがリクエストをモデルエンドポイント、OpenClaw agent、RPGエンジンへ振り分ける。
生成されるチャット、agent、RPGターンの応答は1,500〜2,000文字が上限だ。/chat経路では基本的なMarkdownを除去する。OpenClawとRPGの応答はほぼそのままVaultに届く。以下に見えるMarkdown見出しやコードフェンスも含めて。

OpenClawの結果:「write a Java example app」。Vault上では電子ペーパー4ページにページングされて表示される。
HTTPClient はまず動的なレスポンス文字列を受け取る。パース済みの応答は、折り返しとページングのために2.4 KBの静的ファームウェアバッファへコピーされる。モデル、agent、RPGのリクエストはFreeRTOS worker上で動くが、電子ペーパーのリフレッシュと同期ヘルスチェックは今もメインループを止めうる。
カンファレンスのデモでは、NVIDIA DGX Spark上でgpt-oss-120bを使い、NVIDIA TensorRT-LLMでローカルにサービスを提供した。クライアントと推論サーバーが期待するOpenAI形式のリクエストが食い違っていたので、LiteLLM proxyで吸収した。同じbrokerは他のOpenAI互換サーバーやホスト型APIにも向けられるが、今回のデモではモデル推論を終始LAN内に留めた。

OpenClawのディスク容量チェックが、Vaultの電子ペーパー画面にレンダリングされたところ。
ここでいう「ローカル」とは、推論がLAN内でホストされているという意味であり、手持ちのデバイス上で動くという意味ではない。マイクロコントローラ側が受け取るのは、上限付きの最終応答バッファ、シンプルなファームウェア、そしておおむね引っかかりのないUIだ。ホスト側は、デバイスを書き換えることなくモデル、ランタイム、agentを差し替えられる。
制御モードでは、バックエンドは今のところOpenClawのターンが終わるまで待ってから最終payloadを返す。agentはホスト上で計画を立てツールを呼び出せるが、Vaultはまだツールイベントの一つひとつをパネルへストリーミングしない。リクエスト実行中、OLEDにはタイマーが出る。agentが最終応答を返すと、Vaultはそれを電子ペーパーに表示する。将来job-ID方式の転送に変えれば、単一のHTTPリクエストを開いたままにせずに進捗とキャンセルを露出できる。
こちらが29秒のデバイスデモだ。VaultがOpenClawのタスクを投入し、リアルタイムのOLEDで待ち、結果を電子ペーパーに落とすまでを映している。
お使いのブラウザはvideoタグをサポートしていません。
4つのモード、1つのハードウェア
シェルは今もファームウェアの中核だ。スラッシュコマンドが、自由テキストをどこへルーティングするかを決める。
| モード | ルーティング | 電子ペーパーに表示されるもの |
|---|---|---|
/shell | ローカルコマンド、またはプロキシ経由のリモートコマンド | 履歴と診断情報 |
/assist | OpenAI互換のchat | モデルの返信 |
/control | OpenClaw agent | agentの最終結果 |
/rpg | ステートフルなゲームエンジン+LLM | ナラティブ、位置、ゲーム状態 |
InputManager CardKB、エンコーダ、USB シリアルをひとつのイベントストリームに統合した。上位層は、ある行が物理キーボードから来たのかシリアルコンソールから来たのかを知る必要がない。この抽象化は当初の予想以上に価値があった。おかげでファームウェアの残りの部分は、周辺機器の切断、エンコーダ信号の重複、起動遅延といった物理入力の厄介事を一切気にしなくて済む。
不具合の多くは電気的な問題で、しかも静かに起きる
アーキテクチャはすぐに固まった。物理側にはるかに時間がかかり、そこで学んだのはこれだ——作業台に載せていない回路図を信用するな。

試作機の内部:ESP32-S3、バッテリー、配線、ディスプレイ基板、キーボード、エンコーダ。
この I²C の回避策はプロトコルに違反している
この開発ボードには外部 I²C プルアップ抵抗がなく、ハードウェアの Wire パスは OLED やキーボードを安定して ACK できない。このポイントツーポイントの試作機向けに、ソフトウェアで 2 本のバスを書いた。SCL と SDA を能動的にハイ/ローへ駆動し、ACK と受信データをサンプリングするときは SDA を解放する。OLED バスはハーフビット 3 μs、遅い CardKB は 10 μs とした。
これで抵抗を追加せずに安定動作するようになったが、汎用的な I²C の推奨事項ではない。プッシュプル信号は I²C のオープンドレイン電気モデルに反しており、共有バスやマルチマスタバス、そしてラインをローに引く可能性のあるデバイスには向かない。量産向けの修正は、正規のプルアップ抵抗を付けて通常のバスにすることだ。ソフトウェア実装は、短距離・シングルマスタ接続という条件で本当の故障を切り分けるための現実的な手段にすぎない。
「3.3 V」の昇圧レールが OLED を 2 枚焼いた
当初は Pololu U3V16F3 昇圧レギュレータでディスプレイに給電し、出力を 3.3 V に設定していた。単セルのバッテリーは満充電時にこの目標値を上回り、このレギュレータは高い入力電圧をそのまま通してしまう。その結果 OLED には約 3.8 V がかかり、SSD1306 コントローラが正常動作するために推奨される VDD 上限である 3.3 V を超えた。この持続的な過電圧で 2 つのモジュールが相次いで壊れた。電源を昇降圧段に替えると、バッテリーの放電段階にかかわらずこのレールは安定した。
SPI の初期化は成功するのに画面が真っ白
e-paper ドライバの初期化は完了し、BUSY 線も正常なのに、画面には何も映らない。このボードの構成では GPIO 13 は SPI2 IO_MUX MISO ピンであり、MISO = -1 で SPI を初期化しても、このピンはその機能に属したままのように振る舞う。ところが私はこれをディスプレイの data/command 線に割り当てていた。この線を GPIO 17 に移すと、画面はすぐに正常へ戻った。
これはボードとコアの間で観測された相互作用として扱っている。すべての ESP32-S3 設計に当てはまる一般則ではない。教訓はこうだ——初期化が成功したように見えた後も周辺機器が動かないなら、まずピンマトリクスの所有権が割り当てたピンにまだ影響していないか確認する。
sizeof が誤ったバッファを使い、リモートコマンドのたびにデバイスが再起動する
あるリモートコマンドの経路が char resp[256] を確保し、sizeof(_respScratch) を付けてポーリング関数へ渡していた。ここで _respScratch は 2400 バイトのメンバーである。呼び出し先はこの長さを信頼し、スタック上のバッファを越えて書き込んだ。結果、ローカル以外のシェルコマンドはすべてデバイスを再起動させた。
修正は 1 行——その大きなメンバーを再利用するだけ——だったが、この不具合は高くついた。完全に正規に見える型と API の裏に隠れていたからだ。ハードウェアのデバッグと agent のデバッグには、同じ厄介な性質がある。可観測性は、システムが境界をまたぐ場所で往々にして落ちる。本当に効く修正とは、境界を明確にし、故障を見えるようにする修正だ。
この RPG が「LLM ネイティブ」の意味を教えてくれた
この RPG はもともと Vault の最長ワークフローの実験場だった。長文、ページ送り、セーブ、1-bit アート。やがてこれは、モデルがシステムのどこを引き受けるべきか、そしてどこを引き受けるべきでないかを示す最も明快な例になった。
永続的なゲーム状態とルールは Python が持つ。LLM はナラティブと、世界とプレイヤーに関する簡潔な構造化提案を生成する。エンジンが解析し、検証し、制約し、何を確定してよいかを決める。モデルは意外性をもたらすが、それによって確率的なデータベースになるわけでも、唯一の事実源になるわけでもない。
この責務分担は RPG に限らない。モデルにはモデルが得意なことをさせ、その結果を決定論的なソフトウェアで包む。書かせ、描写させ、つなぎ、変化を生み出させる。状態、ルール、検証はコードに残す。
経路の分離が使えるフォールバックをもたらした
シリアル入力と独立した表示経路が、基本的な保険になっている。e-paper ディスプレイが落ちても、返答は OLED のロールバック表示と USB シリアルから取得できる。OLED や CardKB が落ちても、USB シリアルが使える。オフラインでもローカル診断は動く。
エンコーダは今も e-paper のページをめくる唯一の手段なので、これが壊れると閲覧ビューは退化する。これらの経路は正式なフォールトトレランス設計ではないが、コンポーネントが使えないときに試作機のデバッグを続けさせてくれる。
LAN はセキュリティ境界ではない
試作機の broker はホストのすべてのネットワークインターフェースにバインドし(0.0.0.0)、平文 HTTP を使い、shell に支えられたリモートコマンドルートを公開していた。VAULT_TOKEN を有効にしない場合、broker に到達できるクライアントは誰でもコマンドを実行できる。token を有効にしても、平文 HTTP はアプリケーション層の転送保護を提供しない。
このサーバーを公網に晒してはいけない。製品版では認証を必須とし、shell ルーティングをホワイトリスト方式のコマンド API に置き換え、TLS またはセキュアトンネルを追加し、サービスをネットワーク内で分離し、agent に真にサンドボックス化されたホスト環境を与える。
次にやること
RC8 は、この手作業でハンダ付けしたプロトタイプに書き込まれているファームウェアのバージョンであり、完成品ではない。現状の制約は次のとおり:
- chat と RPG クライアントのリクエストは 60 秒、agent のリクエストは約 65 秒でタイムアウトするが、バックエンドのモデル呼び出しは 120 秒を使い切ることがある;
- MCU がバッファするのは完了済みのレスポンスであり、再開可能なタスクを受け取るわけではない;
- サーバー側の制限は Python の文字数で数えるが、MCU の最終バッファはバイト数で数えるため、マルチバイトテキストはバイト単位でフレーミングする必要がある;
- e-paper の部分リフレッシュは遅延と引き換えにゴーストが出るため、定期的に全画面リフレッシュが必要;
- メインボードはまだ回路図と配線の段階にあり、公開リリースには含まれていない。筐体の組み立ても検証が必要;
- バッテリー持ちは部品のデータシートからの見積もりではなく、実負荷で実測する必要がある。
次バージョンのトランスポート層は job ID を即座に返し、OLED に有界な進捗イベントを出し、キャンセルに対応し、最終成果物は別途電子ペーパーに送る。これにより、デュアルクロックのインターフェースがプロトコル全体を一貫して通るようになる。
2 か月使ってみて、最も価値があった成果は筐体でも、ましてや agent 連携でもなく、他の AI 製品にも持ち込める 1 つのルールだった:状態は流れるべきで、結果は積み重なるべきだ。
スマホや Web アプリなら、物理画面の代わりにパネルでこれを実現できる。アーキテクチャはそのまま成り立つ:短命なマシンの活動と持続するユーザー価値を分け、無界のモデル作業を broker の後ろに置き、システムの中で正確さより言語が効く部分はモデルに任せる。
Vault の機能は意図的にスマホより弱い。だからこそ、また別の app ではなく、計測器のように使える。
Vault は MIT ライセンスでオープンソース。ファームウェア、Python バックエンド、OpenSCAD の筐体、STL ファイル、画像パイプラインはすべて GitHub で見られる。本記事は AI Engineer World's Fair 2026 の講演「OpenClaw in Your Hand: Building a Physical AI Terminal」に対応する。