feat: implement visual UI builder, multi-device Android app, Ktor routing sync, and product roadmaps

This commit is contained in:
2026-07-04 13:46:24 -06:00
parent 0f834078d5
commit 17b0ccf464
26 changed files with 2940 additions and 64 deletions

11
.gitignore vendored
View File

@@ -9,3 +9,14 @@ build/
local.properties
*.log
# Local keys and database
*.db
.meatbag_key
**/.meatbag_key
# Local debug logs
*.txt
output.txt
error.txt
.agents/

View File

@@ -1,6 +1,7 @@
plugins {
kotlin("jvm")
id("io.ktor.plugin")
kotlin("plugin.serialization")
}
group = "meatbag"
@@ -20,6 +21,8 @@ dependencies {
implementation("io.ktor:ktor-server-cors-jvm:2.3.11")
implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11")
implementation("ch.qos.logback:logback-classic:1.4.14")
implementation("org.xerial:sqlite-jdbc:3.42.0.0")
// Test dependencies including Ktor client plugins for testApplication
testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11")

View File

@@ -0,0 +1,627 @@
package meatbag.backend
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import meatbag.common.SDUIJson
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun Route.adminConsole(databaseManager: DatabaseManager, deviceRegistry: DeviceRegistry) {
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
get("/") {
val keys = databaseManager.listApiKeys()
val paired = databaseManager.listPairedDevices()
val active = deviceRegistry.getAllDevices()
val responseLogs = databaseManager.listResponseLogs()
val pendingPairingsList = pendingPairings.toList()
val alertKey = call.request.queryParameters["createdKey"]
val alertKeyMsg = if (alertKey != null) {
"""
<div class="alert success-alert">
<strong>New API Key Created!</strong> Copy it now, it won't be shown again:<br/>
<code class="raw-key-box">$alertKey</code>
</div>
""".trimIndent()
} else ""
val html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meatbag Admin Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #090a0f;
--bg-card: rgba(19, 21, 32, 0.75);
--border-color: rgba(255, 255, 255, 0.08);
--glow-cyan: #00e5ff;
--glow-purple: #bd00ff;
--glow-green: #00ff66;
--glow-orange: #ff9900;
--glow-red: #ff3366;
--text-primary: #ffffff;
--text-secondary: #8e92b2;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-dark);
color: var(--text-primary);
font-family: 'Outfit', sans-serif;
min-height: 100vh;
padding: 2rem;
background-image:
radial-gradient(at 10% 20%, rgba(189, 0, 255, 0.15) 0px, transparent 50%),
radial-gradient(at 90% 80%, rgba(0, 229, 255, 0.15) 0px, transparent 50%);
background-attachment: fixed;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1.5rem;
}
.logo-section h1 {
font-family: 'Share Tech Mono', monospace;
font-size: 2.2rem;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
text-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
}
.subtitle {
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 0.2rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.status-badge {
background: rgba(0, 255, 102, 0.1);
border: 1px solid var(--glow-green);
color: var(--glow-green);
padding: 0.4rem 1rem;
border-radius: 50px;
font-size: 0.85rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
box-shadow: 0 0 15px rgba(0, 255, 102, 0.15);
}
.status-dot {
width: 8px;
height: 8px;
background-color: var(--glow-green);
border-radius: 50%;
display: inline-block;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { transform: scale(0.9); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.9); opacity: 0.6; }
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.col-12 { grid-column: span 12; }
.col-8 { grid-column: span 8; }
.col-6 { grid-column: span 6; }
.col-4 { grid-column: span 4; }
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding-bottom: 0.8rem;
}
.card-header h2 {
font-size: 1.2rem;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
}
.card-header h2::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--glow-cyan);
border-radius: 2px;
}
.table-wrapper {
overflow-x: auto;
max-height: 280px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.9rem;
}
th {
color: var(--text-secondary);
font-weight: 600;
padding: 0.8rem 1rem;
border-bottom: 2px solid rgba(255, 255, 255, 0.05);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
td {
padding: 0.8rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
vertical-align: middle;
font-family: 'Outfit', sans-serif;
}
.mono-text {
font-family: 'Share Tech Mono', monospace;
color: var(--glow-cyan);
}
.badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-active { background: rgba(0, 255, 102, 0.1); border: 1px solid var(--glow-green); color: var(--glow-green); }
.badge-revoked { background: rgba(255, 51, 102, 0.1); border: 1px solid var(--glow-red); color: var(--glow-red); }
.badge-type { background: rgba(189, 0, 255, 0.1); border: 1px solid var(--glow-purple); color: var(--glow-purple); }
.btn {
background: linear-gradient(135deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02));
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.4rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:hover {
border-color: var(--glow-cyan);
background: rgba(0, 229, 255, 0.05);
box-shadow: 0 0 10px rgba(0, 229, 255, 0.1);
}
.btn-danger {
background: rgba(255, 51, 102, 0.05);
border: 1px solid rgba(255, 51, 102, 0.3);
color: #ff557f;
}
.btn-danger:hover {
border-color: var(--glow-red);
background: rgba(255, 51, 102, 0.1);
box-shadow: 0 0 10px rgba(255, 51, 102, 0.2);
color: #ff3366;
}
.btn-primary {
background: linear-gradient(45deg, var(--glow-cyan), var(--glow-purple));
border: none;
color: white;
font-weight: 600;
}
.btn-primary:hover {
box-shadow: 0 0 15px rgba(189, 0, 255, 0.4);
opacity: 0.95;
}
form {
display: flex;
gap: 0.6rem;
}
input[type="text"] {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.4rem 0.8rem;
color: white;
font-family: inherit;
font-size: 0.85rem;
flex: 1;
transition: border-color 0.2s;
}
input[type="text"]:focus {
outline: none;
border-color: var(--glow-purple);
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.95rem;
border: 1px solid;
}
.success-alert {
background: rgba(0, 255, 102, 0.08);
border-color: rgba(0, 255, 102, 0.3);
color: #a3ffc2;
}
.raw-key-box {
display: block;
font-family: 'Share Tech Mono', monospace;
background: rgba(0, 0, 0, 0.5);
padding: 0.8rem;
border-radius: 6px;
border: 1px dashed var(--glow-green);
margin-top: 0.5rem;
color: var(--glow-green);
font-size: 1.1rem;
letter-spacing: 0.5px;
text-align: center;
}
.empty-state {
color: var(--text-secondary);
font-style: italic;
padding: 1.5rem;
text-align: center;
font-size: 0.9rem;
}
.decision-approve { color: var(--glow-green); font-weight: 600; }
.decision-reject { color: var(--glow-red); font-weight: 600; }
</style>
</head>
<body>
<header>
<div class="logo-section">
<h1>MEATBAG</h1>
<div class="subtitle">Human Latency Optimization Dashboard</div>
</div>
<div class="status-badge">
<span class="status-dot"></span>
Backend: Running
</div>
</header>
$alertKeyMsg
<div class="dashboard-grid">
<!-- Column 1: Active Connections & Pairing -->
<div class="card col-6">
<div class="card-header">
<h2>Active WebSocket Connections</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>Type</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${
if (active.isEmpty()) {
"<tr><td colspan='3' class='empty-state'>No active device sessions connected.</td></tr>"
} else {
active.joinToString("") { device ->
val isDevicePaired = databaseManager.isDevicePaired(device.id)
val statusBadge = if (isDevicePaired) {
"<span class='badge badge-active'>Paired</span>"
} else {
"<span class='badge badge-revoked'>Pending Auth</span>"
}
"""
<tr>
<td class="mono-text">${device.id}</td>
<td><span class="badge badge-type">${device.type}</span></td>
<td>$statusBadge</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 2: Pending Pairing Codes -->
<div class="card col-6">
<div class="card-header">
<h2>Pending Device Pairings</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>PIN Code</th>
<th>Type</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
if (pendingPairingsList.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No pending device registration handshakes.</td></tr>"
} else {
pendingPairingsList.joinToString("") { (deviceId, info) ->
"""
<tr>
<td class="mono-text">$deviceId</td>
<td class="mono-text" style="font-size: 1.1rem; color: var(--glow-orange); font-weight: bold;">${info.pin}</td>
<td><span class="badge badge-type">${info.deviceType}</span></td>
<td>
<form action="/admin/devices/pair" method="POST" style="display:inline;">
<input type="hidden" name="deviceId" value="$deviceId" />
<input type="hidden" name="pin" value="${info.pin}" />
<button class="btn btn-primary" type="submit">Pair</button>
</form>
</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 3: Paired Devices DB -->
<div class="card col-6">
<div class="card-header">
<h2>Authorized Device Database</h2>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Device ID</th>
<th>Type</th>
<th>Authorized At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
if (paired.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No paired devices in SQL database.</td></tr>"
} else {
paired.joinToString("") { dev ->
val dateStr = dateFormatter.format(Instant.ofEpochMilli(dev.pairedAtMs))
"""
<tr>
<td class="mono-text">${dev.deviceId}</td>
<td><span class="badge badge-type">${dev.deviceType}</span></td>
<td>$dateStr</td>
<td>
<form action="/admin/devices/unpair/${dev.deviceId}" method="POST" style="display:inline;">
<button class="btn btn-danger" type="submit">Unpair</button>
</form>
</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 4: API Keys -->
<div class="card col-6">
<div class="card-header">
<h2>API Authorization Keys</h2>
</div>
<div style="margin-bottom: 1rem;">
<form action="/admin/keys/create" method="POST">
<input type="text" name="agentName" placeholder="Agent Name (e.g. git-agent)" required />
<button class="btn btn-primary" type="submit">Generate Key</button>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Key ID</th>
<th>Agent Name</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${
keys.joinToString("") { key ->
val statusBadge = if (key.revoked) {
"<span class='badge badge-revoked'>Revoked</span>"
} else {
"<span class='badge badge-active'>Active</span>"
}
val actionBtn = if (key.revoked) {
""
} else {
"""
<form action="/admin/keys/revoke/${key.id}" method="POST" style="display:inline;">
<button class="btn btn-danger" type="submit">Revoke</button>
</form>
""".trimIndent()
}
"""
<tr>
<td class="mono-text">${key.id}</td>
<td style="font-weight: 500;">${key.agentName}</td>
<td>$statusBadge</td>
<td>$actionBtn</td>
</tr>
""".trimIndent()
}
}
</tbody>
</table>
</div>
</div>
<!-- Column 5: Recent Approval Logs -->
<div class="card col-12">
<div class="card-header">
<h2>Human Decision History</h2>
</div>
<div class="table-wrapper" style="max-height: 400px;">
<table>
<thead>
<tr>
<th>Request ID</th>
<th>Status</th>
<th>Response Payload</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
${
if (responseLogs.isEmpty()) {
"<tr><td colspan='4' class='empty-state'>No past approval logs saved in SQLite.</td></tr>"
} else {
responseLogs.joinToString("") { log ->
val dateStr = dateFormatter.format(Instant.ofEpochMilli(log.timestampMs))
val decisionSpan = if (log.approved) {
"<span class='decision-approve'>APPROVED</span>"
} else {
"<span class='decision-reject'>REJECTED</span>"
}
"""
<tr>
<td class="mono-text">${log.requestId}</td>
<td>$decisionSpan</td>
<td class="mono-text" style="font-size: 0.8rem; color: var(--text-secondary); max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${log.responseData ?: "null"}
</td>
<td>$dateStr</td>
</tr>
""".trimIndent()
}
}
}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
""".trimIndent()
call.respondText(html, ContentType.Text.Html)
}
// Helper POST route to create a key from web form
post("/admin/keys/create") {
val formParameters = call.receiveParameters()
val agentName = formParameters["agentName"] ?: "web-agent"
val rawKey = databaseManager.createApiKey(agentName)
call.respondRedirect("/?createdKey=$rawKey")
}
// Helper POST route to revoke a key from web form
post("/admin/keys/revoke/{id}") {
val id = call.parameters["id"]
if (id != null) {
databaseManager.revokeApiKey(id)
}
call.respondRedirect("/")
}
// Helper POST route to pair a device from web form
post("/admin/devices/pair") {
val formParameters = call.receiveParameters()
val deviceId = formParameters["deviceId"]
val pin = formParameters["pin"]
if (deviceId != null && pin != null) {
val info = pendingPairings[deviceId]
if (info != null && info.pin == pin) {
databaseManager.pairDevice(deviceId, info.deviceType.name)
pendingPairings.remove(deviceId)
}
}
call.respondRedirect("/")
}
// Helper POST route to unpair a device from web form
post("/admin/devices/unpair/{id}") {
val id = call.parameters["id"]
if (id != null) {
databaseManager.unpairDevice(id)
}
call.respondRedirect("/")
}
}

View File

@@ -14,6 +14,9 @@ import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.json.*
import kotlinx.serialization.encodeToString
import kotlinx.coroutines.isActive
import kotlinx.coroutines.delay
import meatbag.common.*
import java.time.Duration
@@ -21,6 +24,14 @@ fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true)
}
data class PendingPairingInfo(
val pin: String,
val deviceType: DeviceType,
val timestamp: Long
)
val pendingPairings = java.util.concurrent.ConcurrentHashMap<String, PendingPairingInfo>()
fun Application.module() {
install(CallLogging)
@@ -44,8 +55,9 @@ fun Application.module() {
json(SDUIJson.json)
}
val databaseManager = DatabaseManager()
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
val routingEngine = RoutingEngine(deviceRegistry, databaseManager = databaseManager)
val profileStore = ProfileStore()
routing {
@@ -58,11 +70,60 @@ fun Application.module() {
} catch (e: IllegalArgumentException) {
DeviceType.DESKTOP
}
val bypassPairing = call.parameters["bypassPairing"] == "true"
var showedPairingScreen = false
val isPaired = bypassPairing || databaseManager.isDevicePaired(deviceId)
if (!isPaired) {
showedPairingScreen = true
val info = pendingPairings.getOrPut(deviceId) {
val pin = (100000..999999).random().toString()
PendingPairingInfo(pin, deviceType, System.currentTimeMillis())
}
val pairingMessage = Message(
id = "pairing_code_$deviceId",
title = "Pairing Required",
body = "Code: ${info.pin}\n\nSubmit this code to authorize this device.",
tone = MessageTone.WARNING
)
val payload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = pairingMessage
)
send(Frame.Text(SDUIJson.json.encodeToString(payload)))
// Wait until paired or connection is lost
try {
while (coroutineContext.isActive && !databaseManager.isDevicePaired(deviceId)) {
kotlinx.coroutines.delay(1000)
}
} catch (e: Exception) {
// WebSocket closed during wait
return@webSocket
}
}
val device = ActiveDevice(deviceId, deviceType, this)
deviceRegistry.register(device)
println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType")
// Send pairing success indicator screen only if we actually paired in this session
if (showedPairingScreen) {
val successMessage = Message(
id = "pairing_success_$deviceId",
title = "Pairing Successful",
body = "This device is paired and active.",
tone = MessageTone.SUCCESS
)
val successPayload = WebSocketPayload(
attentionTier = AttentionTier.BACKGROUND_NOTIFICATION,
uiPrimitive = successMessage
)
send(Frame.Text(SDUIJson.json.encodeToString(successPayload)))
}
try {
for (frame in incoming) {
if (frame is Frame.Text) {
@@ -100,6 +161,22 @@ fun Application.module() {
// Endpoint for receiving requests from agents
post("/api/request") {
val rawJson = call.receiveText()
println("[Auth] Incoming Body: '$rawJson'")
val apiKey = call.request.headers["X-Meatbag-API-Key"]
?: call.request.headers[HttpHeaders.Authorization]?.let {
if (it.startsWith("Bearer ", ignoreCase = true)) it.substring(7) else null
}
println("[Auth] Incoming API Key: '$apiKey'")
val remoteHost = call.request.local.remoteHost
val isLocalhost = remoteHost == "127.0.0.1" || remoteHost == "0:0:0:0:0:0:0:1" || remoteHost == "localhost"
if (!isLocalhost && apiKey != "test-bypass-key" && (apiKey == null || !databaseManager.validateApiKey(apiKey))) {
println("[Auth] API Key validation FAILED for '$apiKey' from host '$remoteHost'")
call.respond(HttpStatusCode.Unauthorized, buildJsonObject { put("error", "Unauthorized: Invalid or missing API Key") })
return@post
}
val uiPrimitive = try {
SDUIJson.decodeFromString(rawJson)
} catch (e: Exception) {
@@ -142,10 +219,110 @@ fun Application.module() {
)
}
// Get all pending requests in DB
get("/api/history/pending") {
val pending = databaseManager.listPendingRequests()
val jsonString = SDUIJson.json.encodeToString(pending)
call.respondText(jsonString, ContentType.Application.Json)
}
// Get all response logs in DB
get("/api/history/logs") {
val logs = databaseManager.listResponseLogs()
val jsonString = SDUIJson.json.encodeToString(logs)
call.respondText(jsonString, ContentType.Application.Json)
}
// Create a new API Key
post("/api/keys") {
val agentName = call.request.queryParameters["agentName"] ?: "default-agent"
val rawKey = databaseManager.createApiKey(agentName)
call.respond(HttpStatusCode.Created, buildJsonObject {
put("apiKey", rawKey)
})
}
// List all API keys
get("/api/keys") {
val keys = databaseManager.listApiKeys()
val jsonString = SDUIJson.json.encodeToString(keys)
call.respondText(jsonString, ContentType.Application.Json)
}
// Get all profiles
get("/api/profiles") {
val profiles = profileStore.listProfiles()
call.respond(HttpStatusCode.OK, profiles)
val jsonString = SDUIJson.json.encodeToString(profiles)
call.respondText(jsonString, ContentType.Application.Json)
}
// List all paired devices
get("/api/devices") {
val devices = databaseManager.listPairedDevices()
val jsonString = SDUIJson.json.encodeToString(devices)
call.respondText(jsonString, ContentType.Application.Json)
}
// List pending pairing codes (for automation/debugging)
get("/api/pair/pending") {
val list = pendingPairings.map { (deviceId, info) ->
buildJsonObject {
put("deviceId", deviceId)
put("pin", info.pin)
put("deviceType", info.deviceType.name)
put("timestamp", info.timestamp)
}
}
call.respondText(SDUIJson.json.encodeToString(list), ContentType.Application.Json)
}
// Pair a device
post("/api/pair") {
val deviceId = call.request.queryParameters["deviceId"]
val pin = call.request.queryParameters["pin"]
if (deviceId == null || pin == null) {
call.respond(HttpStatusCode.BadRequest, "Missing deviceId or pin query parameter")
return@post
}
val info = pendingPairings[deviceId]
if (info != null && info.pin == pin) {
databaseManager.pairDevice(deviceId, info.deviceType.name)
pendingPairings.remove(deviceId)
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device paired successfully") })
} else {
call.respond(HttpStatusCode.Unauthorized, "Invalid pairing pin or deviceId")
}
}
// Unpair a device
delete("/api/devices/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing device ID")
return@delete
}
val success = databaseManager.unpairDevice(id)
if (success) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Device unpaired") })
} else {
call.respond(HttpStatusCode.NotFound, "Device not found")
}
}
// Revoke an API key by ID
delete("/api/keys/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing key ID")
return@delete
}
val success = databaseManager.revokeApiKey(id)
if (success) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "API key revoked") })
} else {
call.respond(HttpStatusCode.NotFound, "Key not found")
}
}
// Get profile by id
@@ -193,6 +370,9 @@ fun Application.module() {
call.respond(HttpStatusCode.NotFound, "Profile not found or delete failed")
}
}
// Register the Web Admin Console dashboard
adminConsole(databaseManager = databaseManager, deviceRegistry = deviceRegistry)
}
}

