Koder Konsul — `dev/eye`
On-device UI analysis bridge for Android. Replaces most screenshot-to-LLM round-trips (1 500 image tokens each) with a local JSON API (200 tokens) that agents query via adb forward. From v0.2 agents can also inject gestures, making Eye a full inspect-and-drive bridge.
Role in the stack
| Area | Sector | Consumers |
|---|---|---|
| Developer Platform | — (new sector) | Kode, Kortex, Claude Code, ad-hoc test scripts |
Sits alongside dev/bridge and dev/kterm as a device-facing developer tool. Unlike those, Eye runs on the device — it is the on-device half of a split inspector.
Primary couplings
| Module | Nature |
|---|---|
| None (Android platform only) | v0.2 has no dependency on other Koder modules |
dev/store |
published at hub.koder.dev/apps/koder-konsul (v0.2.1) |
ai/kode, ai/kortex |
consumers of the HTTP API — planned in RFC-001 §8 |
Interfaces
Local HTTP on 127.0.0.1:9876 (reach via adb forward tcp:9876 tcp:9876). All routes except /healthz require Authorization: Bearer <token>.
Observation
| Endpoint | Returns |
|---|---|
GET /healthz |
service status + granted permissions (no auth) |
GET /screen |
full UI tree as JSON + screen_hash |
GET /screen/text |
flat strings, reading order |
GET /screen/buttons |
clickable elements (label from child walk for Compose) |
GET /screen/diff?since=<hash> |
{added, removed, modified} vs a prior snapshot |
GET /windows |
all visible windows (id, layer, type, focused, bounds) |
GET /windows/<id>/screen |
UI tree for a specific window (dialogs, overlays) |
GET /events?since=<n>[&wait=<ms>] |
AccessibilityEvent stream; suspends up to 60 s |
GET /events/stream |
SSE long-lived stream; 15 s keepalive heartbeat |
Capture & OCR
| Endpoint | Body | Returns |
|---|---|---|
POST /capture |
{to?: filename} |
PNG path, dimensions, sha1 |
POST /ocr |
{region?: "x,y,w,h"} |
[{text, bounds}] — ML Kit on-device |
POST /query |
— | 501 (Gemma Nano / AICore — ticket EYE-006) |
Injection (requires toggle ON in app)
| Endpoint | Body | Action |
|---|---|---|
POST /tap |
{x,y} or {view_id} |
Tap gesture (50 ms) |
POST /scroll |
{x,y,dx,dy} or {direction} |
Fling gesture (300 ms) |
POST /type |
{text, view_id?} |
ACTIONSETTEXT on node |
POST /back |
— | GLOBALACTIONBACK |
POST /home |
— | GLOBALACTIONHOME |
POST /recents |
— | GLOBALACTIONRECENTS |
Rate limiting
Token-bucket per-path rate limiter (RateLimiter.kt):
| Path prefix | Limit |
|---|---|
/screen, /capture, /ocr |
5 req/s |
| All others | 20 req/s |
Exceeded requests get 429 Too Many Requests with Retry-After header and error ID EYE-RL-429-001.
gRPC transport (:9877)
Alongside the Ktor HTTP server, Eye runs a gRPC sibling on :9877 (EyeGrpcServer.kt, proto contracts in proto/koder/eye/v1/). All 13 RPCs across 5 services (HealthScreenActionEventMirror) are bound and delegate to the same in-process services the HTTP routes use (#033).
- Auth — bearer token via the gRPC
authorizationmetadata key,mirroring HTTP
Authorization: Bearer. - Rate limiting (#041) —
RateLimitInterceptormaps each unary RPCto its mirror HTTP path and shares the same
RateLimiterbuckets, so HTTP + gRPC draw from one combined budget.Healthzand the server-streaming RPCs are exempt; limit hit →RESOURCE_EXHAUSTED+retry-after-mstrailer. - TLS symmetry (#042) — in LAN mode (#031) the gRPC server binds the
same
CertBootstrapcert as HTTPS, so/info.fingerprint_sha256pins both channels; plaintext foradb forward. /info(#043) — exposesgrpc_port/grpc_host/grpc_tlsalongside the HTTP bind state.
Advanced config (in-app)
EyeConfig exposes four parameters via SharedPreferences, editable in the app's "Advanced" section:
| Setting | Default | Range |
|---|---|---|
| Port | 9876 | 1024–65535 |
| Tree depth | 32 | 4–128 |
| Text length | 512 | 64–4096 |
| Event cap | 1024 | 256–8192 |
EventLog.resize() is called live when event cap changes.
Host-side CLI: konsul
products/dev/konsul/cli/konsul — single-file Python 3 script, zero dependencies.
konsul healthz | text | buttons | screen | diff <hash>
konsul text --since-last # diff vs cached last screen hash (TTL 5 min)
konsul windows | capture [file.png] | ocr [--region x,y,w,h]
konsul watch [--since N] # SSE event stream
konsul tap <x> <y> | tap --id <view_id>
konsul scroll --dir down | type [--id <view_id>] <text>
konsul back | home | recents
konsul setup # adb forward + smoke test
konsul fanout <command> # run read command on all ADB-connected devices
konsul token [--set <token>]
konsul --grpc <read-command> # route over gRPC :9877 (needs grpcio)
konsul --https [--host <ip>] <cmd> # TLS + self-signed cert pinning (LAN, #050)The optional --grpc flag (#034) routes the cleanly-mapping read commands (healthzscreentextbuttonswindows/capture) over the gRPC server; grpcio is imported lazily only on that path so the default HTTP transport stays stdlib-only (zero-dep guarantee preserved). Other commands transparently fall back to HTTP. Vendored stubs in cli/proto/ (regenerate with make grpc-stubs).
--https (#050) pins the server's self-signed cert by fingerprint: a TOFU bootstrap fetches /info, reads fingerprint_sha256, asserts it matches the cert actually presented, and refuses any later connection whose cert SHA-256 differs (stdlib ssl+hmac, zero-dep preserved). Use it for LAN access (--host <device-ip>); the adb forward loopback default stays plaintext. The client pin is unit-tested device-free; the on-device negative test (a mismatched cert rejected against a live server) is gated on a device session and a working TLS server (#060, LAN-TLS engine swap).
Token saved to ~/.config/koder-konsul/token. Screen hash cache at ~/.cache/koder-konsul/<serial>/last-hash. Coloured table output by default; --json for raw.
Status
v0.2.1 (2026-04-23) — verified on Galaxy S26 Ultra (Android 16).
| Component | Status |
|---|---|
| Android app builds (AGP 8.11.1, Kotlin 2.2.20, compileSdk 36) | ✅ |
| Bearer token auth (EncryptedSharedPreferences, Keystore AES-256-GCM) | ✅ |
| Release signing (Android Keystore RSA-2048, APK v2 scheme) | ✅ |
| AccessibilityService captures UI tree | ✅ |
| Foreground service hosts Ktor CIO on localhost | ✅ |
/screen, /screen/text, /screen/buttons |
✅ |
/screen/diff (LRU-16 snapshot, SHA-1 tree hash) |
✅ |
/events with long-poll (?wait=) and /events/stream SSE |
✅ |
/capture (takeScreenshot API 30+) |
✅ |
/ocr ML Kit Latin-script, on-device |
✅ |
/windows + /windows/<id>/screen |
✅ |
| Injection: tap, scroll, type, back, home, recents | ✅ (gated by toggle) |
| Per-path rate limiting (token bucket, 52050 rps) | ✅ |
gRPC server :9877 — 13 RPCs bound, bearer auth (#033) |
✅ |
gRPC rate-limit parity (shared HTTP buckets, #041) + TLS symmetry (#042) + /info bind state (#043) |
✅ |
konsul --grpc transport (lazy grpcio, HTTP fallback, #034) |
✅ |
| Advanced config UI (port, tree depth, text len, event cap) | ✅ |
Host CLI konsul with fanout + snapshot cache |
✅ |
| JVM unit tests (EventLog, RateLimiter, EyeConfig) | ✅ |
| Instrumented tests (androidTest, UiAutomation fixture, 4 integration tests) | ✅ |
| Theme toggle (LightDark per `specsthemeslight-dark.kmd`, sunmoon icon top-right) + pt-BR i18n (paridade completa) | ✅ |
| MVP acceptance checklist (RFC-001 §10) | ✅ |
| Final 3D icon (almond sclera, teal iris, catchlight — adaptive + legacy mipmap) | ✅ |
Landing page at iris.koder.dev |
✅ |
| Koder Hub listing + APK published | ✅ hub.koder.dev/apps/koder-konsul |
| iOS sibling | ⏳ RFC-002 |
Autonomy layer (RFC-003) — from sensor to autonomous on-device agent
Iris is evolving from inspector to autonomous on-device agent (RFC-003, 2026-06). The layer is least-privilege by construction: a capability/consent spine gates every power — each capability is OFF by default, time-boxed (TTL), revocable, with an audit trail (the "god-mode" of unrestricted access + standing credential/geo aggregation was rejected by design).
| Component | File(s) | Role | Status |
|---|---|---|---|
Capability/consent spine (#075) |
CapabilityManager.kt, CapabilityStoreAndroid.kt |
every power gated, default-OFF, TTL, revocable; kill-switch; GET /capabilities + POST /capabilities/revoke-all |
✅ done |
Perception (#072) |
Perception.kt (+ OcrService) |
a11y+OCR unified view + pluggable on-device LLM hook (off by default — YAGNI); POST /perception gated PERCEPTION |
✅ done |
Event/trigger engine (#071) |
TriggerEngine.kt |
"when X → do Y", each rule gated by a capability | core ✅ · wiring on-device |
Offline-first queue (#073) |
OfflineQueue.kt |
durable FIFO + idempotent sync (D6) | core ✅ · wiring on-device |
Workflow orchestration (#074) |
WorkflowRunner.kt |
sequence + per-step retry + state, gated | core ✅ · wiring on-device |
Interactive mirror (#077) |
ActionService.clickAt/scrollAt, KonsulRoutes /tap_at /scroll_at, mirrorHtml overlay |
the /mirror becomes point-and-click: a browser click maps to device px → hit-test → ACTION_CLICK (node-action, works on the S26) |
✅ done (emulator-proven) |
Capability clusters (#103–#106) |
SmsServiceCallServiceUsageServicePackagesServiceSettingsService/CalendarService |
telephony+SMS, app-usage+package inventory, write-settings+DND, calendar — each gated per-cap + OS perm | ✅ done — on-device gating-accepted (15-test CapabilityGatingInstrumentedTest) |
Elevated bridge (#107) |
DeviceOwnerService+KonsulDeviceAdminReceiver (Part B), ShizukuService+ShizukuShellService+IShizukuShell.aidl (Part A) |
T3 Device-Owner (silent setPermissionGrantState → POLICY_FIXED) and Shizuku UserService bridge (shellroot uid, no rootno device-owner) |
✅ done — B E2E-proven, A build-verified + on-device gating-accepted (ShizukuProvider exported launch-crash fixed) |
Real injection via Shizuku (#108) |
ShizukuService.inputTap/Swipe/Text/Key, routes POST /shizuku/{tap,swipe,text,key} |
genuine input touchswipetextkey at the Shizuku shell uid (group input) — works on the non-rooted daily S26 where dispatchGesture is a no-op (#065) and INJECT_EVENTS needs platform-signroot (hub#410). Gated INJECTION + Shizuku-ready |
✅ done — on-device gating-accepted; re-scopes hub#410 (Route C) |
- Injection default-OFF (
#075):ActionService.injectionEnableddelegates toCapabilities.isGranted(INJECTION)—/tap,/type,… act only while granted (fixes the prior default-ON least-privilege gap). - Interactive mirror (
#077): the mirror page routes a browser click/wheelthrough
/tap_at`scroll_at, which hit-test the a11y tree (nodeAtPoint→ nearest clickable/scrollable ancestor) and fireACTIONCLICK/ACTIONSCROLL— so clicking the espelho actuates a real widget even where coordinate gestures are no-ops (the S26). Emulator-proven end-to-end (/tap_at` flips a Compose Switch). - First-run onboarding (
#078): when the AccessibilityService is off, aprominent step-by-step card (incl. the sideload "Allow restricted settings" step) guides the user through the OS consent and stays until configured — the 1.5s status poll auto-dismisses it once Accessibility is on. Guides the OS gates rather than bypassing them (the least-privilege answer to "enable everything"); closes the silent-no-UI gap of
#058. S26-verified live. - Privilege tiers (
RFC-003 §3): T0 node-action (today) → T2 privilegedextension (
hub#408, realINJECT_EVENTS—dispatchGestureis a no-op on the owner's S26, see#065) → elevated bridge (#107, done): Shizuku (Part A — shell/root uid via a bound UserService, no root / no device-owner, so viable on the daily S26 with no MDM footprint) and Device-Owner (Part B — T3, silent runtime-perm grant →POLICY_FIXED, emulator/dedicated-device only since banking/gov.br detect MDM). Neither grants signatureINJECT_EVENTS(stays inhub#408/root) nor auto-enables Accessibility. T4 root = dedicated rooted Pixel (future, owner asset). Kodix-system-app (T5) still future/gated. - Debug-bridge transport (
#068): reuses Koder Remote (infra/net/remoterelay-server) for NAT-traversing
adbover Wi-Fi without manual IPportcode (decided via/k-arch); not a bespoke overlay. - 29 unit tests across the 5 cores; all build-verified on the
ci-runner-androidAndroid build host (/opt/android-sdk, JDK 21). - Naming: kept Koder Konsul (Íris = messenger goddess / longa manus + the
iris as a light-regulating aperture = gated access) —
RFC-003 §5.
Design references
- RFC-001 — On-device UI analysis bridge for AI agents
- RFC-003 — Koder Konsul: de sensor a agente autônomo on-device
meta/docs/stack/specs/errors/user-facing-messages.kmd— allEYE-*/IRIS-*error IDs