diff --git a/.gitignore b/.gitignore
index 0209520..55a32dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,8 @@ data/
*.sqlite
*.sqlite-wal
*.sqlite-shm
+
+# Android
+android-admin/.gradle/
+android-admin/**/build/
+android-admin/local.properties
diff --git a/CHECKLIST.md b/CHECKLIST.md
index ab525cf..9993ab9 100644
--- a/CHECKLIST.md
+++ b/CHECKLIST.md
@@ -6,8 +6,10 @@
## Android admin app
-- [ ] Kotlin + Compose app
-- [ ] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences/Keystore)
-- [ ] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id`
-- [ ] Stats views using `/api/stats/*`
-- [ ] Base URL config for local network host (e.g. `http://192.168.88.2:8787`)
+- [x] Kotlin + Compose app (project scaffold)
+- [ ] Add gradle wrapper jar (or generate via Android Studio)
+- [x] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences)
+- [x] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id`
+- [x] Stats summary view using `/api/stats/summary`
+- [x] Base URL config for local network host (e.g. `http://192.168.88.2:8787`)
+- [ ] Timeseries + model stats screens
diff --git a/README.md b/README.md
index 120af35..9be6628 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,11 @@ export FIRMWARE_API_KEY=...
Note: `.env` is loaded by `./run.sh` for non-secret config (like `ADMIN_TOKEN`, `PORT`).
+## Docs
+
+- `docs/USER.md`
+- `docs/ADMIN.md`
+
## Run (dev)
```bash
diff --git a/android-admin/.gitignore b/android-admin/.gitignore
new file mode 100644
index 0000000..dd749d3
--- /dev/null
+++ b/android-admin/.gitignore
@@ -0,0 +1,10 @@
+.idea/
+.gradle/
+local.properties
+*.iml
+build/
+**/build/
+captures/
+.externalNativeBuild/
+.cxx/
+keystore.jks
diff --git a/android-admin/README.md b/android-admin/README.md
new file mode 100644
index 0000000..f7e447d
--- /dev/null
+++ b/android-admin/README.md
@@ -0,0 +1,18 @@
+# Admin Android App
+
+Android (Kotlin + Jetpack Compose) admin client for the local-network Firmware Chat server.
+
+## Features
+
+- Configure `Base URL` (e.g. `http://192.168.88.2:8787`)
+- Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences)
+- View/search logs, open log detail
+- View stats summary
+
+## Build / Run
+
+Open `android-admin/` in Android Studio and run the `app` configuration.
+
+## Note
+
+This repo does not include `gradle-wrapper.jar` (Android Studio will generate/sync it).
diff --git a/android-admin/app/build.gradle.kts b/android-admin/app/build.gradle.kts
new file mode 100644
index 0000000..a52f2fb
--- /dev/null
+++ b/android-admin/app/build.gradle.kts
@@ -0,0 +1,60 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ namespace = "local.admin"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "local.admin"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
+ implementation("androidx.activity:activity-compose:1.9.1")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ implementation("androidx.compose.material3:material3")
+
+ implementation("androidx.navigation:navigation-compose:2.7.7")
+
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+}
diff --git a/android-admin/app/src/main/AndroidManifest.xml b/android-admin/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5ce5102
--- /dev/null
+++ b/android-admin/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-admin/app/src/main/java/local/admin/MainActivity.kt b/android-admin/app/src/main/java/local/admin/MainActivity.kt
new file mode 100644
index 0000000..5f57c38
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/MainActivity.kt
@@ -0,0 +1,21 @@
+package local.admin
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import local.admin.ui.AdminApp
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ Surface {
+ AdminApp()
+ }
+ }
+ }
+ }
+}
diff --git a/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt b/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt
new file mode 100644
index 0000000..ac03aa6
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/ui/AdminApp.kt
@@ -0,0 +1,19 @@
+package local.admin.ui
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+
+@Composable
+fun AdminApp() {
+ val nav = rememberNavController()
+ NavHost(navController = nav, startDestination = "home") {
+ composable("home") { HomeScreen(nav) }
+ composable("detail/{id}") { backStack ->
+ val id = backStack.arguments?.getString("id") ?: ""
+ DetailScreen(nav, id)
+ }
+ composable("settings") { SettingsScreen(nav) }
+ }
+}
diff --git a/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt b/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt
new file mode 100644
index 0000000..2f9b909
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/ui/CoroutineUtils.kt
@@ -0,0 +1,11 @@
+package local.admin.ui
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+fun launchIo(block: suspend () -> Unit) {
+ CoroutineScope(Dispatchers.IO).launch {
+ block()
+ }
+}
diff --git a/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt b/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt
new file mode 100644
index 0000000..1373aaa
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/ui/DetailScreen.kt
@@ -0,0 +1,67 @@
+package local.admin.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import kotlinx.serialization.json.Json
+import local.admin.data.Api
+import local.admin.data.LogDetail
+import local.admin.data.SecurePrefs
+import okhttp3.OkHttpClient
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DetailScreen(nav: NavController, id: String) {
+ val ctx = LocalContext.current
+ val prefs = remember { SecurePrefs(ctx) }
+ val detail = remember { mutableStateOf(null) }
+ val err = remember { mutableStateOf(null) }
+
+ LaunchedEffect(id) {
+ err.value = null
+ try {
+ val api = Api(OkHttpClient(), Json { ignoreUnknownKeys = true }, prefs.getBaseUrl(), prefs.getAdminToken())
+ detail.value = api.getLogDetail(id)
+ } catch (e: Exception) {
+ err.value = e.message
+ }
+ }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Detail") }) }
+ ) { pad ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(pad)
+ .padding(12.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ err.value?.let { Text("Error: $it") }
+ val d = detail.value
+ if (d != null) {
+ Text("id=${d.request_id}", fontFamily = FontFamily.Monospace)
+ Text("model=${d.model} status=${d.status}", fontFamily = FontFamily.Monospace)
+ Text("messages_json:\n${d.messages_json}")
+ Text("assistant_text:\n${d.assistant_text ?: ""}")
+ }
+ }
+ }
+}
diff --git a/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt b/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt
new file mode 100644
index 0000000..2e3c479
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/ui/HomeScreen.kt
@@ -0,0 +1,154 @@
+package local.admin.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import local.admin.data.Api
+import local.admin.data.LogRow
+import local.admin.data.SecurePrefs
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(nav: NavController) {
+ val ctx = LocalContext.current
+ val prefs = remember { SecurePrefs(ctx) }
+
+ val q = remember { mutableStateOf("") }
+ val model = remember { mutableStateOf("") }
+ val status = remember { mutableStateOf("") }
+ val rows = remember { mutableStateOf>(emptyList()) }
+ val err = remember { mutableStateOf(null) }
+ val stats = remember { mutableStateOf("") }
+
+ fun api(): Api {
+ val http = OkHttpClient()
+ val json = Json { ignoreUnknownKeys = true }
+ return Api(http, json, prefs.getBaseUrl(), prefs.getAdminToken())
+ }
+
+ suspend fun refresh() {
+ err.value = null
+ try {
+ val a = api()
+ val s = a.getStatsSummary()
+ stats.value = "total=${s.total} ok=${s.ok} error=${s.error} aborted=${s.aborted} p95=${s.p95LatencyMs ?: "-"}ms"
+ rows.value = a.getLogs(q.value.ifBlank { null }, status.value.ifBlank { null }, model.value.ifBlank { null }, 50, 0).rows
+ } catch (e: Exception) {
+ err.value = e.message
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ refresh()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Admin") },
+ actions = {
+ IconButton(onClick = { nav.navigate("settings") }) {
+ Icon(Icons.Default.Settings, contentDescription = "Settings")
+ }
+ }
+ )
+ }
+ ) { pad ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(pad)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text(stats.value, fontFamily = FontFamily.Monospace)
+ err.value?.let { Text("Error: $it") }
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(
+ value = q.value,
+ onValueChange = { q.value = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Search") },
+ singleLine = true,
+ )
+ Button(onClick = { launchIo { refresh() } }) {
+ Text("Go")
+ }
+ }
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(
+ value = status.value,
+ onValueChange = { status.value = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Status") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = model.value,
+ onValueChange = { model.value = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Model") },
+ singleLine = true,
+ )
+ }
+
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(rows.value) { r ->
+ LogCard(r) { nav.navigate("detail/${r.requestId}") }
+ Spacer(Modifier.height(10.dp))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogCard(r: LogRow, onOpen: () -> Unit) {
+ Card(
+ onClick = onOpen,
+ colors = CardDefaults.cardColors(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(r.status)
+ Text(r.model, fontFamily = FontFamily.Monospace, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ Text("Prompt: ${r.userPreview}", maxLines = 2, overflow = TextOverflow.Ellipsis)
+ Text("Answer: ${r.assistantPreview}", maxLines = 3, overflow = TextOverflow.Ellipsis)
+ }
+ }
+}
diff --git a/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt b/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt
new file mode 100644
index 0000000..5ae9276
--- /dev/null
+++ b/android-admin/app/src/main/java/local/admin/ui/SettingsScreen.kt
@@ -0,0 +1,61 @@
+package local.admin.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import local.admin.data.SecurePrefs
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(nav: NavController) {
+ val ctx = LocalContext.current
+ val prefs = remember { SecurePrefs(ctx) }
+ val baseUrl = remember { mutableStateOf(prefs.getBaseUrl()) }
+ val token = remember { mutableStateOf(prefs.getAdminToken()) }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("Settings") }) }
+ ) { pad ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(pad)
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(
+ value = baseUrl.value,
+ onValueChange = { baseUrl.value = it },
+ label = { Text("Base URL") },
+ singleLine = true,
+ )
+ OutlinedTextField(
+ value = token.value,
+ onValueChange = { token.value = it },
+ label = { Text("ADMIN_TOKEN") },
+ singleLine = true,
+ )
+ Button(onClick = {
+ prefs.setBaseUrl(baseUrl.value.trim())
+ prefs.setAdminToken(token.value)
+ nav.popBackStack()
+ }) {
+ Text("Save")
+ }
+ }
+ }
+}
diff --git a/android-admin/build.gradle.kts b/android-admin/build.gradle.kts
new file mode 100644
index 0000000..2cb31d0
--- /dev/null
+++ b/android-admin/build.gradle.kts
@@ -0,0 +1,5 @@
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
+}
diff --git a/android-admin/gradle.properties b/android-admin/gradle.properties
new file mode 100644
index 0000000..8f2e28c
--- /dev/null
+++ b/android-admin/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/android-admin/gradle/wrapper/gradle-wrapper.properties b/android-admin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..48c0a02
--- /dev/null
+++ b/android-admin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android-admin/gradlew b/android-admin/gradlew
new file mode 100755
index 0000000..92ae2a7
--- /dev/null
+++ b/android-admin/gradlew
@@ -0,0 +1,25 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for POSIX generated by Gradle.
+##
+##############################################################################
+
+app_path=$0
+
+APP_HOME=${app_path%"${app_path##*/}"}
+APP_HOME=$(cd "${APP_HOME:-./}" && pwd -P) || exit
+
+DEFAULT_JVM_OPTS='-Xmx64m -Xms64m'
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+if [ -n "${JAVA_HOME:-}" ] ; then
+ JAVACMD="$JAVA_HOME/bin/java"
+else
+ JAVACMD=java
+fi
+
+exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
+ -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/android-admin/gradlew.bat b/android-admin/gradlew.bat
new file mode 100644
index 0000000..3b66fa0
--- /dev/null
+++ b/android-admin/gradlew.bat
@@ -0,0 +1,21 @@
+@rem
+@rem Gradle startup script for Windows
+@rem
+
+@if "%DEBUG%"=="" @echo off
+setlocal
+
+set APP_HOME=%~dp0
+
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+if defined JAVA_HOME (
+ set JAVACMD=%JAVA_HOME%\bin\java.exe
+) else (
+ set JAVACMD=java.exe
+)
+
+%JAVACMD% %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+endlocal
diff --git a/android-admin/settings.gradle.kts b/android-admin/settings.gradle.kts
new file mode 100644
index 0000000..48e8da8
--- /dev/null
+++ b/android-admin/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Admin"
+include(":app")
diff --git a/docs/ADMIN.md b/docs/ADMIN.md
new file mode 100644
index 0000000..02383d8
--- /dev/null
+++ b/docs/ADMIN.md
@@ -0,0 +1,94 @@
+# Admin
+
+There are two admin surfaces:
+
+1) Browser admin UI: `/admin`
+2) Admin APIs: `/api/logs` and `/api/stats/*`
+
+Admin is intended for local-network use.
+
+## Browser admin UI
+
+1. Start the server (serves both chat UI and admin UI):
+
+```bash
+export FIRMWARE_API_KEY=...
+./run.sh build
+./run.sh server
+```
+
+2. Open the admin UI:
+
+- `http://:/admin` (example: `http://192.168.88.2:8787/admin`)
+
+3. Login:
+
+- Enter `ADMIN_TOKEN` once.
+- The server sets an `admin_token` httpOnly cookie.
+- The UI then calls admin APIs without needing custom headers.
+
+Logout clears the cookie.
+
+## Admin APIs
+
+Admin APIs are read-only and require authentication.
+
+Auth methods:
+
+- Header: `x-admin-token: $ADMIN_TOKEN`
+- Cookie session: `admin_token` (set by `POST /api/admin/session`)
+
+### List/search logs
+
+`GET /api/logs`
+
+Query params:
+
+- `q`: full-text search across prompt+answer (SQLite FTS5)
+- `status`: `ok|error|aborted|started`
+- `model`: model id
+- `from`, `to`: timestamps (ms epoch)
+- `limit` (1..200), `offset`
+
+Example:
+
+```bash
+curl -H "x-admin-token: $ADMIN_TOKEN" \
+ "http://192.168.88.2:8787/api/logs?q=xin%20chao&limit=50"
+```
+
+### Log detail
+
+`GET /api/logs/:request_id`
+
+Returns full record including:
+
+- `messages_json` (full conversation context)
+- `assistant_text` (final/partial answer)
+- timestamps and status
+
+### Stats
+
+- `GET /api/stats/summary`
+- `GET /api/stats/models`
+- `GET /api/stats/timeseries?bucket=hour|day`
+
+Example:
+
+```bash
+curl -H "x-admin-token: $ADMIN_TOKEN" \
+ "http://192.168.88.2:8787/api/stats/summary"
+```
+
+## Data logging
+
+SQLite file (default): `./data/chatlog.sqlite`
+
+Each `/api/chat` request inserts a log row at start and updates it when finished.
+
+Stored fields include:
+
+- timestamps: `ts_request`, `ts_first_token`, `ts_done`
+- full context: `messages_json`
+- quick fields: `user_text`, `assistant_text`
+- `model`, `status`, optional `usage` tokens
diff --git a/docs/USER.md b/docs/USER.md
new file mode 100644
index 0000000..b474815
--- /dev/null
+++ b/docs/USER.md
@@ -0,0 +1,33 @@
+# User
+
+## Start
+
+```bash
+export FIRMWARE_API_KEY=...
+cp .env.example .env
+```
+
+Edit `.env`:
+
+```bash
+ADMIN_TOKEN=...
+HOST=0.0.0.0
+PORT=8787
+```
+
+Then:
+
+```bash
+./run.sh build
+./run.sh server
+```
+
+Open:
+
+- `http://192.168.88.2:8787/`
+
+## Chat UI
+
+- Works in mobile Safari/iPhone.
+- Streams responses.
+- Renders assistant responses as Markdown (sanitized).