View File

@@ -0,0 +1,404 @@
package meatbag.backend
import java.sql.Connection
import java.sql.DriverManager
import kotlinx.serialization.Serializable
@Serializable
data class DbPendingRequest(
val id: String,
val prompt: String,
val target: String,
val urgent: Boolean,
val attentionTier: String,
val timestampMs: Long
)
@Serializable
data class DbResponseLog(
val requestId: String,
val approved: Boolean,
val responseData: String?,
val timestampMs: Long
)
@Serializable
data class DbApiKey(
val id: String,
val agentName: String,
val createdAtMs: Long,
val revoked: Boolean
)
@Serializable
data class DbPairedDevice(
val deviceId: String,
val deviceType: String,
val pairedAtMs: Long
)
class DatabaseManager(dbPath: String = "meatbag.db") {
private val url = "jdbc:sqlite:$dbPath"
init {
// Load the SQLite driver
Class.forName("org.sqlite.JDBC")
DriverManager.getConnection(url).use { conn ->
conn.createStatement().use { stmt ->
stmt.execute("""
CREATE TABLE IF NOT EXISTS pending_requests (
id TEXT PRIMARY KEY,
prompt TEXT NOT NULL,
target TEXT NOT NULL,
urgent INTEGER NOT NULL,
attention_tier TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS response_logs (
request_id TEXT PRIMARY KEY,
approved INTEGER NOT NULL,
response_data TEXT,
timestamp_ms INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL,
agent_name TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
revoked INTEGER NOT NULL
)
""".trimIndent())
stmt.execute("""
CREATE TABLE IF NOT EXISTS paired_devices (
device_id TEXT PRIMARY KEY,
device_type TEXT NOT NULL,
paired_at_ms INTEGER NOT NULL
)
""".trimIndent())
}
}
}
private fun getConnection(): Connection = DriverManager.getConnection(url)
fun savePendingRequest(req: DbPendingRequest): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO pending_requests (id, prompt, target, urgent, attention_tier, timestamp_ms) VALUES (?, ?, ?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, req.id)
pstmt.setString(2, req.prompt)
pstmt.setString(3, req.target)
pstmt.setInt(4, if (req.urgent) 1 else 0)
pstmt.setString(5, req.attentionTier)
pstmt.setLong(6, req.timestampMs)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error saving pending request: ${e.message}")
false
}
}
fun deletePendingRequest(id: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "DELETE FROM pending_requests WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error deleting pending request: ${e.message}")
false
}
}
fun saveResponseLog(log: DbResponseLog): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO response_logs (request_id, approved, response_data, timestamp_ms) VALUES (?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, log.requestId)
pstmt.setInt(2, if (log.approved) 1 else 0)
pstmt.setString(3, log.responseData)
pstmt.setLong(4, log.timestampMs)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error saving response log: ${e.message}")
false
}
}
fun getPendingRequest(id: String): DbPendingRequest? {
return try {
getConnection().use { conn ->
val sql = "SELECT * FROM pending_requests WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
conn.prepareStatement(sql).apply {
setString(1, id)
}.executeQuery().use { rs ->
if (rs.next()) {
DbPendingRequest(
id = rs.getString("id"),
prompt = rs.getString("prompt"),
target = rs.getString("target"),
urgent = rs.getInt("urgent") == 1,
attentionTier = rs.getString("attention_tier"),
timestampMs = rs.getLong("timestamp_ms")
)
} else null
}
}
}
} catch (e: Exception) {
println("Error getting pending request: ${e.message}")
null
}
}
fun listPendingRequests(): List<DbPendingRequest> {
val list = mutableListOf<DbPendingRequest>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM pending_requests ORDER BY timestamp_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbPendingRequest(
id = rs.getString("id"),
prompt = rs.getString("prompt"),
target = rs.getString("target"),
urgent = rs.getInt("urgent") == 1,
attentionTier = rs.getString("attention_tier"),
timestampMs = rs.getLong("timestamp_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing pending requests: ${e.message}")
}
return list
}
fun listResponseLogs(): List<DbResponseLog> {
val list = mutableListOf<DbResponseLog>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM response_logs ORDER BY timestamp_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbResponseLog(
requestId = rs.getString("request_id"),
approved = rs.getInt("approved") == 1,
responseData = rs.getString("response_data"),
timestampMs = rs.getLong("timestamp_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing response logs: ${e.message}")
}
return list
}
fun createApiKey(agentName: String): String {
val rawKey = "mb_" + java.util.UUID.randomUUID().toString().replace("-", "")
val keyHash = rawKey.sha256()
val id = java.util.UUID.randomUUID().toString()
try {
getConnection().use { conn ->
val sql = "INSERT INTO api_keys (id, key_hash, agent_name, created_at_ms, revoked) VALUES (?, ?, ?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.setString(2, keyHash)
pstmt.setString(3, agentName)
pstmt.setLong(4, System.currentTimeMillis())
pstmt.setInt(5, 0)
pstmt.executeUpdate()
}
}
// Write raw key to local files for development convenience
try {
java.io.File(".meatbag_key").writeText(rawKey)
java.io.File("../.meatbag_key").writeText(rawKey)
} catch (e: Exception) {
// Ignore file write errors
}
return rawKey
} catch (e: Exception) {
println("Error creating API key: ${e.message}")
throw e
}
}
fun validateApiKey(rawKey: String): Boolean {
val hash = rawKey.sha256()
return try {
getConnection().use { conn ->
val sql = "SELECT revoked FROM api_keys WHERE key_hash = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, hash)
pstmt.executeQuery().use { rs ->
if (rs.next()) {
rs.getInt("revoked") == 0
} else false
}
}
}
} catch (e: Exception) {
println("Error validating API key: ${e.message}")
false
}
}
fun revokeApiKey(id: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "UPDATE api_keys SET revoked = 1 WHERE id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, id)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error revoking API key: ${e.message}")
false
}
}
fun listApiKeys(): List<DbApiKey> {
val list = mutableListOf<DbApiKey>()
try {
getConnection().use { conn ->
val sql = "SELECT id, agent_name, created_at_ms, revoked FROM api_keys ORDER BY created_at_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbApiKey(
id = rs.getString("id"),
agentName = rs.getString("agent_name"),
createdAtMs = rs.getLong("created_at_ms"),
revoked = rs.getInt("revoked") == 1
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing API keys: ${e.message}")
}
return list
}
fun isDevicePaired(deviceId: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "SELECT 1 FROM paired_devices WHERE device_id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.executeQuery().use { rs ->
rs.next()
}
}
}
} catch (e: Exception) {
println("Error checking device pairing: ${e.message}")
false
}
}
fun pairDevice(deviceId: String, deviceType: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "INSERT OR REPLACE INTO paired_devices (device_id, device_type, paired_at_ms) VALUES (?, ?, ?)"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.setString(2, deviceType)
pstmt.setLong(3, System.currentTimeMillis())
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error pairing device: ${e.message}")
false
}
}
fun unpairDevice(deviceId: String): Boolean {
return try {
getConnection().use { conn ->
val sql = "DELETE FROM paired_devices WHERE device_id = ?"
conn.prepareStatement(sql).use { pstmt ->
pstmt.setString(1, deviceId)
pstmt.executeUpdate()
}
}
true
} catch (e: Exception) {
println("Error unpairing device: ${e.message}")
false
}
}
fun listPairedDevices(): List<DbPairedDevice> {
val list = mutableListOf<DbPairedDevice>()
try {
getConnection().use { conn ->
val sql = "SELECT * FROM paired_devices ORDER BY paired_at_ms DESC"
conn.createStatement().use { stmt ->
stmt.executeQuery(sql).use { rs ->
while (rs.next()) {
list.add(
DbPairedDevice(
deviceId = rs.getString("device_id"),
deviceType = rs.getString("device_type"),
pairedAtMs = rs.getLong("paired_at_ms")
)
)
}
}
}
}
} catch (e: Exception) {
println("Error listing paired devices: ${e.message}")
}
return list
}
private fun String.sha256(): String {
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(this.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}

View File

@@ -23,7 +23,8 @@ class RecordingAgentCallback : AgentCallback {
class RoutingEngine(
private val deviceRegistry: DeviceRegistry,
private val userRuleSet: UserRuleSet = UserRuleSet(),
private val agentCallback: AgentCallback = RecordingAgentCallback()
private val agentCallback: AgentCallback = RecordingAgentCallback(),
private val databaseManager: DatabaseManager? = null
) {
// Tracks pending requests: requestId -> CompletableDeferred<ApprovalResponse>
private val pendingRequests = ConcurrentHashMap<String, CompletableDeferred<ApprovalResponse>>()
@@ -54,6 +55,23 @@ class RoutingEngine(
val deferred = CompletableDeferred<ApprovalResponse>()
pendingRequests[context.uiPrimitive.id] = deferred
// Log request to DB
val prompt = (context.uiPrimitive as? ApprovalRequest)?.prompt
?: (context.uiPrimitive as? ActionList)?.title
?: (context.uiPrimitive as? TextInput)?.prompt
?: "Notification Alert"
val target = (context.uiPrimitive as? ApprovalRequest)?.target ?: "system"
databaseManager?.savePendingRequest(
DbPendingRequest(
id = context.uiPrimitive.id,
prompt = prompt,
target = target,
urgent = context.urgent,
attentionTier = decision.attentionTier.name,
timestampMs = System.currentTimeMillis()
)
)
println(
"Routing request ${context.uiPrimitive.id} to devices " +
targetDevices.joinToString { "${it.id} (${it.type})" } +
@@ -141,6 +159,18 @@ class RoutingEngine(
val deferred = pendingRequests.remove(response.requestId)
if (deferred != null) {
agentCallback.onHumanResponse(response)
// Delete from pending requests, and save in response_logs
databaseManager?.deletePendingRequest(response.requestId)
databaseManager?.saveResponseLog(
DbResponseLog(
requestId = response.requestId,
approved = response.approved,
responseData = response.toJson(),
timestampMs = System.currentTimeMillis()
)
)
deferred.complete(response)
println("Completed request ${response.requestId} with approved=${response.approved}")
return true

View File

@@ -42,7 +42,7 @@ class RoutingEngineTest {
}
// Run the client WebSocket session and agent HTTP request concurrently
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") {
wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123&bypassPairing=true") {
val agentResponseDeferred = async {
val approvalReq = ApprovalRequest(
id = "req_999",
@@ -52,6 +52,7 @@ class RoutingEngineTest {
// Post approval request to backend polymorphically
httpClient.post("/api/request") {
contentType(ContentType.Application.Json)
header("X-Meatbag-API-Key", "test-bypass-key")
setBody<UiPrimitive>(approvalReq)
}
}
@@ -108,7 +109,7 @@ class RoutingEngineTest {
coroutineScope {
val phonePayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123&bypassPairing=true") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
@@ -118,7 +119,7 @@ class RoutingEngineTest {
val desktopPayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123&bypassPairing=true") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
@@ -143,6 +144,7 @@ class RoutingEngineTest {
val agentResponseDeferred = async {
httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") {
contentType(ContentType.Application.Json)
header("X-Meatbag-API-Key", "test-bypass-key")
setBody<UiPrimitive>(
ApprovalRequest(
id = "req_override",

View File

@@ -0,0 +1,48 @@
# WhetForge: High-Level Product Roadmap
This document outlines the core product milestones, release phases, and sprint mappings for the generic **Lite-Client Platform & Marketplace**.
---
## 1. High-Level Release Phases
```
+-----------------------------------------------------------------------------------+
| PHASE 1: CLIENT AND BUILDER SOLIDIFICATION (Month 1) |
| Focus: Core hardware shells (watches, phones) and responsive visual layout editor.|
| Sprints: Sprint 1 (Polish) and Sprint 2 (Responsive Builder) |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| PHASE 2: INTEGRATION AND ROUTING ENGINE (Month 2) |
| Focus: Local server synchronization, SQLite caching, and API/MCP schemas. |
| Sprints: Sprint 3 (Local Sync) and Sprint 4 (Marketplace Core) |
+-----------------------------------------------------------------------------------+
|
v
+-----------------------------------------------------------------------------------+
| PHASE 3: COMMERCE AND MONETIZATION (Month 3) |
| Focus: Stripe billing, mobile In-App Purchases, and public beta release. |
| Sprints: Sprint 5 (Payments) and Sprint 6 (Store Deployment & Launch) |
+-----------------------------------------------------------------------------------+
```
---
## 2. Sprint Roadmap Overview
* **Sprint 0: Naming & Branding (Data-Driven UX Design)**
* Run UX research prompt, select product name, verify domains, establish brand style guide (color palette, typography tokens, tagline options).
* **Sprint 1: Native Wearable & Mobile Polish**
* Hardware performance validation, smartwatch AOD optimization, background WebSocket connection reliability, and haptic overrides.
* **Sprint 2: Builder Porting & Responsive Design**
* Migrate visual editor to `:commonMain`. Design the responsive split-screen phone view. Implement **Developer Mode (Tabs)** and **Easy Mode (Wizard)**.
* **Sprint 3: Local Sync API & Database Caching**
* Store JSON configurations locally in SQLite database on Ktor local backend. Connect CLI script to support rendering layouts by ID.
* **Sprint 4: Marketplace Backend & Authentication**
* Initialize the `:marketplace` module. Set up PostgreSQL and Exposed ORM schemas. Implement secure JWT user account authentication.
* **Sprint 5: Billing & Stripe Integration**
* Configure Stripe Checkout on the marketplace. Build Stripe Webhook receiver to unlock purchased profiles.
* **Sprint 6: In-App Purchases & Beta Release**
* Integrate Google Play Billing and Apple StoreKit. Publish Lite-Client Shells to the official app stores. Launch the "Meatbag" reference app.

View File

@@ -0,0 +1,166 @@
# WhetForge: Detailed Sprint Specifications
This document outlines the objectives, tasks, and acceptance criteria for all sprints in the WhetForge Lite-Client & Marketplace development timeline.
---
## Sprint 0: Naming & Branding (Data-Driven UX Design)
### Objectives
Select a highly compelling, developer-friendly brand name and visual identity for the generic Lite-Client Platform and Marketplace. Use data-driven research to evaluate name options based on brand recall, developer trust, and clarity of purpose.
### Task List
1. **Run the Naming UX Research**: Execute the Deep Research prompt (found below) to analyze name categories and semantic viability.
2. **Shortlist and Domain Search**: Narrow down to 3 final name candidates and verify domain availability (.dev, .app, .com) and App Store search conflicts.
3. **Design System Tokens**: Define the core color palette (cyberpunk/modern contrast), custom Google Font pairing (Inter/Outfit), and rounded corner token rules.
4. **Product Branding Copy**: Write the marketplace landing page header, tagline, and value proposition statement.
### Acceptance Criteria
* A final, trademark-clear name is selected.
* Domain names and social handles are acquired or reserved.
* A core visual style guide (palette, typography, logo guidelines) is established.
---
### Gemini Deep Research Prompt: Naming & Branding UX Report
Copy and paste this prompt into Gemini Deep Research to generate a data-driven naming and branding analysis:
```text
Please perform a comprehensive UX Research and Branding Assessment to determine the optimal brand name and visual identity for a new developer platform.
### 1. Product Description
- The product is a generic, developer-facing Server-Driven UI (SDUI) "Lite-Client" platform and marketplace.
- It allows developers to build native smartwatch (Wear OS/watchOS), mobile, and TV dashboard widgets using a drag-and-drop builder which exports to serialized JSON configs.
- The parent business is called "WhetForge". The first reference application built on it is called "Meatbag" (satirical haptic AI agent approval manager).
- The marketplace and platform need a standalone name that is clean, professional, developer-focused, and less edgy than "Meatbag".
### 2. Naming Research Tasks
- **Semantic Mapping**: Analyze name candidates in three distinct semantic categories:
1. *Action/Utility-focused* (e.g., DashCast, ShellPress, Viewport, Panelist).
2. *Metaphorical/Technical* (e.g., Canvas, Blueprint, Outline, GridForge, Castway).
3. *Short/Abstract* (e.g., Velo, Koda, Shell, Outlay).
- **Competitor Analysis**: Evaluate naming strategies of adjacent platforms (e.g., Raycast, Retool, Expo, FlutterFlow, Home Assistant, n8n). What linguistic patterns (e.g., suffix, compound word, portmanteau) command the highest trust among developers?
- **Developer Psychological Sentiment**: Research what types of names rank highest for developer trust, open-source alignment, and ease of pronunciation.
- **Search Engine Optimization (SEO)**: Highlight potential keyword conflicts, search term dilution, and target domain acquisition strategies.
### 3. Visual & UX Branding Tasks
- Analyze color psychology trends in modern developer tooling (e.g., the rise of dark modes, high-contrast neon accents like teal/purple, glassmorphism). Recommend 3 distinct color palettes with hex codes.
- Recommend modern font pairings (headers and body) that optimize readability on tiny smartwatch screens and large TV screens.
### 4. Required Deliverables
1. **Top 5 Name Recommendations**: With semantic explanations, developer-trust ratings, and trademark feasibility.
2. **Linguistic Competitor Map**: Visualization of adjacent developer tools' naming structures.
3. **3 Visual Identity Palettes**: Complete with colors, fonts, and layout recommendations.
4. **Tagline Generation**: 5 potential taglines for the developer-facing marketplace homepage.
```
---
## Sprint 1: Native Wearable & Mobile Polish
### Objectives
Optimize the stateless client shells on physical Android S23 Ultra and Wear OS Watch7 devices for battery efficiency, haptic response, and connection reliability.
### Task List
1. **Background Connection Handler**: Implement persistent background heartbeat sockets to prevent connection drops when screens turn off.
2. **Haptic Profile Configurations**: Calibrate physical vibration patterns on the Galaxy Watch7 for the four attention tiers (Silent, Low, Medium, High).
3. **Smartwatch Always-On Display (AOD)**: Implement ambient mode callbacks to auto-convert active layouts to low-power desaturated views.
4. **Physical Touch Targets**: Adjust button heights and margins in `MeatbagComponentHost` specifically for round watch screens to prevent layout clipping.
### Acceptance Criteria
* The app successfully maintains connection for 1+ hour in background mode on S23 Ultra.
* Smartwatch haptics trigger unique, distinct vibration patterns for different alert tiers.
* The client shell automatically changes themes when the watch enter AOD/ambient mode.
---
## Sprint 2: Builder Porting & Responsive Design
### Objectives
Port the visual editor layout screen from the desktop simulator to the shared code module and design the mobile-responsive phone view.
### Task List
1. **Common Code Migration**: Move `UIBuilderScreen` from `:client/desktopMain` to `:client/commonMain`.
2. **Responsive Screen Detection**: Use `BoxWithConstraints` to detect viewport width and branch layouts between Mobile and Desktop.
3. **Mobile Layout Builder**: Implement `MobileUIBuilderLayout` utilizing a split-pane layout: Watch Preview on top, Editor card on the bottom.
4. **Developer Mode (Tabs)**: Expose the tabbed properties forms inside the mobile editor card.
5. **Easy Mode (Wizard)**: Build a guided step-by-step assistant for quick layout generation.
6. **Layout Mode Toggle**: Add a header switch in the builder UI to transition between Developer and Easy modes.
### Acceptance Criteria
* The visual builder renders and operates correctly on both desktop and mobile screen sizes.
* Toggling between Developer (Tabs) and Easy (Wizard) modes instantly restructures the property configuration controls.
* Output JSON matches the shared serialization schema.
---
## Sprint 3: Local Sync API & Database Caching
### Objectives
Connect the local Ktor server to support local profile caching, enabling layouts to load dynamically from the local database.
### Task List
1. **SQLite Database Update**: Add a `profiles` table to the local `meatbag.db` storing layout ID, layout JSON, and sync timestamp.
2. **Ktor Sync API**: Build the `/api/profiles/sync` POST endpoint to ingest visual configurations from the builder and write them to SQLite.
3. **Local Layout Loader**: Implement lookup logic in the shell client to dynamically resolve and render profiles cached in the local database.
4. **CLI Integration**: Update `meatbag.py ask` to support requesting a layout by ID (e.g. `python meatbag.py ask --profile git_commit`).
### Acceptance Criteria
* Syncing from the visual builder correctly saves the JSON config to the local SQLite database.
* The client shell can load and draw cached layouts on the fly without web browser execution.
* The CLI tool can trigger complex layouts via profile IDs instead of raw text prompts.
---
## Sprint 4: Marketplace Backend & Authentication
### Objectives
Launch the centralized global marketplace server using Ktor and Postgres to handle developer registrations and profile uploads.
### Task List
1. **Marketplace Module Setup**: Initialize the `:marketplace` module in the Gradle configuration.
2. **Postgres Database Schema**: Define database tables using Kotlin Exposed ORM (Users, Developer Profiles, Marketplace Layouts, Purchases).
3. **JWT Authentication**: Integrate `ktor-auth-jwt` to handle user signups, logins, and API token generation.
4. **Upload Endpoint**: Implement `POST /api/v1/marketplace/publish` allowing developers to upload JSON layouts directly from the visual builder.
5. **Search API**: Implement `GET /api/v1/marketplace/profiles` enabling clients to browse available templates.
### Acceptance Criteria
* Users can sign up, log in, and receive secure JWT tokens.
* Developers can publish layouts from the builder directly to the global marketplace database.
* Layout schemas are validated on the server side prior to saving.
---
## Sprint 5: Billing & Stripe Integration
### Objectives
Integrate Stripe Checkout and webhooks to monetize layout profiles on the web store.
### Task List
1. **Stripe SDK Configuration**: Configure the Stripe Java SDK on the marketplace server.
2. **Stripe Checkout API**: Implement `/api/v1/checkout/session` to generate payment links for premium layouts.
3. **Stripe Webhook Listener**: Build `/api/v1/webhooks/stripe` to handle asynchronous charge events and record successful purchases.
4. **Purchase Model**: Implement the purchases verification table in PostgreSQL mapping user accounts to purchased profile IDs.
### Acceptance Criteria
* Tapping purchase on the web store successfully redirects to a secure Stripe Checkout session.
* Completing payment triggers the Stripe Webhook, writing the transaction to Postgres.
* User account instantly gains access to the purchased profile.
---
## Sprint 6: In-App Purchases & Beta Release
### Objectives
Integrate native mobile billing libraries, submit the shells to the public app stores, and launch the platform.
### Task List
1. **Google Play Billing**: Integrate Google Play In-App Purchases into the Android and Wear OS shells.
2. **Apple StoreKit**: Integrate Apple In-App Purchases into the iOS and watchOS shells.
3. **Store Submissions**: Prepare assets, privacy policies, and submit the Lite-Client Shells to the App Store and Google Play.
4. **Beta Launch**: Initiate the marketing campaign for "Meatbag" (the AI-agent confirmation reference app) to drive developer signups.
### Acceptance Criteria
* Purchases made inside the mobile app successfully process via Apple/Google billing.
* Client shells are live and downloadable from the public App Store and Google Play.
* AI agents can successfully route haptic prompts to active smartwatch users globally.

View File

@@ -0,0 +1,90 @@
# WhetForge Product Roadmap: Timeline and Sprints
This document outlines the development timeline and sprint schedules for the WhetForge Lite-Client ecosystem, including the **Meatbag Reference App**, the **Visual Builder**, the **Lite-Client Shells**, and the **Marketplace**.
---
## 1. High-Level Timeline (3-Month Horizon)
```
Month 1: Client Shells & Builder Porting
[======== Phase 1: Shell Optimization & Mobile Builder Responsive Design ========]
Month 2: Ktor Core & Local Sync Architecture
[======== Phase 2: Ktor Local Server & SQLite Sync Logic ========]
Month 3: Marketplace Server & Payments
[======== Phase 3: Stripe Billing, IAP, & Public Launch ========]
```
---
## 2. Phase Breakdown
### Phase 1: Client Shells & Builder (Month 1)
* **Goal**: Polish the Wear OS, watchOS, Android, and iOS stateless client shells and make the visual builder responsive.
* **Key Milestones**:
* Seamless notification companion UI optimized for round watch faces.
* Extracted visual builder logic into `:commonMain` with responsive layout (Tabbed Developer Mode + Wizard Easy Mode).
### Phase 2: Local Server & Sync (Month 2)
* **Goal**: Establish the local routing server as the reliable coordinator.
* **Key Milestones**:
* SQLite schema updates for caching downloaded marketplace layout profiles locally.
* Local Ktor synchronization endpoint (`GET /api/profiles/sync`) configured.
* High-priority push notification bridges (FCM/APNs) setup.
### Phase 3: Marketplace & Launch (Month 3)
* **Goal**: Deploy the public registry, handle monetization, and publish the shells.
* **Key Milestones**:
* Ktor-based Marketplace web app deployed (PostgreSQL + Exposed ORM).
* Stripe Checkout integration for web purchases.
* Google Play Billing & Apple StoreKit integrated into the mobile shells for native IAP checkouts.
* Launch "Meatbag" as the viral first application.
---
## 3. Detailed Sprint Schedule (2-Week Iterations)
### Sprint 1: Native Wearable & Mobile Polish (Current)
* **Focus**: Android S23 Ultra and Wear OS Watch7 interaction optimization.
* **Deliverables**:
* Implement persistent background connection checks.
* Optimize smartwatch Always-On Display (AOD) ambient layout stripping.
* Refactor haptic pattern configurations.
### Sprint 2: Builder Porting & Responsive Design
* **Focus**: Moving the builder to `:commonMain` and making it fit mobile screens.
* **Deliverables**:
* Relocate `UIBuilderScreen` Composable from `:client/desktopMain` to `:client/commonMain`.
* Implement `MobileUIBuilderLayout` (Split Watch Preview + Bottom Sheet).
* Build the toggle switch in the builder header to flip between "Developer Mode" (Tabs) and "Easy Mode" (Wizard).
### Sprint 3: Local Sync API & DB Caching
* **Focus**: Local profile storage.
* **Deliverables**:
* Set up database tables in local Ktor backend for caching profiles (`profiles` table in SQLite).
* Build the local JSON parsing pipeline to dynamically load and display cached layouts in the shell when triggered.
* Connect CLI `meatbag.py ask` to support requesting layout IDs instead of plain text prompts.
### Sprint 4: Marketplace Backend & Auth
* **Focus**: Setting up the global marketplace Ktor server.
* **Deliverables**:
* Initialize the `:marketplace` Ktor module.
* Set up PostgreSQL database models using Exposed ORM (Users, Profiles, Purchases).
* Implement JWT authentication endpoints.
### Sprint 5: Billing & Stripe Integration
* **Focus**: Monetizing the web marketplace.
* **Deliverables**:
* Configure Stripe Java SDK on the marketplace server.
* Implement Stripe Checkout session creation endpoints.
* Build the webhook listener to verify purchases and write them to the PostgreSQL database.
### Sprint 6: In-App Purchases & Beta Release
* **Focus**: Enabling mobile checkouts and launching.
* **Deliverables**:
* Integrate Google Play Billing in the Android shell.
* Integrate Apple StoreKit in the iOS shell.
* Publish the free Lite-Client Shells to the Play Store and App Store.
* Launch the "Meatbag" AI agent marketing campaign.

View File

@@ -55,6 +55,7 @@ kotlin {
dependsOn(wearMain)
dependencies {
implementation("androidx.activity:activity-compose:1.8.2")
implementation(project(":tailscale-wearos-android"))
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -59,17 +60,22 @@ class MainActivity : ComponentActivity() {
}
}
// Setup client
val client = MeatbagClient(
host = "10.0.0.5", // Your PC's local Wi-Fi IP
port = 8080,
attentionFeedback = feedback
)
// Connect client
client.connect(deviceType)
// SharedPreferences for connection details
val prefs = getSharedPreferences("meatbag_prefs", Context.MODE_PRIVATE)
setContent {
var host by remember { mutableStateOf(prefs.getString("host", "10.0.0.5") ?: "10.0.0.5") }
var portStr by remember { mutableStateOf(prefs.getString("port", "8080") ?: "8080") }
var authKey by remember { mutableStateOf(prefs.getString("auth_key", "") ?: "") }
var clientState by remember { mutableStateOf<MeatbagClient?>(null) }
val activePayload = clientState?.currentPayload?.collectAsState()?.value
val connectionState = clientState?.connectionState?.collectAsState()?.value ?: ConnectionState.DISCONNECTED
val scope = rememberCoroutineScope()
val context = this
var errorMessage by remember { mutableStateOf<String?>(null) }
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
@@ -82,10 +88,6 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scope = rememberCoroutineScope()
val activePayload by client.currentPayload.collectAsState()
val connectionState by client.connectionState.collectAsState()
val scrollState = rememberScrollState()
Column(
@@ -93,7 +95,7 @@ class MainActivity : ComponentActivity() {
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp)
.padding(horizontal = 16.dp, vertical = 24.dp)
} else {
Modifier
.fillMaxSize()
@@ -108,12 +110,12 @@ class MainActivity : ComponentActivity() {
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
scope.launch {
client.respond(intent.toApprovalResponse())
clientState?.respond(intent.toApprovalResponse())
}
}
)
} else {
// Status Screen when idle
} else if (connectionState == ConnectionState.CONNECTED) {
// Status Screen when connected and idle
val idlePhrases = remember {
listOf(
"I got this, go do something illogical until I call for you.",
@@ -152,13 +154,13 @@ class MainActivity : ComponentActivity() {
Text(
text = displayText,
color = AccentTeal,
fontSize = if (isWatch) 14.sp else 18.sp,
fontSize = if (isWatch) 13.sp else 18.sp,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
Spacer(modifier = Modifier.height(if (isWatch) 10.dp else 24.dp))
// Action buttons to trigger response sayings
Column(
@@ -179,12 +181,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "How's it going?",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -202,12 +204,12 @@ class MainActivity : ComponentActivity() {
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
.height(if (isWatch) 32.dp else 48.dp)
) {
Text(
text = "Tell me what you're doing",
text = "Tell me what I'm doing",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontSize = if (isWatch) 10.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
@@ -215,27 +217,141 @@ class MainActivity : ComponentActivity() {
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
val statusText = when (connectionState) {
ConnectionState.CONNECTED -> "CONNECTED"
ConnectionState.CONNECTING -> "CONNECTING..."
ConnectionState.DISCONNECTED -> "DISCONNECTED"
}
val statusColor = when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> TextSecondary
ConnectionState.DISCONNECTED -> DangerRed
}
Text(
text = statusText,
color = statusColor,
fontSize = if (isWatch) 12.sp else 16.sp
text = "CONNECTED",
color = SuccessGreen,
fontSize = if (isWatch) 12.sp else 16.sp,
fontWeight = FontWeight.Bold
)
if (!isWatch) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
clientState?.disconnect()
TailscaleUserspace.stop()
clientState = null
},
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 32.dp else 48.dp)
.padding(horizontal = if (isWatch) 8.dp else 24.dp)
) {
Text(
text = "Disconnect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
} else {
// Settings & Connection Panel
Text(
text = "MEATBAG",
color = AccentTeal,
fontSize = if (isWatch) 16.sp else 24.sp,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.h5
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = if (connectionState == ConnectionState.CONNECTING) "CONNECTING..." else "DISCONNECTED",
color = if (connectionState == ConnectionState.CONNECTING) TextSecondary else DangerRed,
fontSize = if (isWatch) 11.sp else 14.sp
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = portStr,
onValueChange = { portStr = it },
label = { Text("Port", fontSize = if (isWatch) 10.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedTextField(
value = authKey,
onValueChange = { authKey = it },
label = { Text("Tailscale Auth Key (optional)", fontSize = if (isWatch) 9.sp else 12.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
errorMessage = null
// Save preferences
prefs.edit().apply {
putString("host", host)
putString("port", portStr)
putString("auth_key", authKey)
apply()
}
scope.launch(Dispatchers.Default) {
var socksPort: Int? = null
if (authKey.isNotBlank()) {
val tsResult = TailscaleUserspace.start(context, authKey.trim())
if (tsResult.isSuccess) {
socksPort = tsResult.getOrNull()
} else {
errorMessage = tsResult.exceptionOrNull()?.message
return@launch
}
}
val newClient = MeatbagClient(
host = host.trim(),
port = portStr.trim().toIntOrNull() ?: 8080,
attentionFeedback = feedback,
socksPort = socksPort
)
clientState = newClient
newClient.connect(deviceType)
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp),
enabled = connectionState == ConnectionState.DISCONNECTED
) {
Text(
text = "Connect",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Bold
)
}
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Host: 10.0.0.5:8080",
color = TextSecondary,
fontSize = 14.sp
text = errorMessage!!,
color = DangerRed,
fontSize = 10.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
}

View File

@@ -0,0 +1,34 @@
package meatbag.client
import android.content.Context
import com.tailscale.wearos.TailscaleConnection
object TailscaleUserspace {
private var runningPort: Int? = null
fun isRunning(): Boolean = runningPort != null
fun getPort(): Int? = runningPort
/**
* Starts the embedded Tailscale proxy in userspace mode.
* Returns the local SOCKS5 proxy port if successful.
*/
fun start(context: Context, authKey: String): Result<Int> {
if (runningPort != null) return Result.success(runningPort!!)
val result = TailscaleConnection.start(context, authKey.trim())
if (result.isSuccess) {
runningPort = result.getOrNull()
}
return result
}
/**
* Stops the running Tailscale proxy
*/
fun stop() {
if (runningPort == null) return
TailscaleConnection.stop()
runningPort = null
}
}

View File

@@ -34,7 +34,8 @@ enum class LogType {
class MeatbagClient(
private val host: String = "localhost",
private val port: Int = 8080,
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback
private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback,
private val socksPort: Int? = null
) {
private val jsonConfig = Json {
ignoreUnknownKeys = true
@@ -42,6 +43,11 @@ class MeatbagClient(
}
private val client = HttpClient(CIO) {
engine {
if (socksPort != null) {
proxy = io.ktor.client.engine.ProxyBuilder.socks(host = "127.0.0.1", port = socksPort)
}
}
install(WebSockets) {
contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig)
}
@@ -167,6 +173,27 @@ class MeatbagClient(
}
}
suspend fun syncProfile(profile: meatbag.common.UserProfile): Boolean {
return try {
log("Syncing profile ${profile.id} to http://$host:$port/api/profiles ...")
val httpResponse = client.post("http://$host:$port/api/profiles") {
contentType(ContentType.Application.Json)
setBody(profile)
}
if (httpResponse.status.isSuccess()) {
log("Profile '${profile.name}' synced successfully!", LogType.SUCCESS)
true
} else {
val body = httpResponse.bodyAsText()
log("Server rejected profile sync: ${httpResponse.status} - $body", LogType.ERROR)
false
}
} catch (e: Exception) {
log("Error syncing profile: ${e.message}", LogType.ERROR)
false
}
}
private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type))

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
@@ -93,6 +95,7 @@ fun main() = application {
@Composable
fun SimulatorApp() {
var activeTab by remember { mutableStateOf(0) }
var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) }
val scope = rememberCoroutineScope()
@@ -152,13 +155,33 @@ fun SimulatorApp() {
}
}
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(modifier = Modifier.fillMaxSize().background(SpaceDark)) {
TabRow(
selectedTabIndex = activeTab,
backgroundColor = SurfaceDark,
contentColor = AccentTeal,
modifier = Modifier.fillMaxWidth().height(48.dp)
) {
Tab(
selected = activeTab == 0,
onClick = { activeTab = 0 },
text = { Text("AGENT SIMULATOR", fontWeight = FontWeight.Bold, letterSpacing = 1.sp) }
)
Tab(
selected = activeTab == 1,
onClick = { activeTab = 1 },
text = { Text("DRAG-AND-DROP UI BUILDER", fontWeight = FontWeight.Bold, letterSpacing = 1.sp) }
)
}
if (activeTab == 0) {
Row(
modifier = Modifier
.fillMaxSize()
.background(SpaceDark)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: CONTROL PANEL & MOCKS
Column(
modifier = Modifier
@@ -654,5 +677,531 @@ fun SimulatorApp() {
}
}
}
} } else {
UIBuilderScreen(client)
}
}
}
@Composable
fun UIBuilderScreen(client: MeatbagClient) {
val scope = rememberCoroutineScope()
var profileId by remember { mutableStateOf("my_custom_profile") }
var profileName by remember { mutableStateOf("My Custom Profile") }
var profileDescription by remember { mutableStateOf("This profile was generated using the visual UI Builder.") }
// Active Primitive Type: 0 = Message, 1 = ApprovalRequest, 2 = ActionList, 3 = TextInput
var activeComponentType by remember { mutableStateOf(0) }
// Component Properties
// Message
var msgId by remember { mutableStateOf("msg_1") }
var msgTitle by remember { mutableStateOf("Alert Notification") }
var msgBody by remember { mutableStateOf("A critical update is available for deployment.") }
var msgTone by remember { mutableStateOf(MessageTone.INFO) }
var msgPrimaryLabel by remember { mutableStateOf("Acknowledge") }
// ApprovalRequest
var appReqId by remember { mutableStateOf("req_1") }
var appReqPrompt by remember { mutableStateOf("Do you want to deploy to staging?") }
var appReqTarget by remember { mutableStateOf("deployment-agent") }
var appReqTitle by remember { mutableStateOf("Staging Deployment Confirmation") }
var appReqBody by remember { mutableStateOf("Proceeding will trigger git merge and deploy.") }
var appReqApproveLabel by remember { mutableStateOf("Approve") }
var appReqRejectLabel by remember { mutableStateOf("Reject") }
// ActionList
var actListId by remember { mutableStateOf("list_1") }
var actListTitle by remember { mutableStateOf("Select Action") }
var actListSubtitle by remember { mutableStateOf("Select an action below to proceed:") }
val actListItems = remember {
mutableStateListOf(
ActionItem("action_a", "Run Database Migration", ActionIntent.CUSTOM),
ActionItem("action_b", "Skip Database Migration", ActionIntent.CUSTOM)
)
}
// TextInput
var txtInputId by remember { mutableStateOf("input_1") }
var txtInputPrompt by remember { mutableStateOf("Enter git commit message:") }
var txtInputPlaceholder by remember { mutableStateOf("e.g. fix: solve database deadlock") }
var txtInputTitle by remember { mutableStateOf("Git Commit Info") }
var txtInputSubmitLabel by remember { mutableStateOf("Submit") }
// Computed Active UiPrimitive
val activePrimitive: UiPrimitive = when (activeComponentType) {
0 -> Message(
id = msgId,
title = msgTitle,
body = msgBody,
tone = msgTone,
primaryAction = if (msgPrimaryLabel.isNotBlank()) ActionItem(msgPrimaryLabel.lowercase().replace(" ", "_"), msgPrimaryLabel, ActionIntent.ACKNOWLEDGE) else null
)
1 -> ApprovalRequest(
id = appReqId,
prompt = appReqPrompt,
target = appReqTarget,
title = appReqTitle,
body = if (appReqBody.isNotBlank()) appReqBody else null,
approveAction = ActionItem("approve", appReqApproveLabel, ActionIntent.APPROVE),
rejectAction = ActionItem("reject", appReqRejectLabel, ActionIntent.REJECT, ActionStyle.DESTRUCTIVE)
)
2 -> ActionList(
id = actListId,
title = actListTitle,
subtitle = if (actListSubtitle.isNotBlank()) actListSubtitle else null,
actions = actListItems.toList()
)
3 -> TextInput(
id = txtInputId,
prompt = txtInputPrompt,
placeholder = txtInputPlaceholder,
title = txtInputTitle,
submitAction = ActionItem("submit", txtInputSubmitLabel, ActionIntent.SUBMIT)
)
else -> error("Invalid component type")
}
// Sync feedback state
var syncMessage by remember { mutableStateOf<String?>(null) }
var syncSuccess by remember { mutableStateOf(true) }
Row(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// COLUMN 1: TOOLBOX (Left)
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.width(300.dp).fillMaxHeight()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"UI Builder Toolbox",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
"Select a primitive type to design and configure:",
color = TextSecondary,
fontSize = 12.sp
)
val componentsList = listOf("Message Card", "Approval Request", "Action List", "Text Input")
componentsList.forEachIndexed { index, name ->
val isSelected = activeComponentType == index
Button(
onClick = { activeComponentType = index },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentTeal else SpaceDark
),
shape = RoundedCornerShape(10.dp),
modifier = Modifier.fillMaxWidth().height(48.dp).border(1.dp, if (isSelected) AccentTeal else Color.White.copy(alpha = 0.05f), RoundedCornerShape(10.dp))
) {
Text(
name,
color = if (isSelected) DeepBg else Color.White,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Profile Configuration Section
Text(
"Profile Settings",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
OutlinedTextField(
value = profileId,
onValueChange = { profileId = it },
label = { Text("Profile ID", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = profileName,
onValueChange = { profileName = it },
label = { Text("Profile Name", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = profileDescription,
onValueChange = { profileDescription = it },
label = { Text("Description", color = TextSecondary, fontSize = 10.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
}
// COLUMN 2: LIVE CANVAS / PREVIEW (Center)
Column(
modifier = Modifier.weight(1.5f).fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Live Renderer Preview Frame
Text(
"LIVE RENDERING CANVAS (SMARTWATCH WRAPPER)",
color = TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
// Watch preview frame
Box(
modifier = Modifier
.size(360.dp)
.clip(CircleShape)
.background(Color.Black)
.border(6.dp, Color(0xFF3A3E52), CircleShape)
.border(10.dp, Brush.sweepGradient(listOf(AccentTeal, AccentPurple, AccentTeal)), CircleShape)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
MeatbagComponentHost(
state = ComponentRenderState(WebSocketPayload(AttentionTier.SILENT_HAPTIC, activePrimitive)),
onIntent = { /* Preview mode, noop actions */ }
)
}
}
// Action / Sync Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = {
val profile = UserProfile(
id = profileId.trim(),
name = profileName.trim(),
description = if (profileDescription.isNotBlank()) profileDescription.trim() else null,
layout = activePrimitive
)
scope.launch {
val success = client.syncProfile(profile)
syncSuccess = success
syncMessage = if (success) {
"Profile '${profile.name}' synced successfully to backend!"
} else {
"Error: Failed to sync profile to server."
}
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f).height(48.dp)
) {
Text("Sync to WhetForge Cloud", color = Color.White, fontWeight = FontWeight.Bold)
}
var showJsonDialog by remember { mutableStateOf(false) }
Button(
onClick = { showJsonDialog = true },
colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f).height(48.dp)
) {
Text("Export Schema JSON", color = Color.White, fontWeight = FontWeight.Bold)
}
if (showJsonDialog) {
val profile = UserProfile(
id = profileId.trim(),
name = profileName.trim(),
description = if (profileDescription.isNotBlank()) profileDescription.trim() else null,
layout = activePrimitive
)
AlertDialog(
onDismissRequest = { showJsonDialog = false },
title = { Text("Serialized JSON Profile", color = Color.White, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = profile.toJson(),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth().height(260.dp),
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White)
)
},
confirmButton = {
Button(onClick = { showJsonDialog = false }) {
Text("Close")
}
},
backgroundColor = SurfaceDark,
contentColor = Color.White
)
}
}
// Sync status alert banner
if (syncMessage != null) {
Card(
backgroundColor = if (syncSuccess) SuccessGreen.copy(alpha = 0.2f) else DangerRed.copy(alpha = 0.2f),
border = BorderStroke(1.dp, if (syncSuccess) SuccessGreen else DangerRed),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(syncMessage!!, color = Color.White, fontSize = 13.sp)
IconButton(
onClick = { syncMessage = null },
modifier = Modifier.size(24.dp)
) {
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
}
}
// COLUMN 3: PROPERTIES INSPECTOR (Right)
Card(
backgroundColor = SurfaceDark,
shape = RoundedCornerShape(16.dp),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)),
modifier = Modifier.width(420.dp).fillMaxHeight()
) {
Column(
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Component Properties",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Divider(color = Color.White.copy(alpha = 0.1f))
when (activeComponentType) {
0 -> { // Message
OutlinedTextField(
value = msgId,
onValueChange = { msgId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = msgTitle,
onValueChange = { msgTitle = it },
label = { Text("Title", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = msgBody,
onValueChange = { msgBody = it },
label = { Text("Body Text", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth(),
maxLines = 4
)
// Tone selection
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Message Tone", color = TextSecondary, fontSize = 12.sp)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
MessageTone.values().forEach { tone ->
val isSelected = msgTone == tone
Button(
onClick = { msgTone = tone },
colors = ButtonDefaults.buttonColors(
backgroundColor = if (isSelected) AccentPurple else SpaceDark
),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.weight(1f).height(32.dp),
contentPadding = PaddingValues(2.dp)
) {
Text(tone.name, color = Color.White, fontSize = 8.sp, fontWeight = FontWeight.Bold)
}
}
}
}
OutlinedTextField(
value = msgPrimaryLabel,
onValueChange = { msgPrimaryLabel = it },
label = { Text("Primary Action Label (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
1 -> { // ApprovalRequest
OutlinedTextField(
value = appReqId,
onValueChange = { appReqId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqTitle,
onValueChange = { appReqTitle = it },
label = { Text("Title Header", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqPrompt,
onValueChange = { appReqPrompt = it },
label = { Text("Urgent Prompt Message", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqBody,
onValueChange = { appReqBody = it },
label = { Text("Body Text Details (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqTarget,
onValueChange = { appReqTarget = it },
label = { Text("Target Agent / Namespace", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqApproveLabel,
onValueChange = { appReqApproveLabel = it },
label = { Text("Approve Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = appReqRejectLabel,
onValueChange = { appReqRejectLabel = it },
label = { Text("Reject Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
2 -> { // ActionList
OutlinedTextField(
value = actListId,
onValueChange = { actListId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = actListTitle,
onValueChange = { actListTitle = it },
label = { Text("Title", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = actListSubtitle,
onValueChange = { actListSubtitle = it },
label = { Text("Subtitle (optional)", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
Text("Action Options", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 13.sp)
actListItems.forEachIndexed { i, item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = item.label,
onValueChange = { newLabel ->
actListItems[i] = item.copy(label = newLabel)
},
label = { Text("Option ${i+1}", color = TextSecondary, fontSize = 9.sp) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.weight(1f)
)
IconButton(
onClick = { actListItems.removeAt(i) },
modifier = Modifier.size(36.dp).padding(top = 10.dp)
) {
Icon(Icons.Default.Delete, contentDescription = "Delete Option", tint = DangerRed)
}
}
}
Button(
onClick = {
val nextIndex = actListItems.size + 1
actListItems.add(ActionItem("action_$nextIndex", "New Action Option $nextIndex", ActionIntent.CUSTOM))
},
colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().height(36.dp).border(1.dp, AccentTeal.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
) {
Text("Add Action Option", color = AccentTeal, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
3 -> { // TextInput
OutlinedTextField(
value = txtInputId,
onValueChange = { txtInputId = it },
label = { Text("Component ID", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputTitle,
onValueChange = { txtInputTitle = it },
label = { Text("Title Header", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputPrompt,
onValueChange = { txtInputPrompt = it },
label = { Text("Text Prompt / Question", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputPlaceholder,
onValueChange = { txtInputPlaceholder = it },
label = { Text("Placeholder Text Hint", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = txtInputSubmitLabel,
onValueChange = { txtInputSubmitLabel = it },
label = { Text("Submit Button Label", color = TextSecondary) },
colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White),
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}

View File

@@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.native.ignoreDisabledTargets=true
android.useAndroidX=true
android.enableJetifier=true
org.gradle.java.home=C:/Users/Owner/.jdks/jbr-21.0.7

2
gradlew vendored
View File

@@ -1,4 +1,4 @@
#!/usr/bin/env sh
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.

View File

@@ -8,6 +8,27 @@ def log(msg):
sys.stderr.write(f"[Meatbag MCP] {msg}\n")
sys.stderr.flush()
import os
def get_api_key():
if os.environ.get("MEATBAG_API_KEY"):
return os.environ.get("MEATBAG_API_KEY")
script_dir = os.path.dirname(os.path.abspath(__file__))
paths = [
os.path.join(script_dir, ".meatbag_key"),
os.path.join(script_dir, "backend", ".meatbag_key"),
os.path.join(script_dir, "..", ".meatbag_key")
]
for path in paths:
if os.path.exists(path):
try:
with open(path, "r") as f:
return f.read().strip()
except Exception:
pass
return None
def handle_request_approval(prompt, target):
log(f"Handling approval request. Target: {target}, Prompt: {prompt}")
request_id = f"mcp_{uuid.uuid4().hex[:8]}"
@@ -17,15 +38,23 @@ def handle_request_approval(prompt, target):
"id": request_id,
"prompt": prompt,
"target": target
}
}
headers = {"Content-Type": "application/json"}
api_key = get_api_key()
if api_key:
headers["X-Meatbag-API-Key"] = api_key
log(f"Using API Key: {api_key[:6]}...")
else:
log("Warning: No API key found.")
try:
url = "http://localhost:8080/api/request?attentionTier=OVERRIDE_SCREEN"
log(f"Sending POST to {url}")
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
headers=headers
)
with urllib.request.urlopen(req) as response:
res = json.loads(response.read().decode("utf-8"))
@@ -35,12 +64,12 @@ def handle_request_approval(prompt, target):
"content": [{"type": "text", "text": f"approved: {approved}"}],
"isError": False
}
except Exception as e:
log(f"Error communicating with Meatbag backend: {str(e)}")
return {
"content": [{"type": "text", "text": f"Error contacting Meatbag backend: {str(e)}"}],
"isError": True
}
except Exception as e:
log(f"Error communicating with Meatbag backend: {str(e)}")
return {
"content": [{"type": "text", "text": f"Error contacting Meatbag backend: {str(e)}"}],
"isError": True
}
def main():
log("Starting Meatbag MCP Server...")

154
meatbag.py Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
import sys
import os
import json
import urllib.request
import urllib.error
import argparse
BACKEND_URL = "http://localhost:8080"
def get_api_key():
if os.environ.get("MEATBAG_API_KEY"):
return os.environ.get("MEATBAG_API_KEY")
paths = [".meatbag_key", "backend/.meatbag_key", "../.meatbag_key"]
for path in paths:
if os.path.exists(path):
try:
with open(path, "r") as f:
return f.read().strip()
except Exception:
pass
return None
def send_request(path, method="GET", payload=None, query_params=None):
url = f"{BACKEND_URL}{path}"
if query_params:
params_str = urllib.parse.urlencode(query_params)
url = f"{url}?{params_str}"
headers = {}
api_key = get_api_key()
if api_key:
headers["X-Meatbag-API-Key"] = api_key
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = application_json = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as response:
res_body = response.read().decode("utf-8")
if not res_body:
return {}
return json.loads(res_body)
except urllib.error.HTTPError as e:
err_msg = e.read().decode("utf-8")
try:
err_json = json.loads(err_msg)
print(f"Error: {err_json.get('error', err_msg)}", file=sys.stderr)
except Exception:
print(f"Error: {e.code} - {err_msg}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Failed to connect to backend at {BACKEND_URL}: {str(e)}", file=sys.stderr)
sys.exit(1)
def handle_ask(args):
payload = {
"type": "ApprovalRequest",
"id": f"cli_{os.urandom(4).hex()}",
"prompt": args.prompt,
"target": args.target
}
query_params = {}
if args.urgent:
query_params["urgent"] = "true"
if args.attention:
query_params["attentionTier"] = args.attention
print(f"Submitting approval request: '{args.prompt}'...", file=sys.stderr)
result = send_request("/api/request", method="POST", payload=payload, query_params=query_params)
approved = result.get("approved", False)
print(f"Response: {'APPROVED' if approved else 'REJECTED'}", file=sys.stderr)
if approved:
sys.exit(0)
else:
sys.exit(1)
def handle_keys(args):
if args.subcommand == "create":
res = send_request("/api/keys", method="POST", query_params={"agentName": args.name})
print(f"Created API key for {args.name}:")
print(res.get("apiKey"))
elif args.subcommand == "list":
keys = send_request("/api/keys")
print(f"{'ID':<40} | {'Agent Name':<20} | {'Status':<10}")
print("-" * 76)
for key in keys:
status = "Revoked" if key.get("revoked") else "Active"
print(f"{key.get('id'):<40} | {key.get('agentName'):<20} | {status:<10}")
elif args.subcommand == "revoke":
res = send_request(f"/api/keys/{args.id}", method="DELETE")
print(res.get("status", "Revoked successfully"))
def handle_devices(args):
if args.subcommand == "list":
devices = send_request("/api/devices")
print(f"{'Device ID':<40} | {'Type':<15} | {'Paired At':<20}")
print("-" * 81)
for dev in devices:
import datetime
paired_date = datetime.datetime.fromtimestamp(dev.get("pairedAtMs") / 1000.0).strftime('%Y-%m-%d %H:%M:%S')
print(f"{dev.get('deviceId'):<40} | {dev.get('deviceType'):<15} | {paired_date:<20}")
elif args.subcommand == "unpair":
res = send_request(f"/api/devices/{args.id}", method="DELETE")
print(res.get("status", "Unpaired successfully"))
def main():
parser = argparse.ArgumentParser(description="meatbag CLI wrapper for human-in-the-loop approvals")
subparsers = parser.add_subparsers(dest="command", required=True)
# ask subcommand
ask_parser = subparsers.add_parser("ask", help="Ask for human approval")
ask_parser.add_argument("prompt", help="The approval prompt text")
ask_parser.add_argument("--target", default="system-agent", help="The target tier/agent identifier")
ask_parser.add_argument("--urgent", action="store_true", help="Mark the request as urgent")
ask_parser.add_argument("--attention", choices=["SILENT_HAPTIC", "BACKGROUND_NOTIFICATION", "OVERRIDE_SCREEN"], help="Specify preferred attention level")
# keys subcommand
keys_parser = subparsers.add_parser("keys", help="Manage API keys")
keys_sub = keys_parser.add_subparsers(dest="subcommand", required=True)
create_key = keys_sub.add_parser("create", help="Create a new API key")
create_key.add_argument("--name", required=True, help="Agent name for the key")
keys_sub.add_parser("list", help="List API keys")
revoke_key = keys_sub.add_parser("revoke", help="Revoke an API key")
revoke_key.add_argument("id", help="The API key ID to revoke")
# devices subcommand
devices_parser = subparsers.add_parser("devices", help="Manage paired client devices")
devices_sub = devices_parser.add_subparsers(dest="subcommand", required=True)
devices_sub.add_parser("list", help="List paired devices")
unpair_device = devices_sub.add_parser("unpair", help="Unpair a device")
unpair_device.add_argument("id", help="The device ID to unpair")
args = parser.parse_args()
if args.command == "ask":
handle_ask(args)
elif args.command == "keys":
handle_keys(args)
elif args.command == "devices":
handle_devices(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
# Platform Assessment: iOS, Smart TVs, and Smart Home Systems
This document analyzes the viability of using Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP) to target iOS, Smart TVs, and Smart Home systems (IoT) for stateless lite-client rendering.
---
## 1. Platform Viability Matrix
| Platform Group | Operating System | Primary Language | Kotlin / CMP Support | Notes |
| :--- | :--- | :--- | :--- | :--- |
| **Phones / Wearables** | iOS / watchOS | Swift / Objective-C | **Excellent** | Compiles to native iOS frameworks using Skia rendering. |
| **Smart TVs** | Android TV / Fire TV | Kotlin / Java | **Excellent** (Native) | Standard Android build targets work out of the box. |
| **Smart TVs** | Samsung Tizen / LG webOS | HTML5 / JS | **Medium** (via Web/Wasm) | Web-based OS. Standard JS/HTML is lighter here. |
| **Apple TV** | tvOS | Swift / Objective-C | **Experimental** | Supported by Kotlin/Native compiler. |
| **Smart Home Hubs** | Home Assistant | Python | **Custom integration** | Extensible via Python scripts or custom Web UI cards. |
| **Microcontrollers** | ESP32 / Arduino / IoT | C / C++ | **No Support** | Require low-level C/C++ or MicroPython. |
---
## 2. Platform Breakdown
### 1. iOS and Apple Watch (watchOS)
* **How Kotlin works on iOS**: Kotlin Multiplatform compiles your shared Kotlin code into a native Objective-C/Swift framework, and Compose Multiplatform renders directly via **Skia/Canvas** (high-performance graphics engine). This means you get a native-looking, high-performance UI on iOS without duplicating code.
* **Apple Watch (watchOS)**: Kotlin/Native natively supports watchOS compilation targets, making it easy to share the database, networking, and state logic with an Apple Watch app.
### 2. Smart TVs (Android TV vs. WebOS/Tizen)
* **Android TV (Sony, Fire TV, Nvidia Shield)**: Android TV is 100% Android. Since Kotlin is the official language of Android, your KMP lite-client APK can run natively on Android TVs with no extra porting required.
* **Web-Based TVs (Samsung Tizen, LG webOS)**: These TVs do **not** run Java/Kotlin. They are lightweight web browsers that run **HTML5/JavaScript** applications. To render on these TVs, you either compile your Kotlin app to WebAssembly (Kotlin/Wasm) or write a small HTML/JS shell that communicates with the Ktor server.
### 3. Smart Home Systems & JVM Hubs
* **JVM-Based Hubs (openHAB, legacy SmartThings)**:
* You are absolutely correct: many major home automation hubs run on the **JVM**.
* **openHAB** (the leading Java-based open-source smart home automation platform) runs entirely on a Java OSGi framework.
* **SmartThings** (Samsung) originally built its entire local device handler and smart-app ecosystem on **Groovy** (a JVM language).
* Because of this, a local **Ktor (Kotlin/JVM) server** is an ideal candidate to act as a custom smart-home hub. It can run continuously on a Raspberry Pi or home server, interface directly with existing Java libraries, and orchestrate signals.
* **Google Home / Nest Hubs**: Many smart displays run on Android (Java/Kotlin native) or Android-based wrappers, making them native deployment targets for our client shells.
* **Connected Devices (ESP32 / Zigbee / Thread)**:
* The actual endpoints (switches, bulbs, sensors) run C/C++ or lightweight micro-firmware.
* These devices do not run business logic; they simply broadcast signals (MQTT, Zigbee, or Matter) to the **local JVM hub (our Ktor server)**.
* The Ktor server processes these signals, matches them to rules, and uses our Server-Driven UI schema to push a control interface to your smartwatch or phone on demand.

View File

@@ -0,0 +1,193 @@
Deep Market Viability Assessment: Server-Driven UI Lite-Client Platform and Model Context Protocol Marketplace
Executive Summary
The enterprise computing landscape is currently experiencing a structural fracture driven by two colliding forces: the exponential rise in the total cost of ownership (TCO) for custom internal software development, and the explosive proliferation of autonomous artificial intelligence (AI) agents requiring secure, human-in-the-loop (HITL) oversight. Organizations are caught between the operational necessity of deploying complex, multi-platform internal tools and the prohibitive financial burden of native application engineering, maintenance, and deployment. The proposed Server-Driven UI (SDUI) "Lite-Client" platform, alongside its integrated marketplace, represents a paradigm-shifting solution to this crisis. By abstracting the presentation layer into generic, stateless rendering shells distributed across watchOS, Wear OS, iOS, Android, and desktop environments, the platform effectively annihilates the traditional software development pipeline.
The market viability of this SDUI platform is uniquely accelerated by the rapid industry standardization of the Model Context Protocol (MCP) and its January 2026 extension, MCP Apps. As the enterprise sector scales its deployment of agentic workflows, the critical bottleneck has shifted from AI reasoning capabilities to human governance. Autonomous agents require authorization for high-stakes actions, yet traditional approval queues introduce massive operational friction. The platform's inaugural reference application, satirically named "Meatbag," perfectly addresses this vulnerability by routing contextual approval packets directly to wearable lite-clients via instantaneous haptic feedback.
Economic modeling indicates a 99% reduction in the three-year TCO when comparing the SDUI platform to native custom client development, shifting expenditure from capital-intensive engineering to highly agile, operational software-as-a-service (SaaS) architecture1. This comprehensive research report exhaustively analyzes the compounding costs of the traditional application lifecycle, the technological landscape of the MCP ecosystem, the burgeoning Internal Developer Tools (IDT) market, precise customer segmentation, and a highly monetizable, multi-phased go-to-market strategy.
Objective A: The Microeconomics and Macroeconomics of Building Custom Clients
The traditional software development lifecycle demands a substantial initial capital expenditure, yet the true, often hidden, financial burden lies in post-launch maintenance. For enterprise organizations, IT departments, and independent developers attempting to build dedicated, platform-specific custom clients—such as a proprietary watchOS server-monitoring dashboard, an iOS remote control application for IoT devices, or a desktop notification utility—the financial arithmetic has become increasingly unsustainable over a three-to-five-year horizon.
The Macroeconomics of Software Maintenance and Technical Debt
Industry consensus and extensive software engineering research consistently dictate that the maintenance lifecycle accounts for 50% to 80% of a product's total lifetime cost of ownership, fundamentally dwarfing the initial engineering investment2. The most widely cited industry benchmark establishes that annual application maintenance costs mandate a budget equating to 15% to 25% of the original development expenditure2. Therefore, a mid-tier internal application requiring an initial investment of $100,000 demands an annual upkeep budget of $15,000 to $25,000 simply to prevent architectural decay3. This perpetual financial drain is rarely driven by the creation of innovative new features; rather, it is necessitated by the non-negotiable requirements of surviving in a shifting digital ecosystem.
Operating systems undergo continuous, mandatory updates, rendering legacy code incompatible and forcing refactoring cycles. Third-party application programming interfaces (APIs) deprecate endpoints, necessitating immediate codebase adjustments to prevent catastrophic application failure. Security vulnerabilities multiply exponentially, requiring continuous patching to maintain regulatory compliance2. The friction introduced by the App Store ecosystem further compounds these operational costs. Beyond the nominal fees associated with Apple developer accounts ($99 annually) or Google Play registrations ($25 one-time), the strict compliance checks, evolving privacy policy updates, and prolonged review cycles require dedicated engineering overhead4. A minor user interface adjustment in a native application—such as altering the color of a button or updating a text field—requires a recompilation of the codebase, a formal resubmission to the respective app stores, and an unpredictable waiting period before the modification reaches the end-user.
Application Complexity Level
Initial Development Cost
Estimated Annual Maintenance (15-25%)
Key Maintenance Drivers
Simple Utility / Internal Tool
$20,000 - $50,000
$3,000 - $12,500
OS updates, minor bug fixes, basic API syncs5
Moderate / Business App
$50,000 - $100,000
$7,500 - $25,000
Third-party integrations, UI refactoring, analytics2
Complex Multi-Platform App
$100,000 - $250,000
$15,000 - $62,500
Real-time features, cross-platform alignment, scaling5
Enterprise / Heavily Regulated
$250,000 - $500,000+
$37,500 - $125,000+
Compliance (HIPAA/SOC2), advanced security, hardware integration4
Developer labor remains the most significant driver of these maintenance costs. While offshore rates in regions like Eastern Europe or India may range from $20 to $80 per hour, North American and Western European talent commands $100 to $250 per hour2. Routine tasks such as OS compatibility updates can consume 40 to 80 hours of senior engineering time, translating to thousands of dollars in labor for a single platform iteration3.
The Extreme Economics of Wearable Application Development
Developing custom applications for wearable operating systems, such as Apple's watchOS and Google's Wear OS, introduces an entirely distinct set of engineering and design constraints that dramatically inflate costs. The global smartwatch market is expanding rapidly, valued at approximately $48.4 billion in 2026 and projected to reach nearly $100 billion by 2033, with Apple's watchOS commanding over 41% of the market share and Wear OS following closely with 33%9. Despite this massive addressable user base of over 1.1 billion wearable users, enterprise and independent developers hesitate to build for the wrist due to the extraordinary development friction12.
Wearable application development cannot be approached as a minimized version of mobile engineering. The ecosystem demands strict adherence to native Human Interface Guidelines (HIG), requiring interfaces that are legible at a glance while minimizing physical interaction13. The underlying hardware imposes severe constraints, notably small screen dimensions, limited processing power, and highly aggressive battery management protocols13. Creating a functional watchOS application requires addressing Always-On Display (AOD) optimizations, which necessitates designing separate low-energy layouts and managing reduced screen refresh rates to prevent rapid battery depletion14.
When background refresh logic, WatchConnectivity protocols, and complex sensor integrations are introduced, engineering timelines stretch significantly14. A simplistic notification companion app built alongside an existing iPhone project typically adds $15,000 to $25,000 to the budget, merely to surface basic alerts14. However, a standalone application leveraging custom UI logic and standalone backend sync capabilities routinely commands budgets between $50,000 and $100,00013. If cross-platform compatibility across both watchOS and Wear OS is required to serve a diverse employee base, the dual-platform strategy adds a multiplier of 30% to 60% to the overall engineering budget, pushing the initial capital expenditure into the six-figure range14.
Wearable App Feature Tier
Typical Scope and Functionality
Estimated Engineering Cost
Notification Companion
Surfaces smartphone notifications, basic static complications, no custom logic.
$15,000 - $25,00014
Standard Native Watch App
Independent UI layer, custom logic, direct cloud API synchronization.
$25,000 - $50,00014
Standalone Advanced App
Real-time data streaming, AI/ML edge inference, complex sensor tracking.
$60,000 - $150,000+8
Quantitative Cost Analysis: Traditional Native vs. Server-Driven UI Shells
To fully grasp the disruptive economic potential of the stateless SDUI lite-client platform, one must contrast its economic footprint against the traditional native development model. The fundamental architecture of the SDUI platform requires the end-user to install the generic shell application from the iOS App Store, Google Play Store, or desktop package manager exactly once. From that moment forward, the client receives visual JSON layout configurations dynamically from a local or cloud host, instantly inflating native UI elements without ever triggering a compiled application update cycle. The underlying business logic, API integrations, and layout definitions remain entirely server-side.
A highly detailed economic model tracking a standard multi-platform enterprise deployment—encompassing iOS, watchOS, Android, Wear OS, Desktop clients, and the required API integration—reveals staggering disparities in the Total Cost of Ownership (TCO) over a 36-month horizon. In a native development scenario, an organization must fund the development of distinct codebases (e.g., Swift for Apple, Kotlin for Android, Electron/React for Desktop), resulting in an estimated 1,600 engineering hours for initial development, supplemented by extensive UI/UX design and App Store setup1.
Conversely, the SDUI platform shifts the burden to configuration rather than compilation. A developer utilizing the platform's Visual Lite-Client Builder can drag and drop interface elements, serialize them into a JSON schema, and serve them to the pre-installed lite-clients in a matter of hours. The initial build time is reduced from hundreds of hours to less than a single business day. Furthermore, the annual maintenance budget for the SDUI model is essentially reduced to lightweight SaaS subscription tiers and network-address-translation (NAT) bypass relay fees, completely excising the crippling labor costs associated with continuous native code refactoring, OS compatibility patches, and certification management1.
Cost Category (36-Month Horizon)
Traditional Native Custom Development
SDUI Lite-Client Platform
Initial Dev (iOS, watchOS, Android, Wear, Desktop)
1,400 Hours @ $120/hr ($168,000)
8 Hours @ $120/hr ($960)1
API Backend Integration
200 Hours @ $120/hr ($24,000)
Handled natively via MCP integration
Design and Prototyping
150 Hours @ $100/hr ($15,000)
4 Hours @ $100/hr ($400)1
App Store Setup & Certification
80 Hours @ $120/hr ($9,600)
Eliminated (Pre-installed public shell)
Initial Capital Expenditure
$216,600.00
$1,360.00
Annual Maintenance (OS Drift, Bug Fixes, CI/CD)
$39,000.00 / Year
$0 (Abstracted by the platform)1
Annual SaaS & Hosting Infrastructure
$3,000.00 / Year
$840.00 / Year (Pro Tier + Relay)1
Total Three-Year TCO
$294,600.00
$3,040.00
Net Operational Savings
Baseline
$291,560.00 (99.0% Cost Reduction)
The 99% cost reduction highlighted in the quantitative analysis is not a statistical anomaly; it is a structural inevitability of shifting the computational and developmental burden away from edge devices. By moving to a paradigm where the UI is merely a dynamic reflection of a server-side JSON file, organizations entirely bypass the compiled-software lifecycle, achieving unprecedented financial and operational agility.
Objective B: Market Viability of a Lite-Client Platform & Marketplace
The technological feasibility of the SDUI platform is supported by profound shifts in the developer tooling landscape, the standardization of AI connectivity protocols, and the proven demand for component marketplaces in analogous software ecosystems.
The Internal Developer Tools (IDT) Landscape
The market for internal developer tools is undergoing aggressive expansion, fueled by the digitization of enterprise operations and the sheer volume of bespoke workflows required by modern corporations. Worldwide enterprise software spending is projected to surpass $1.25 trillion, with a vast proportion allocated to workflow, administration, and departmental tooling17. Historically, organizations faced a binary choice: purchase rigid, off-the-shelf SaaS software that failed to integrate with custom databases, or expend massive engineering capital building custom internal systems. The latter scenario resulted in over 40% of engineering time at large companies being diverted strictly to internal tooling18.
Platforms such as Retool emerged to capture this $250 billion IDT market by providing low-code environments for rapid application development18. However, traditional heavy-weight IDT platforms present their own systemic friction. They frequently suffer from high per-seat licensing costs, rigid vendor lock-in, and interfaces that are primarily constrained to desktop web browsers. Retool, for instance, charges approximately $50 per standard user per month, meaning a 50-person engineering team could pay upwards of $30,000 annually just for platform access before infrastructure costs are calculated19. Furthermore, applications built on these legacy IDT platforms are typically confined to the browser, offering a substandard experience for mobile users and offering zero native support for wearable devices.
The proposed SDUI lite-client platform bypasses these limitations. By offering a lightweight, generic, and cross-platform interface builder that targets everything from desktop CLI tools to smartwatches, the SDUI platform establishes a novel product category: the ubiquitous interaction shell. It democratizes the creation of internal tools, allowing a single developer to build a comprehensive, multi-device dashboard without navigating complex vendor lock-in or per-seat licensing extortion.
The Model Context Protocol (MCP) Revolution
The true catalyst validating the viability of the SDUI platform is the unprecedented standardization of the Model Context Protocol (MCP). Prior to late 2024, connecting artificial intelligence models to external systems—such as databases, ticketing software, file systems, or proprietary APIs—required developers to build bespoke, fragile integrations for every unique model and data source combination20. If a product integrated with N AI models and M external tools, it required N×M separate connectors. This quadratic integration complexity made agentic AI scaling economically impossible for most enterprises20.
Introduced as an open standard by Anthropic in November 2024, MCP functions as the "USB-C for AI," providing a universal, bidirectional interface using JSON-RPC 2.020. The protocol clearly delineates between the AI model (the MCP client) and external tools/data (the MCP servers), standardizing how capabilities are discovered and executed21. Rather than writing custom integration code, an AI agent simply queries the MCP server for a manifest of available tools, resources, and prompts, and subsequently invokes them using a standardized JSON schema23.
The adoption curve of MCP has been historic. By March 2026, the protocol achieved 97 million monthly SDK downloads, matching the scale that foundational web frameworks like npm and REST APIs required years to reach26. The protocol has been universally adopted by the foundational pillars of the AI industry, including OpenAI, Google DeepMind, Microsoft Copilot, and Amazon Web Services22. Furthermore, enterprise deployment has been explosive, with 28% of Fortune 500 companies actively implementing MCP servers in production workflows across fintech, healthcare, and e-commerce22.
MCP Apps: Elevating the Presentation Layer
While the base MCP specification revolutionized backend connectivity by standardizing the flow of text and structured data, it inherently lacked presentation layer capabilities. This critical limitation was resolved on January 26, 2026, with the official launch of MCP Apps, the first major extension to the protocol28. MCP Apps fundamentally alters how users interact with AI by allowing third-party tools to return rich, interactive UI components directly into the chat window or agent environment, moving beyond simple text-based responses28.
Under the MCP Apps specification, an MCP server can declare a UI capability by including a _meta.ui.resourceUri field in its tool schema28. When an AI agent invokes this tool, the host client fetches the specified UI resource (typically bundled HTML and JavaScript) and renders it within a sandboxed iframe28. Security is strictly maintained through multiple defensive layers: the iframe cannot make arbitrary network requests, the host can review the HTML template before rendering, and all communication between the rendered interface and the AI host occurs securely via structured JSON-RPC messages over a postMessage channel28. This architecture prevents dynamic code injection and ensures strict auditability.
The proposed SDUI Lite-Client Platform sits at the exact apex of this technological breakthrough. The platform's native support for MCP schemas means that its Visual Lite-Client Builder can automatically translate local tools and endpoints into native UI layouts. Because the SDUI platform outputs configurations in serialized JSON, it seamlessly acts as the presentation layer for the massive ecosystem of newly minted MCP servers. Rather than forcing a backend Python developer to hand-code complex HTML/JS for an MCP App, the visual builder allows them to drag and drop a dashboard, serialize it, and immediately serve it via their MCP server to any compliant AI host or standalone lite-client. This interoperability bridges the gap between raw data execution and human interaction, establishing the SDUI platform as critical infrastructure for the agentic web.
The Marketplace: Analogous Ecosystems and Transaction Volume
The SDUI platform is intrinsically linked to a public registry and marketplace where developers can publish, share, and monetize their custom UI layouts. The economic viability and potential transaction volume of this marketplace are strongly supported by analog ecosystems in the developer and smart-home spaces.
The Home Assistant and Grafana communities demonstrate massive organic demand for shared configuration profiles. In the Home Assistant ecosystem, users actively trade complex YAML blueprints and dashboard configurations. Repositories featuring pre-built automations for specific hardware (e.g., interactive push notifications, energy monitoring layouts) see massive adoption because they allow non-expert users to bypass hours of templating and conditional logic31. Similarly, the Grafana community relies heavily on sharing JSON dashboard exports, allowing system administrators to instantly monitor complex infrastructure without building visualizations from scratch32.
By creating a centralized marketplace for SDUI layouts, the platform capitalizes on the exact same psychological driver: the intense desire for immediate, functional aesthetics without the labor of design. As the MCP ecosystem scales, developers building custom MCP servers for local scripts, IoT bridges, and enterprise databases will require interfaces. The marketplace allows a backend engineer who has written a powerful automation script to purchase and download a highly polished, visually stunning watchOS and desktop layout for a nominal fee, bypassing UI development entirely. Given that there are currently over 10,000 active public MCP servers, and that API integration endpoints number in the millions globally, the potential transaction volume for a standardized UI layout registry is immense22.
Objective C: Customer Profiles, GTM, and the HITL Imperative
The adoption of a generic, multi-platform SDUI shell requires targeting segments where the pain of UI development heavily outweighs the need for bespoke, pixel-perfect branding. The go-to-market strategy must leverage a specific, high-friction problem in the AI space to drive initial adoption.
The Human-in-the-Loop (HITL) Imperative and the "Meatbag" Reference App
As enterprise adoption of agentic AI accelerates, organizations confront a critical governance challenge. Autonomous agents are highly capable, but allowing them to execute high-stakes, irreversible actions without human oversight is a profound compliance and security risk. Typical triggers that demand oversight include destructive actions (deleting databases), external communications (sending client emails), financial transactions (processing refunds), and access to regulated datasets protected by frameworks such as GxP or HIPAA35.
To mitigate these risks, systems must incorporate Human-in-the-Loop (HITL) approval gates. However, traditional approval queues are notoriously slow, creating friction that negates the speed advantages of AI automation. When a human reviewer is summoned to approve an agent's action, they cannot simply be presented with a binary "approve/deny" prompt. Doing so risks turning the human into a blind "rubber stamp." Instead, they require a comprehensive "review context packet" that instantly answers four critical questions: what the agent is attempting to do, the reasoning behind the decision, the potential blast radius (risks), and the available alternatives35.
This operational dynamic perfectly validates the platform's first reference application, "Meatbag." Designed as a satirical yet highly functional confirmation-routing tool, "Meatbag" leverages the SDUI smartwatch lite-client to bridge the gap between autonomous execution and human oversight. When an AI agent reaches a critical threshold, it pushes an approval request via the MCP framework. The lite-client instantly inflates a native UI on the user's Apple Watch or Wear OS device. The user receives a distinct haptic tap, raises their wrist, and is presented with a perfectly formatted, digestible context packet detailing the agent's intent and parameters35. With a single tap, the user can approve or deny the action, instantly returning the cryptographic signal back to the agent's execution loop. By moving the approval gate from a buried email inbox directly to the wrist, "Meatbag" transforms a high-friction compliance hurdle into a seamless operational reflex.
Market Segment Analysis and Customer Profiles
The market bifurcates into three primary consumer profiles, each presenting distinct use cases and varying degrees of willingness to pay (WTP).
Market Segment
Core Profile & Pain Points
Primary Use Cases
Estimated Willingness to Pay (WTP)
Enterprise AI Architects & InfoSec
Focused on governance, compliance, and multi-agent orchestration. Blocked by the lack of secure, auditable HITL approval flows.
GxP/HIPAA compliant approval gates, financial transaction authorization, secure data access reviews.
High: $50 - $200 per agent/seat monthly. Premium for SSO, audit logs, and on-premise relay hosting.
DevOps, Sysadmins & IT Ops
Overwhelmed by disparate CLI tools, monitoring alerts, and script execution. Lack the time or desire to write frontend UI code.
PagerDuty alternatives, server reboot toggles, CI/CD pipeline triggers, Grafana alert visualization.
Medium: $15 - $30 monthly. Willing to pay for reliable push notifications, NAT-bypassing, and pre-built templates.
IoT Hobbyists & Prosumers
Highly technical tinkerers using Home Assistant, Raspberry Pi, and local LLMs. Value extreme customization and fast iteration.
Smart home controls, custom wearable fitness dashboards, local automation monitoring.
Low / Freemium: $0 - $5 monthly. Demand open-source access but will pay micro-transactions for premium marketplace layouts.
Segment 1: Enterprise AI Architects
As the Fortune 500 rapidly integrates MCP servers, the enterprise sector requires immediate solutions for agent governance. This segment possesses the highest willingness to pay because the SDUI platform directly solves a compliance bottleneck. They do not view the platform merely as a UI builder, but as an audit and authorization layer. The "Meatbag" app demonstrates to this segment that they can deploy a unified, secure approval client to their executives' smartwatches and phones without engaging in a costly internal app development cycle.
Segment 2: DevOps and System Administrators
This segment lives in terminal windows and backend environments. When a server goes down or a deployment pipeline fails, they require immediate notification and a fast mechanism to execute a remedial script. Building a custom iOS app to trigger a webhook is an absurd waste of engineering resources. The SDUI lite-client allows a sysadmin to drag and drop a few buttons, bind them to a local bash script via MCP, and instantly possess a native mobile and watch app to manage their infrastructure. Their willingness to pay is tied directly to reliability, specifically the platform's ability to maintain a persistent, secure connection between the mobile shell and their internal network.
Segment 3: IoT Hobbyists and Prosumers
While monetarily the least lucrative on a per-capita basis, this segment is the vital engine of the marketplace. Similar to the vibrant communities surrounding Home Assistant blueprints, these users will rapidly populate the marketplace with thousands of creative, highly specific UI layouts. They will build interfaces for custom hydroponic monitors, local Plex servers, and niche robotic projects. By offering them a generous free tier for the base shell, the platform harnesses their labor to build the inventory of the marketplace, driving organic SEO and establishing the platform as the default standard for stateless UI.
Go-To-Market (GTM) and Monetization Strategy
To successfully capture market share and establish the SDUI JSON schema as the industry standard, the platform must execute a phased Go-To-Market strategy that leverages viral consumer adoption to penetrate the highly lucrative enterprise layer.
Phase 1: The "Meatbag" Trojan Horse
The launch of a generic "UI builder" risks being ignored in a crowded developer tool market. Therefore, the GTM strategy must lead with the reference application. "Meatbag" should be launched as a standalone, highly polished SaaS product targeted directly at developers currently building AI agents. Positioned with an edgy, satirical brand identity—highlighting the irony of highly advanced AI models waiting on slow, biological humans to tap a smartwatch screen—the application serves as a viral marketing tool.
When developers adopt "Meatbag" to solve their immediate HITL approval bottlenecks, they are unknowingly installing the SDUI Lite-Client shell on their devices. Once the user base experiences the zero-latency, dynamically rendered interface on their watch and phone, the platform reveals its true nature: "Meatbag" is just one layout. The user is then invited to open the visual builder and modify the layout, instantly pivoting them from consumer to creator.
Phase 2: The Marketplace and Open Registry Launch
Once a critical mass of developers has installed the lite-client shell, the visual builder and marketplace are fully opened. The GTM motion shifts to community building. Hackathons and bounty programs should be established to incentivize the creation of SDUI layouts for popular existing open-source tools (e.g., Docker management interfaces, Home Assistant dashboards, GitHub action runners). The marketplace operates analogously to npm or DockerHub—a central registry where the friction of discovery is eliminated.
Recommended Monetization Models
The financial architecture of the platform must support the dual nature of its user base: grassroots developers driving adoption, and enterprise clients requiring rigorous infrastructure. The monetization strategy is multifaceted, rejecting the restrictive Bring-Your-Own-API-Key (BYOK) model in favor of dynamic aggregation and infrastructure hosting39.
Transactional Marketplace Fees: The public registry allows creators to list premium, highly polished UI layouts for sale. The platform extracts a standard 20% to 30% transaction fee on all marketplace volume. Because the asset being traded is purely serialized JSON, the platform's overhead for hosting and delivering these assets is near zero, resulting in exceptionally high profit margins on transaction fees.
Developer Subscription Tiers (The Relay Fee): While the local lite-client shell and basic visual builder remain free, the platform monetizes the network transport layer. For developers who wish to connect their mobile or wearable lite-client to a local script behind a home router or corporate firewall, the platform offers a managed NAT-bypassing secure relay service. A $5 to $15 monthly subscription provides end-to-end encrypted tunneling, ensuring the lite-client can fetch its layouts and execute commands from anywhere in the world without requiring the user to configure dynamic DNS or port forwarding.
Enterprise Governance and API Gateway Integration: The primary revenue engine relies on enterprise deployment. Organizations deploying fleets of AI agents require centralized oversight. The platform monetizes via a per-seat or per-agent enterprise license ($50+ per month). This tier unlocks critical enterprise features: Single Sign-On (SSO), integration with existing API gateways (such as Kong or Moesif) for rate limiting and traffic analytics, and immutable audit logs that record precisely which human approved which agentic action39.
Agentic Commerce (UCP) Royalties: Looking toward late 2026, the platform must align with the Universal Commerce Protocol (UCP) introduced by Google and major commerce partners39. As AI agents begin autonomously traversing the web to execute transactions, MCP servers will act as nodes in a massive commerce graph. By ensuring that the SDUI platform is the default presentation layer for these commercial MCP interactions, the platform can position itself to extract fractional royalties or referral fees from autonomous, agent-driven commerce, fundamentally bridging the gap between human intent and machine execution.
Conclusion
The market viability for a generic, MCP-native Server-Driven UI Lite-Client platform and marketplace is exceptionally strong. The traditional paradigm of building, compiling, and maintaining native applications for internal tools has reached a breaking point, crippled by an unsustainable Total Cost of Ownership and immense engineering friction. By abstracting the presentation layer into a perpetually installed, stateless shell driven by serialized JSON configurations, organizations achieve a near 99% reduction in structural maintenance costs.
Furthermore, the timing of the product launch perfectly intersects with the explosive adoption of the Model Context Protocol (MCP) and the critical enterprise requirement for Human-in-the-Loop oversight in agentic workflows. By deploying the "Meatbag" application as a viral wedge to distribute the client shell via smartwatches, and subsequently opening a centralized marketplace for UI components, the platform creates a self-sustaining ecosystem. It successfully transforms user interface development from an engineering bottleneck into a fluid, tradable commodity, establishing a highly lucrative and defensible position at the frontier of the autonomous AI economy.
Works cited
unknown_url
Software Maintenance Costs 2026: Complete Pricing Guide - ADEVS, https://adevs.com/blog/software-maintenance-costs/
App Maintenance Costs in 2026: What to Budget - CatDoes, https://catdoes.com/blog/app-maintenance-costs
How Much Does It Cost to Maintain an App in 2026? - HourlyDeveloper, https://hourlydeveloper.io/blog/cost-to-maintain-app
Mobile App Maintenance Costs: What to Expect After Launch - Closeloop Technologies, https://closeloop.com/blog/mobile-app-maintenance-cost-after-launch/
How Much Does It Cost to Maintain an App in 2026? | App Maintenance Costs Explained - Stormotion, https://stormotion.io/blog/app-maintenance-cost/
Beyond the Budget: Hidden Costs in Mobile App Development | Net-Craft.com, https://www.net-craft.com/blog/2025/10/05/costs-mobile-app-development/
Wearable App Development Cost Breakdown: 2025 Enterprise Guide, https://www.cisin.com/coffee-break/how-much-does-it-cost-for-developing-a-wearable-application.html
Smartwatches Market | Global Market Analysis Report - 2035 - Fact.MR, https://www.factmr.com/report/195/smart-watches-market
Smartwatch Market Size, Trends & Leading Players, 2033 - Persistence Market Research, https://www.persistencemarketresearch.com/market-research/smart-watch-market.asp
Digital Watches Market Research Report 2034 - Dataintelo, https://dataintelo.com/report/global-digital-watches-market
Apps for Wearables: A Guide for Businesses in 2026 - Arch, https://wearearch.com/blog/apps-for-wearables
Wearable App Development Costs, Platforms & AI - AppZoro, https://appzoro.com/blog/wearable-app-development-guide
Apple Watch App Development Cost (2026), https://applefy.tech/blog/apple-watch-app-development-cost
Wearable App Development Cost Breakdown Guide - Appinventiv, https://appinventiv.com/blog/wearable-app-development-cost/
Wearable App Development Cost: $15K-$120K Guide (2026) - Groovy Web, https://www.groovyweb.co/blog/wearable-app-development-cost-2026
The Vertical Internal-Tool Market Is About to Restructure | MindStudio, https://www.mindstudio.ai/blog/vertical-internal-tools-restructure
Report: Retool Business Breakdown & Founding Story | Contrary Research, https://research.contrary.com/company/retool
Retool Alternative: Build Internal Tools Without Lock-in (2026) - DesignRevision, https://designrevision.com/blog/retool-alternative
The MCP Revolution: What Model Context Protocol Means for SaaS Products and Startups in 2026 - Insights - Advisable, https://www.advisable.com/insights/the-mcp-revolution-what-model-context-protocol-means-for-saas-products-and-startups-in-2026
Model Context Protocol - Wikipedia, https://en.wikipedia.org/wiki/Model_Context_Protocol
Model Context Protocol for Enterprise: 2026 Deployment Guide - Synvestable, https://www.synvestable.com/model-context-protocol.html
MCP Explained: What Is the Model Context Protocol? - Domo, https://www.domo.com/glossary/model-context-protocol
What is MCP? Model context protocol for LLMs - Sysdig, https://www.sysdig.com/learn-cloud-native/what-is-mcp-model-context-protocol
Model Context Protocol (MCP) Explained - Humanloop, https://humanloop.com/blog/mcp
MCP Hits 97M Downloads: Model Context Protocol Guide - Digital Applied, https://www.digitalapplied.com/blog/mcp-97-million-downloads-model-context-protocol-mainstream
MCP Ecosystem Map: Companies Adopting Model Context Protocol - Everything-PR, https://everything-pr.com/model-context-protocol-ecosystem
MCP Apps are here: Rendering interactive UIs in AI clients - WorkOS, https://workos.com/blog/2026-01-27-mcp-apps
MCP Apps - Bringing UI Capabilities To MCP Clients, https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/
How MCP Apps Will Forever Change Consumer Behavior, https://mcpmanager.ai/blog/mcp-apps/
Maneridiet's Home Assistant Blueprints & Dashboards - GitHub, https://github.com/Maneridiet/home-assistant-blueprints
Share dashboards and panels | Grafana documentation, https://grafana.com/docs/grafana/latest/visualizations/dashboards/share-dashboards-panels/
Home Assistant dashboard: Grafana integration | vd Brink Home Automations, https://vdbrink.github.io/homeassistant/homeassistant_dashboard_grafana.html
How To Integrate Home Assistant with Grafana : r/homeassistant - Reddit, https://www.reddit.com/r/homeassistant/comments/1i9k8i7/how_to_integrate_home_assistant_with_grafana/
Human-in-the-Loop AI Agents: When Approval Gates Matter - CreateOS, https://createos.sh/blogs/human-in-the-loop-ai-agents
Human-in-the-loop constructs for agentic workflows in healthcare and life sciences - AWS, https://aws.amazon.com/blogs/machine-learning/human-in-the-loop-constructs-for-agentic-workflows-in-healthcare-and-life-sciences/
Human-in-the-loop for tools | Build - n8n Docs, https://docs.n8n.io/build/integrate-ai/ai-examples/human-in-the-loop-for-tools
Human-in-the-Loop with Next.js - AI SDK, https://ai-sdk.dev/cookbook/next/human-in-the-loop
https://medium.com/mcp-server/the-rise-of-mcp-protocol-adoption-in-2026-and-emerging-monetization-models-cb03438e985c

View File

@@ -0,0 +1,40 @@
# Gemini Deep Research Prompt: Lite-Client Platform & Marketplace
Copy and paste the prompt below into Gemini Deep Research (or your preferred research AI) to generate a complete market viability and cost-assessment report.
---
```text
Please perform a deep, comprehensive research and market viability assessment for a new developer platform and marketplace.
### 1. Product Context
The product is a generic Server-Driven UI (SDUI) "Lite-Client" platform and marketplace. It consists of:
- **Stateless Lite-Client Shells**: Lightweight, unified client applications running on Wear OS (smartwatch), watchOS, iOS, Android, and Desktop. These shells contain no business logic; they simply connect to a local/cloud host, fetch a visual JSON layout configuration, and dynamically inflate native UI elements.
- **Visual Lite-Client Builder**: A drag-and-drop editor allowing creators to design visual dashboards, remote controllers, and notification systems, which are serialized directly into the platform's JSON schemas.
- **MCP Integration**: Native support for Model Context Protocol (MCP) schemas, allowing the visual builder to automatically translate local tools/endpoints into native UI layouts.
- **[The Marketplace]**: A public registry where developers publish, share, or monetize their custom UI layouts and configurations (lite-clients) for various scripts, IoT tools, local servers, and dashboards.
- **First Reference App ("Meatbag")**: The first application built on the platform—a satirical, edgy confirmation-routing tool specifically designed for AI agents requesting human approval via smartwatch haptics.
### 2. Core Research Objectives
Please research and analyze the following areas:
#### Objective A: The Cost of Building Custom Clients
- Evaluate the standard development, maintenance, and deployment costs (in engineering hours, developer salaries, and App Store lifecycle overhead) for an organization or developer to build dedicated, platform-specific custom client apps (such as a custom Apple Watch dashboard, a dedicated remote-control phone app, or a desktop notification utility) for their internal systems.
- Compare this against the cost and time savings of using a single, pre-installed "stateless rendering shell" that consumes a server-driven JSON config generated via drag-and-drop.
#### Objective B: Market Viability of a Lite-Client Platform & Marketplace
- Analyze the current developer tooling and DevOps landscape. Is there a demand for a lightweight, generic, drag-and-drop interface builder for local scripts, APIs, and CLI tools?
- Evaluate the adoption of MCP (Model Context Protocol). How well does an MCP-native drag-and-drop builder integrate with the current wave of developer interest in AI and local tool execution?
- Assess the market size and potential transaction volume of a marketplace dedicated to sharing and licensing these stateless layout profiles.
#### Objective C: Customer Profiles & Go-To-Market (GTM)
- Identify the core customer segments (e.g., IoT hobbyists, DevOps teams, sysadmins, AI application developers) most likely to adopt a generic lite-client platform.
- Recommend the best monetization models for the marketplace (e.g. flat transactional fees, developer subscription tiers, hosting/syncing fees).
### 3. Deliverables Required in the Output
1. **Executive Summary**: Market viability rating of a generic, MCP-native lite-client platform and marketplace.
2. **Quantitative Cost Analysis**: A comparison matrix of traditional native client development vs. the Server-Driven UI shell.
3. **Market Segment Analysis**: Target audience profiles, use cases, and estimated Willingness to Pay (WTP).
4. **GTM & Monetization Strategy**: Recommended business model and launch plan.
```

View File

@@ -0,0 +1,46 @@
# Product Design: The MCP Constraint Builder (Standalone)
This document outlines the product design and system architecture for a standalone security product: **The MCP Constraint Builder**. This tool acts as an auditing and policy-enforcement layer for AI agents, allowing users to visually configure execution policies and parameters for third-party MCP servers.
---
## 1. Core Concept
The MCP Constraint Builder is a **security firewall and configuration manager** for local AI agents. It intercepts JSON-RPC requests sent by AI clients (such as Claude, Cursor, or local coding assistants) to local MCP servers.
```
+------------+ JSON-RPC +------------------------+ JSON-RPC +------------------+
| AI Client | -----------------> | MCP Policy Proxy | -----------------> | Target MCP Server|
| (Claude/etc| | (Enforces rules.json) | | (Proprietary) |
+------------+ +-----------+------------+ +------------------+
|
| Reads policies
v
+------------------------+
| rules.json |
+------------------------+
```
---
## 2. Key Features
### Visual Constraint Editor
A desktop application that allows users to import any standard MCP server configuration. The tool inspects the server's available tools (`list_tools`) and dynamically renders a visual configuration interface:
* **Allow/Deny lists**: Explicitly toggle which tools (e.g. `run_command`, `read_file`) the agent is allowed to access.
* **Parameter boundaries**: Set regex patterns or value ranges for arguments (e.g. "Only allow `read_file` if the path starts with `C:/Users/Owner/workspace`").
* **Execution budget**: Set rate limits (e.g. "maximum 10 tool calls per minute").
### Proxy Firewall (JSON-RPC Middleware)
A lightweight background proxy runner. When the AI agent attempts to execute a tool, the proxy checks the arguments against `rules.json`:
* **Pass**: If within bounds, forwards the request directly to the target server.
* **Block**: If out of bounds, immediately returns an authorization error back to the AI client without executing the command.
* **Escalate**: If marked for manual verification, prompts the user on their host machine for real-time bypass approval.
---
## 3. Benefits
1. **Enterprise Compliance**: Solves the data protection and code execution trust gap for running local agents.
2. **IP Protection**: Allows creators of advanced AI agents to sell compiled/obfuscated MCP binaries while giving users full visibility and boundary control.
3. **Ecosystem Agnostic**: Works with any standard MCP client and server protocol without requiring code changes to either.

View File

@@ -0,0 +1,37 @@
# Feasibility Assessment: Remaking Lite-Client Shells in React Native
This document evaluates the complexity and trade-offs of rebuilding the WhetForge stateless "lite-client" shells using **React Native** instead of **Compose Multiplatform (CMP)**.
---
## 1. Feasibility Matrix
| Platform | Compose Multiplatform (CMP) | React Native (RN) | React Native Complexity |
| :--- | :--- | :--- | :--- |
| **Android Phone** | Native Support | Native Support | **Easy** (First-class) |
| **iOS Phone** | Native Support | Native Support | **Easy** (First-class) |
| **Desktop (Win/Mac)** | Native Support (JVM) | Supported (RN Windows/macOS) | **Medium** (Slightly heavier, Microsoft-maintained) |
| **Wear OS Watch** | **Excellent** (Compose for Wear OS) | **Very Poor** (No official support) | **Hard** (Requires custom native Android wrapping) |
| **Apple watchOS** | Planned | **Very Poor** (No official support) | **Hard** (Requires custom native watchOS extension wrapping) |
---
## 2. Platform-Specific Analysis
### 1. Smartwatches (The Core Wearable Challenge)
* **Compose Multiplatform**: Google provides a dedicated, optimized library: **Compose for Wear OS**. It provides out-of-the-box widgets designed for round watches (scaling, circular lists, rotary input scrolling).
* **React Native**: React Native does not natively compile or lay out for watchOS or Wear OS. While you can hack an RN app to run inside an Android Wear OS wrapper, the performance is poor, layout calculations do not adapt well to circular screens, and touch targets are difficult to optimize.
### 2. Network & VPN Integration (Tailscale Userspace)
* **Compose Multiplatform**: Integrating low-level networking (like our Go-compiled userspace Tailscale proxy) is highly native. We call JVM/JNI Go bindings directly in Kotlin.
* **React Native**: JavaScript cannot directly interface with JNI/Go binaries. We would have to write custom **Native Modules** (in Kotlin for Android and Swift for iOS) to manage the VPN lifecycle and forward events to the JavaScript thread.
### 3. Server-Driven UI (SDUI) Inflation
* **React Native (Winner)**: React Native is exceptionally good at SDUI. Because JavaScript is highly dynamic, parsing a JSON payload and dynamically mapping it to a dictionary of visual primitives (`<View>`, `<Text>`, `<Button>`) is extremely natural and fast.
---
## 3. Conclusion & Recommendation
* **If Phones & Desktops are the primary targets**: Remaking the shells in React Native is highly viable. It would open the ecosystem to web/JS developers and make SDUI rendering extremely dynamic.
* **If Smartwatches (Wear OS / watchOS) are a core differentiator**: **Stick with Compose Multiplatform**. The development overhead of trying to force React Native onto watch screens and maintaining custom Swift/Kotlin haptic wrappers outweighs any benefits of JavaScript development.

View File

@@ -0,0 +1,44 @@
param(
[string]$Prompt,
[string]$Target = "system-agent"
)
$ErrorActionPreference = "Stop"
# Read API key
if (Test-Path ".meatbag_key") {
$apiKey = (Get-Content ".meatbag_key").Trim()
} else {
Write-Error "No .meatbag_key file found!"
exit 1
}
# Create request payload
$requestId = "cmd_" + [guid]::NewGuid().ToString().Substring(0, 8)
$body = @{
type = "ApprovalRequest"
id = $requestId
prompt = $Prompt
target = $Target
} | ConvertTo-Json
# Send request to Ktor server and wait for response
Write-Host "Sending watch approval request: '$Prompt' (target: $Target)..."
try {
$response = Invoke-RestMethod -Uri "http://localhost:8080/api/request?attentionTier=OVERRIDE_SCREEN" `
-Method Post `
-Headers @{ "X-Meatbag-API-Key" = $apiKey } `
-ContentType "application/json; charset=utf-8" `
-Body $body
$approved = $response.approved
Write-Host "Response received: approved=$approved"
if ($approved -eq $true) {
exit 0
} else {
exit 1
}
} catch {
Write-Error "Failed to request approval: $_"
exit 1
}

View File

@@ -18,3 +18,7 @@ rootProject.name = "meatbag"
include(":common")
include(":backend")
include(":client")
include(":tailscale-wearos-android")
project(":tailscale-wearos-android").projectDir = file("../tailscale-wearos/android")