Design document · 2026-09-15

ELC Remote for Agent

製品名 elc-remoteステータス Windows / macOS / Android は実機で検証済読者 実装・運用する開発チーム正本 elc-remote/docs/DESIGN.md

ELC Remote の「画面の無いビューア」。自動で動くもの(AI や監視スクリプト)が、人と同じように端末の画面を見て操作するための常駐プログラムです。

人が使うビューア(Web / iOS / Android / デスクトップ)とまったく同じ端末間プロトコルで繋ぎますが、画面は描きません。最新の 1 枚を持っているだけで、取りに来たときに渡します。操作する側は 127.0.0.1 のローカル API を叩きます。

- 単一バイナリ。 cgo を使わないので、Linux / macOS / Windows のどれでもそのまま動きます。入れるものはありません。 - 443/WSS のリレーだけを使います。 UDP が塞がれた環境でも通ります。 - サーバーは中身を読めません。 鍵は端末どうしの ECDH でだけ作られます。人が使うビューアと同じです。 - 人が同じ画面に入れます。 自動操作の様子を人がそのまま見られ、必要ならその場で操作を取り上げたり切断したりできます(端末側のプロフィール窓)。

`` AI / スクリプト ELC Remote for Agent ELC Remote 操作される端末 ────────────── ──────────── ─────────── ────────────── HTTP 127.0.0.1 ──▶ 画面を持つだけ ──▶ リレー(素通し) ──▶ Windows / macOS 入力を送る / Android ``

---

01入れる

配布ページから自分の OS のものを落として、実行権を付けるだけです。

curl -fsSLO https://remote-app.elc-gateway.com/download/agent/ELC-Remote-for-Agent-0.3.15-linux-amd64
chmod +x ELC-Remote-for-Agent-0.3.5-linux-amd64
sudo mv ELC-Remote-for-Agent-0.3.15-linux-amd64 /usr/local/bin/elc-remote-agent
elc-remote-agent version
OSファイル
Linux (x86_64)ELC-Remote-for-Agent-0.3.15-linux-amd64
Linux (arm64)ELC-Remote-for-Agent-0.3.15-linux-arm64
macOS (Apple Silicon)ELC-Remote-for-Agent-0.3.15-macos-arm64
macOS (Intel)ELC-Remote-for-Agent-0.3.15-macos-amd64
WindowsELC-Remote-for-Agent-0.3.15-windows-amd64.exe

macOS では初回に Gatekeeper が止めることがあります。xattr -d com.apple.quarantine elc-remote-agent で外してください。

02繋がるか確かめる

1 枚撮って終わります。これが通れば経路と認証は問題ありません。

elc-remote-agent shot 460161493 232364 shot.jpg
# w_sasaki-PC に繋がりました
# shot.jpg に書きました (1920x1200, 124076 bytes)

03常駐させる

elc-remote-agent serve
# ELC Remote for Agent 0.3.15 — http://127.0.0.1:7900
# サーバー: https://remote-app.elc-gateway.com
# トークン: Yk3s...(API に付ける Bearer トークン)
# 設定:     ~/.config/elc-remote/elc-remote-agent.json
# 画面:     http://127.0.0.1:7900/?token=Yk3s...

状況を見る画面

最後の行の URL をブラウザで開くと、いまどの端末に繋いでいるかが見えます。 端末ごとに ID・OS・画面の大きさ・速さ・最後の 1 枚が来てからの時間・ 同時に見ている人・取り上げられた権限が並び、そのときの画面も出ます。 その場で切ることもできます。

動いているかを確かめるのに curl を打つ必要はありません。

画面もループバックでしか開きません。別のマシンから見るときは同じように ポート転送でつなぎ、http://127.0.0.1:7900/ を開いてください。 トークンは一度入れればそのタブの間だけ覚えます(閉じれば消えます)。

トークンは ~/.config/elc-remote/elc-remote-agent.json(Windows は %AppData%\elc-remote\)に書き出されます。呼ぶ側はここから読んでください。

TOKEN=$(python3 -c 'import json;print(json.load(open("'$HOME'/.config/elc-remote/elc-remote-agent.json"))["token"])')

--addr はループバックだけです。 この API は端末を操作できるので、外から届く場所では待ち受けません。別のマシンから使うときは SSH のポート転送を使ってください。

ssh -L 7900:127.0.0.1:7900 <ELC Remote for Agent を動かしているマシン>

serve のオプション

既定
--addr127.0.0.1:7900待ち受け先(ループバックのみ)
--serverhttps://remote-app.elc-gateway.comELC Remote サーバー
--token自動生成API トークン
--keyコントローラー鍵。付けるとパスコード無しで繋げる
--org-token組織トークン(マルチテナントのとき)
--nameELC Remote for Agent@<ホスト名>端末側に名乗る名前
--stateOS の設定ディレクトリ設定の置き場

環境変数 ELC_SERVER / ELC_TOKEN / ELC_ORG_TOKEN / ELC_KEY でも指定できます。

04パスコードを持たずに繋ぐ(コントローラー鍵)

自動で動くものにパスコードを持たせたくない場合は、鍵で入れます。

elc-remote-agent keygen
# 秘密鍵を書きました: ~/.config/elc-remote/controller-key.json
# 公開鍵:
#   1logZF+gPa0+F2FJD9L7/IwUcNU4k9VhGb8EQedhAD8=

出てきた公開鍵を、繋ぎたい端末の ELC Remote アプリで「この端末 → パスコード無しで入れる相手」に登録します。あとは --key を付けるだけで、passcode を省略できます。

elc-remote-agent serve --key ~/.config/elc-remote/controller-key.json

---

05API

すべて Authorization: Bearer <トークン> が要ります(/healthz だけ不要)。本文と応答は JSON、画面だけ image/jpeg です。

一覧

GET/healthz生きているか
POST/sessions端末に繋ぐ
GET/sessions繋いでいる一覧
GET/sessions/{sid}状態(画面の大きさ、モニタ、同席者、権限、カーソル)
DELETE/sessions/{sid}切る
GET/sessions/{sid}/frameいまの画面(JPEG)
POST/sessions/{sid}/still高画質の 1 枚。領域も指定できる
POST/sessions/{sid}/input入力を 1 つ送る
POST/sessions/{sid}/wait画面が落ち着くまで待つ
POST/sessions/{sid}/act操作して、落ち着くまで待って、結果を返す
POST/sessions/{sid}/monitorモニタを選ぶ
POST/sessions/{sid}/quality画質を変える
GET POST/sessions/{sid}/clipboardクリップボード
GET/templates登録したテンプレートの一覧
PUT DELETE/templates/{name}テンプレートの登録と削除
POST/sessions/{sid}/templates/{name}いまの画面の一部を切り取ってテンプレートにする
POST/sessions/{sid}/matchテンプレートが写っている場所を探す
POST/sessions/{sid}/text画面(または一部)の文字を読む
POST/sessions/{sid}/find-textその文字が画面のどこにあるかを探す
GET POST/sessions/{sid}/elements画面の部品表(構造)を取る
POST/sessions/{sid}/probe同じ狙いを 3 段すべてに当てて、効き目を比べる

POST /sessions

{ "device": "460161493", "passcode": "232364",
  "monitor": "all", "fps": 4, "scale": 1.0, "quality": 80 }

passcode--key を使っているなら要りません。monitor"all" かモニタ番号。省略すると端末の既定のままです。

返りは状態です。sid を以降で使います。

{ "sid": "01e485c9", "device": "460161493", "name": "w_sasaki-PC",
  "platform": "windows", "authed": true, "transport": "relay",
  "monitors": [ { "id": 0, "name": "Display 1", "w": 1920, "h": 1200, "primary": true } ],
  "peers": [ { "name": "agent@srv", "id": "vMJEZMT40", "self": true, "input": true } ],
  "perm": { "input": true, "clip": true, "files": true },
  "cursor": { "x": 960, "y": 600, "shape": "arrow" } }

端末がオフラインなら 409、ID が無ければ 400 を返します。25 秒待たされません。

GET /sessions/{sid}/frame

いまの画面を JPEG で返します。

クエリ
since_seqこの番号より新しい画面だけ欲しい。同じなら 204(変化なし)
timeout_ms新しい画面が来るまで待つ(長時間ポーリング)。既定 0 = 待たない
max_age_msこれより古ければ X-Frame-Stale: 1 を付ける(画面が動いていない印)

応答ヘッダに X-Frame-Seq / X-Frame-Width / X-Frame-Height / X-Frame-Age-Ms が付きます。

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:7900/sessions/$SID/frame?timeout_ms=5000&since_seq=41" -o now.jpg

端末は画面が変わったときだけ送ってきます。 だから since_seq を付けて待つと「変わるまで待つ」になり、204 が返れば「変わっていない」と分かります。

POST /sessions/{sid}/still

等倍の高画質を 1 枚もらいます。w/h を付けるとその領域だけを切り出すので、文字を読みたいところだけを小さな転送量で取れます。座標は frame で返る画像の座標です。

{ "x": 0, "y": 0, "w": 600, "h": 200 }

応答ヘッダ X-Still-Region: x,y,w,h に、実際に切り出した場所が入ります。

POST /sessions/{sid}/input

type使うもの
movex, y
click / dblclickx, y(省略可), button(0=左 1=中 2=右)
down / upx, y, button
scrollx, y, dx, dy
keycode(例 Enter, KeyA, F5)
keyscodes(同時押し。順に押して逆順に離す)
texttext(文字列をそのまま打つ。日本語も可)
cadCtrl+Alt+Del(Windows のサービス常駐時のみ効きます)

code は DOM の KeyboardEvent.code(キーの物理位置)です。端末側が自分の配列に直します。

座標の代わりに template(登録した見た目)や text(画面に出ている文字)で狙えます。 見つからなければ 404 を返すので、「押したつもりで押せていない」が起きません。

# 「保存」と書かれているところを押す
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"type":"click","text":"保存"}' \
  http://127.0.0.1:7900/sessions/$SID/input
# 貼り付け(Windows なら Ctrl+V)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"type":"keys","codes":["ControlLeft","KeyV"]}' \
  http://127.0.0.1:7900/sessions/$SID/input

端末側で操作を取り上げられているときは 400 を返します(「端末側のプロフィール窓で外されています」)。黙って無視はしません。

POST /sessions/{sid}/wait

画面が落ち着くまで待ちます。

{ "settle_ms": 600, "timeout_ms": 15000, "ignore_busy": false }
{ "settled": true, "waited_ms": 1830, "frames": 4, "busy": false }

settle_ms のあいだ新しい画面が来なければ「落ち着いた」とみなします。加えて、カーソルが砂時計の間は待ち続けます(Windows のみ判別できます)。画面の差分だけで判断すると、動く広告や時計で「まだ動いている」と誤判定するためです。

sleep 30 で当てずっぽうに待つ代わりにこれを使うと、実際にかかった時間だけ待てます。

texttemplate を渡すと、それが出るまで(gone: true なら消えるまで)待ちます。 プログレスバーのように動き続ける画面では落ち着きを待てないので、こちらを使います。

{ "text": "保存しました", "timeout_ms": 30000 }
{ "template": "done-icon", "gone": true, "timeout_ms": 60000 }

POST /sessions/{sid}/act

操作して、落ち着くまで待って、結果を返します。1 往復で済むので、押す→待つ→見るを別々に呼ぶより速く、AI の推論回数も減ります。

{ "inputs": [ { "type": "click", "x": 820, "y": 460 } ],
  "settle_ms": 600, "timeout_ms": 15000 }
{ "settled": true, "waited_ms": 1840, "frames": 12, "busy": false,
  "frame": { "seq": 34, "w": 1920, "h": 1200, "bytes": 133168 } }

そのあと GET /frame で最新の画面を取ります。inputs は複数並べられ、各要素に delay_ms を付けると間に待ちを挟めます。

そのほか

# モニタを選ぶ
curl -X POST -d '{"mode":"single","id":1}' .../monitor
curl -X POST -d '{"mode":"all"}'          .../monitor

# 画質(fps は上限。変化が無ければ送られてこない)
curl -X POST -d '{"fps":2,"scale":1.0,"quality":85}' .../quality

# クリップボード(日本語を打たせるときは text より確実)
curl       .../clipboard                      # -> {"text":"..."}
curl -X POST -d '{"text":"貼り付ける文字"}'  .../clipboard

---

06何を押すかの指し方 — 精度の階段

座標を数えて押すと、画面の配置が変わるたびに壊れます。 押したいものを名前か見た目で指すほうが、取り違えもやり直しも減ります。

指し方には精度の段があり、ここでは上から順に試して、最初に当たった段で 決めます。どの段で当てたかと、その確からしさは必ず返します。

何を見るか強いところ弱いところ
1. 構造 structOS の部品表(Windows=UI Automation / macOS=AX)と CDP矩形がピクセルそのもの。拡大率・JPEG の劣化・配色・言語に影響されない。速い古いホスト、ゲーム画面、許可の無い macOS では取れない
2. ピクセル pixel見本の絵との正規化相互相関文字を持たないものでも指せる見た目が変わると外れる
3. 意味 text文字認識(tesseract)見本を用意しなくてよい小さい字と和文で崩れる。遅い

text で指すと 1 と 3 を、template で指すと 2 を使います。両方書けば 1→2→3 の順です。

実測(Chrome の配布ページ、Windows 11):

狙い構造文字認識
ELC Remote0.92 / 109ms0.85 / 6897ms(別の箇所を指した)
Web ビューアを開く0.92 / 102ms見つからず / 5388ms
ダウンロード0.92 / 109ms0.93 / 6311ms(構造とのずれ 8px)

構造が使えるなら 50〜60 倍速く、外しません。自分の現場での効き目は /probe で測ってください(下記)。

構造で指す

# いま画面に出ている部品を並べる
curl -s -X POST -H "$H" -H 'Content-Type: application/json' -d '{}'   $API/sessions/$SID/elements
# -> {"count":60,"sources":["uia","cdp"],"took_ms":115,
#     "items":[{"name":"最小化","role":"button","x":810,"y":11,"w":45,"h":39,
#               "enabled":true,"src":"uia"}, ...]}
引数既定
allfalsetrue で見えている窓すべて。既定は前面の窓だけ
cdptrueブラウザと Electron の中身も見に行く
max400返す数の上限
timeout_ms2500これを過ぎたら諦める

src はどこから取れたか。uia(Windows)、ax(macOS)、cdp(ブラウザの中身)。

ブラウザの中身を指すには、そのブラウザがデバッグ口を開いている必要があります。 開いていなければ notes にその旨が入り、OS の部品表だけで答えます。

# Chrome / Edge / Electron を起動するとき
chrome --remote-debugging-port=9222

覗きに行く口は 922292239229 です。

段ごとの効き目を測る

curl -s -X POST -H "$H" -H 'Content-Type: application/json'   -d '{"text":"ダウンロード"}' $API/sessions/$SID/probe
# -> {"layers":[
#      {"layer":"struct","found":true,"x":349,"y":147,"score":0.92,"took_ms":109,
#       "extra":{"matched":"ダウンロード","role":"a","src":"cdp","elements":60}},
#      {"layer":"text","found":true,"x":357,"y":152,"score":0.93,"took_ms":6311}],
#     "agreement":[{"a":"struct","b":"text","dx":8,"dy":5}]}

probe止めずに全段を走らせます。通常の操作より遅いので、較正と 原因調べに使ってください。agreement は段どうしの隔たりで、小さいほど その答えの裏が取れたことになります。

どの段で当てたかを見る

inputact は、狙いで指したとき必ず答えを返します。

curl -s -X POST -H "$H" -H 'Content-Type: application/json'   -d '{"type":"click","text":"保存"}' $API/sessions/$SID/input
# -> {"ok":true,"via":"struct","score":0.92,"x":148,"y":385,
#     "matched":"保存","src":"uia"}

どの段も外れたときは、段ごとの理由を並べて返します。

狙いが見つかりませんでした —— 構造: 部品が 1 つも取れませんでした
(OS のアクセシビリティ: 許可がありません) / 文字: "保存" は画面に見つかりませんでした

テンプレート(見た目で指す)

いまの画面から切り取って、名前を付けて残せます。画像を用意する必要はありません。

# いまの画面の (540,462) から 92x30 を「保存ボタン」として覚える
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
  -d '{"x":540,"y":462,"w":92,"h":30}' \
  $API/sessions/$SID/templates/save-button

# 探す
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
  -d '{"template":"save-button","threshold":0.9}' $API/sessions/$SID/match
# -> {"count":1,"matches":[{"x":540,"y":462,"w":92,"h":30,"cx":586,"cy":477,"score":1}]}
template / image登録した名前、または base64 の画像(その場限り)
threshold0..1。既定 0.9。0.9 くらいが「同じものが写っている」の目安
x y w h探す範囲(動く時計などを避けたいとき)
max返す数の上限。既定 8

明るさの違いには強い(正規化相互相関)ですが、回転や拡大縮小には対応しません。 同じ端末の同じ画面を探す用途なので、等倍の一致で足ります。 {"still": true} を付けて切り取ると、配信フレームではなく等倍の画面から取ります。

速さの目安は 1920x1200 の画面から 92x30 を探して 0.25 秒、120x32 で 1.0 秒です。

文字で指す

文字認識は外部コマンドに任せます。 単一バイナリに保つためで、 画像を標準入力で渡して標準出力を読むだけの決まりなので、tesseract でも自前のものでも挿せます。

elc-remote-agent serve --ocr "tesseract stdin stdout -l jpn+eng --psm 6 tsv"

TSV で出す設定にしてください。 語ごとの位置が取れるので、文字を指して押せます。 TSV でなければ、ただの文字列として扱います(位置は分かりません)。

# 画面の文字を読む(領域を絞れる)
curl -s -X POST -H "$H" -d '{"x":500,"y":440,"w":500,"h":120}' $API/sessions/$SID/text
# -> {"text":"Android 8.0 以降 ELC-Remote-...", "words":[...], "count":6}

# その文字がどこにあるか
curl -s -X POST -H "$H" -d '{"text":"Android"}' $API/sessions/$SID/find-text
# -> {"count":2,"matches":[{"x":545,"y":465,"w":69,"h":27,"cx":579,"cy":478,...}]}

読むときは既定で等倍に取り直します。配信フレームは縮小されていて文字がつぶれるためです。 返す座標は配信フレームの画素にそろえてあるので、そのまま input の座標に使えます ({"still": false} で取り直しを止められます。速いかわりに読み落とします)。

文字認識を設定していなければ 501 と設定方法を返します。

07使い方の型

TOKEN=$(python3 -c 'import json;print(json.load(open("'$HOME'/.config/elc-remote/elc-remote-agent.json"))["token"])')
API=http://127.0.0.1:7900
H="Authorization: Bearer $TOKEN"

SID=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
  -d '{"device":"460161493","passcode":"232364","fps":4}' $API/sessions \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["sid"])')

# 1. 見る
curl -s -H "$H" "$API/sessions/$SID/frame?timeout_ms=5000" -o screen.jpg

# 2. 押して、落ち着くまで待つ
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
  -d '{"inputs":[{"type":"click","x":820,"y":460}],"settle_ms":600}' \
  $API/sessions/$SID/act

# 3. 文字を読みたいところだけ等倍で取る
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
  -d '{"x":600,"y":300,"w":700,"h":240}' \
  $API/sessions/$SID/still -o label.jpg

curl -s -X DELETE -H "$H" $API/sessions/$SID

人が見ているときは手を出さない

peers に自分以外が入っていれば、人が同じ画面を見ています。

curl -s -H "$H" $API/sessions/$SID | python3 -c '
import json,sys
d = json.load(sys.stdin)
others = [p["name"] for p in d["peers"] if not p["self"]]
print("人が見ています:", others) if others else print("自分だけです")'

08安全のために

09制限

小さな文字や装飾の強い文字は読み落とします。確実に押したいものはテンプレートのほうが堅いです。