AI-first TypeScript SDK for dynamic WebView development on Android & iOS.
aphtml lets an LLM agent (or a human developer) rapidly create, update, search, and reuse HTML pages and CSS/JS modules inside mobile WebViews — all through a simple tool-calling interface.
Born from the same philosophy as sfhtml (single-file HTML AI-skill CLI), but designed specifically for mobile WebView and LLM tool-calling instead of desktop CLI usage.
| Problem | aphtml solution |
|---|---|
| sfhtml is desktop-only (Rust CLI) | Pure TypeScript SDK, works anywhere |
| AI can't reuse existing CSS/JS on mobile | Module registry with AI-readable headers |
| LLMs need structured interfaces | 23 MCP/OpenAI-compatible tool schemas |
| Native bridge varies (RN, Capacitor, iOS, Android) | Pluggable BridgeAdapter interface |
| Storage varies per platform | Pluggable StorageAdapter interface |
| No undo / version history for pages | Git-style version history with branch support |
| Page WebView can't call back to instance | Bridge RPC Proxy — zero-token bidirectional channel |
| Full HTML output wastes tokens on small edits | Unified diff updates — AI sends only changed lines |
npm install aphtmlimport { ApHtml, MemoryStorage } from "aphtml";
import { MyBridge } from "./my-bridge"; // your BridgeAdapter implementation
const ap = new ApHtml({
storage: new MemoryStorage(),
bridge: new MyBridge(),
});
// 1. Register a reusable CSS module
await ap.exec("create_module", {
name: "theme",
type: "css",
code: `:root { --color-primary: #6200ee; --color-surface: #fff; }`,
purpose: "Global color tokens",
exports: ["--color-primary", "--color-surface"],
tags: ["theme", "colors"],
});
// 2. Create a page that uses it
await ap.exec("create_page", {
name: "home",
html: `<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="theme.css">
</head>
<body>
<h1 style="color: var(--color-primary)">Hello!</h1>
</body>
</html>`,
summary: "Home screen",
modules: ["theme.css"],
show: true, // immediately display in WebView
});Get MCP/OpenAI-compatible tool schemas:
const tools = ap.getTools(); // 23 tool definitions
// Execute a tool call from an LLM:
const result = await ap.exec(toolName, toolArgs);
// result: { ok: boolean, data?: unknown, error?: string }import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const server = new Server({ name: "aphtml", version: "0.2.1" }, { capabilities: { tools: {} } });
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const result = await ap.exec(req.params.name, req.params.arguments ?? {});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
});const completion = await openai.chat.completions.create({
model: "gpt-4o",
tools: ap.getTools().map((t) => ({ type: "function", function: t })),
messages: [{ role: "user", content: "Create a login page with our company theme" }],
});
for (const call of completion.choices[0].message.tool_calls ?? []) {
const result = await ap.exec(call.function.name, JSON.parse(call.function.arguments));
console.log(result);
}| Tool | Description |
|---|---|
create_page |
Create a new HTML page; auto-injects AP-PAGE-HEADER. Returns version "0". |
update_page |
Full replace or unified diff partial update (saves tokens). Auto-increments version. Optional from_version for branching. |
show_page |
Display page in WebView via bridge |
get_page |
Read full HTML + version history. Optional version param to retrieve a specific version. |
list_pages |
List all pages with metadata, versions |
search_pages |
Search by keyword |
delete_page |
Remove a page (all versions) |
| Tool | Description |
|---|---|
create_module |
Register a reusable CSS/JS module; auto-injects AP-MODULE-HEADER |
update_module |
Replace module code, preserving header |
get_module |
Read full source code |
list_modules |
List all modules with metadata |
delete_module |
Remove a module |
| Tool | Description |
|---|---|
deps_scan |
Reverse dependency lookup — given a module name, find all pages and modules that reference it |
Every module gets an AP-MODULE-HEADER comment block so the AI understands what already exists before writing new code. This mirrors sfhtml's AI-SKILL-HEADER design.
CSS example:
/* AP-MODULE-HEADER START
# Module: theme
## Type: css
## Purpose
Global color tokens and typography
## Exports
- --color-primary
- --color-surface
## Tags
theme, colors, typography
AP-MODULE-HEADER END */
:root {
--color-primary: #6200ee;
}JS example:
/* AP-MODULE-HEADER START
# Module: chart-utils
## Type: js
## Purpose
Chart rendering helpers using Canvas API
## Exports
- function renderBarChart(data, container)
- function renderLineChart(data, container)
## Tags
chart, visualization, canvas
AP-MODULE-HEADER END */
export function renderBarChart(data, container) { ... }Page header (HTML):
<!-- AP-PAGE-HEADER START
# Page: dashboard
## App: MyApp
## Purpose
User dashboard showing key metrics
## Modules
- theme.css
- chart-utils.js
## Changelog
- 2026-03-22: initial
AP-PAGE-HEADER END -->Every page maintains a git-style linear version history with branch support. No new tools needed — version info flows through existing responses.
// create_page always starts at version "0"
await ap.exec("create_page", { name: "my-app", html: "..." });
// → version "0"
// update_page auto-increments
await ap.exec("update_page", { name: "my-app", full_html: "..." });
// → version "1"
await ap.exec("update_page", { name: "my-app", full_html: "..." });
// → version "2"
// Branch from a specific version
await ap.exec("update_page", { name: "my-app", full_html: "...", from_version: "1" });
// → version "1.0" (branch from v1)
// Get a specific version
const v0 = await ap.exec("get_page", { name: "my-app", version: "0" });
// Revert workflow: get old version, update with its content
const old = await ap.exec("get_page", { name: "my-app", version: "1" });
await ap.exec("update_page", { name: "my-app", full_html: old.data.html });
// → version "3" (content of v1)Version info is included in every response:
{
"ok": true,
"data": {
"name": "my-app",
"html": "...",
"meta": {
"version": "2",
"parentVersion": "1",
"versions": [
{ "version": "0", "parentVersion": null, "updatedAt": "..." },
{ "version": "1", "parentVersion": "0", "updatedAt": "..." },
{ "version": "2", "parentVersion": "1", "updatedAt": "..." },
{ "version": "1.0", "parentVersion": "1", "updatedAt": "..." }
]
}
}
}Pages can call back to the cloud instance via __aphtml.call() — a zero-token bidirectional channel. The SDK proxies RPC calls through the existing WebSocket, so page HTML never contains credentials.
Page WebView aphtml SDK Instance Daemon
──────────── ────────── ───────────────
__aphtml.call('fs.read', bridge.onMessage(msg)
{path:'/tmp/x'}) ──► filter() → transport.call() ──► daemon handles
◄── __aphtml._rpcResolve() ◄── bridge.evalScript() ◄── result
Inside page HTML:
<script>
const content = await __aphtml.call('fs.read', { path: '/tmp/data.json' });
const result = await __aphtml.call('agent.execute', { prompt: 'Analyze...' });
</script>Host app setup (whitelist required, secure by default):
const { bridge, rpc } = ap.connectInstance(transport, {
rpcFilter: (pageId, method) => ['fs.read', 'agent.execute'].includes(method),
});Provide your own storage for persistence on device:
import type { StorageAdapter, ApPage, ApModule, PageMeta, ModuleMeta } from "aphtml";
// Example: Capacitor Filesystem
export class CapacitorStorage implements StorageAdapter {
async getPage(name: string): Promise<ApPage | null> { /* ... */ }
async savePage(page: ApPage): Promise<void> { /* ... */ }
async deletePage(name: string): Promise<boolean> { /* ... */ }
async listPages(): Promise<PageMeta[]> { /* ... */ }
async getModule(name: string): Promise<ApModule | null> { /* ... */ }
async saveModule(mod: ApModule): Promise<void> { /* ... */ }
async deleteModule(name: string): Promise<boolean> { /* ... */ }
async listModules(): Promise<ModuleMeta[]> { /* ... */ }
}The built-in LiveBridge uses a local WebSocket server to talk to live WebViews in real time, with zero native code required.
[aphtml SDK / LLM / MCP server] ◄─ WebSocket ─► [WebView displaying HTML page]
LiveBridge :4321 aphtml-live.js (auto-injected)
Usage:
import { ApHtml, MemoryStorage, LiveBridge } from "aphtml";
// Start WebSocket server on port 4321
const bridge = new LiveBridge(4321);
// Physical device on same LAN: new LiveBridge(4321, { host: "192.168.1.42" })
const ap = new ApHtml({
storage: new MemoryStorage(),
bridge,
});
// Create a page – aphtml auto-injects the live-client script when showing
await ap.exec("create_page", {
name: "dashboard",
html: `<html><body><div id="chart">Loading...</div></body></html>`,
});
// Show it (pushes full HTML to any connected WebView)
await ap.exec("show_page", { name: "dashboard" });
// Later – LLM patches just the chart element via diff, live, no page reload
await ap.exec("update_page", {
name: "dashboard",
diff: [
"@@ -1,1 +1,1 @@",
'-<div id="chart">Loading...</div>',
'+<div id="chart"><canvas id="c"></canvas></div>',
].join("\n"),
});
// Or run JS directly in the live WebView
await ap.exec("update_page", {
name: "dashboard",
eval_script: `renderBarChart(window.__data, document.getElementById('c'))`,
});
// Listen for events sent back from inside the WebView
bridge.onMessage((msg) => {
console.log("WebView says:", msg.type, msg.data);
// msg = { type: "user:click", pageId: "dashboard", data: { id: "buy-btn" } }
});
// When done
await bridge.close();Inside the WebView page, send events back to the host:
// (the live-client script sets up window.__aphtml automatically)
window.__aphtml.post("user:click", { id: "buy-btn" });
window.__aphtml.post("form:submit", { name: "Alice", age: 30 });WebSocket protocol (aphtml-server → WebView):
| Message | Effect |
|---|---|
{ type: "full", html } |
Replace entire page (document.open/write) |
{ type: "eval", script } |
Execute arbitrary JS in the page |
{ type: "close" } |
Navigate back / close tab |
Platform support:
- Simulator/emulator →
ws://localhost:PORT(default) - Physical device (same WiFi) →
ws://192.168.x.x:PORTvia{ host: "..." }option - Works with any WebView that can make WebSocket connections (React Native WebView, WKWebView, Android WebView, Capacitor Browser, desktop Electron)
If you need to hook into the native WebView API (e.g. injectJavaScript on a React Native ref), implement BridgeAdapter directly:
import type { BridgeAdapter, BridgeMessage } from "aphtml";
// Example: React Native WebView
export class RNBridge implements BridgeAdapter {
async showPage(pageId: string, html: string): Promise<void> {
// navigate to WebView screen and set HTML source
}
async patchPage(pageId: string, selector: string, html: string): Promise<boolean> {
// Low-level DOM patch (still available for direct bridge usage)
// ref.current.injectJavaScript(`document.querySelector(...).outerHTML = ...`)
return true;
}
async evalScript(pageId: string, script: string): Promise<unknown> {
// ref.current.injectJavaScript(script)
return undefined;
}
async closePage(pageId: string): Promise<void> { /* ... */ }
onMessage(handler: (msg: BridgeMessage) => void): () => void {
// WebView onMessage => handler({ type, data })
return () => {}; // unsubscribe
}
}Connect a cloud AI instance (e.g. OpenClaw) so it can push page mutations to your frontend in real time via JSON-RPC 2.0 over WebSocket.
┌─────────────┐ ┌─────────────────┐
│ Frontend App│ │ Cloud Instance │
│ ┌─────────┐│ DaemonTransport │ (OpenClaw) │
│ │ aphtml ││◄══════════════════════►│ daemon process │
│ │ PageReg ││ JSON-RPC 2.0 WS │ AI can call: │
│ │ Bridge ││ │ page.create │
│ │ Tools ││ │ page.update │
│ └─────────┘│ │ page.show │
│ ┌─────────┐│ │ page.delete │
│ │ WebView ││ └─────────────────┘
│ └─────────┘│
└─────────────┘
JSON-RPC 2.0 client over WebSocket with auto-reconnect, call queuing, heartbeat, and auth token injection.
import { DaemonTransport } from "aphtml";
const transport = new DaemonTransport({
url: "ws://10.0.2.2:9000/rpc",
autoReconnect: true,
callTimeout: 30000,
getToken: async () => auth.getToken(),
});
await transport.connect();Works in Node.js (ws package) and React Native / browser (native WebSocket) automatically.
Connect a single cloud instance and receive page mutations:
const { bridge, rpc } = ap.connectInstance(transport, {
filter: (method, params) => allowedPages.has(params.name as string),
rpcFilter: (pageId, method, params) => allowedRpcMethods.has(method),
});
// Now the cloud instance can push pages:
// { method: "page.create", params: { name: "dashboard", html: "..." } }
// { method: "page.update", params: { name: "dashboard", diff: "@@ -1,1 +1,1 @@\n-...\n+..." } }
// { method: "page.show", params: { name: "dashboard" } }
// And pages can call back to the instance (via rpcFilter whitelist):
// __aphtml.call('fs.read', { path: '/tmp/data.json' }) → proxied to daemon
// Later:
bridge.stop();
rpc.stop();Initialize a group of AI instances with a coordinator. The frontend sends one group.init RPC to the coordinator, then only receives a unified page stream — all instance-to-instance coordination happens server-side.
Frontend Coordinator Other Instances
┌──────┐ group.init ┌──────────┐ RPC ┌──────────┐
│aphtml ├────────────────►│ Instance A├───────────►│ Instance B│
│ │ page.update │(coordinator)│◄──────────│ (finance) │
│ │◄─────────────────│ ├───────────►│ Instance C│
└──────┘ unified stream └──────────┘ │ (editor) │
└──────────┘
import { ApHtml, MemoryStorage, DaemonTransport } from "aphtml";
import type { GroupConfig } from "aphtml";
const ap = new ApHtml({ storage: new MemoryStorage(), bridge: myBridge });
const transport = new DaemonTransport({ url: "ws://coordinator:8080/rpc" });
await transport.connect();
const { bridge, rpc, result } = await ap.initGroup(transport, {
coordinator: "main",
members: [
{ role: "main", host: "10.0.1.1", wsPort: 8080 },
{ role: "finance", host: "10.0.1.2", wsPort: 8081 },
{ role: "editor", host: "10.0.1.3", wsPort: 8082 },
],
prompt: "Build a real-time stock + news dashboard",
output: {
pages: ["dashboard"],
format: "html",
streaming: true,
},
});
console.log(result.ok); // true
console.log(result.groupId); // "grp-001"
console.log(result.connectedMembers); // 3
// Coordinator now assembles data from finance + editor instances
// and pushes unified page.update notifications to this frontend.
// Later:
bridge.stop();import type {
GroupMember, // { role, host, wsPort, meta? }
GroupConfig, // { coordinator, members, prompt, output? }
GroupOutputConfig, // { pages?, format?, streaming? }
GroupInitResult, // { ok, groupId?, connectedMembers?, error? }
} from "aphtml";Manage navigation slots (tabs, sidebar items) and page templates:
const nav = ap.navRegistry;
nav.setSlot({ id: "home", label: "Home", page: "home", icon: "house" });
nav.setSlot({ id: "settings", label: "Settings", page: "settings" });
const slots = nav.getSlots(); // all navigation itemsCapture structured log entries from tools, bridges, and remote mutations:
const logs = ap.logRegistry;
logs.push({ level: "info", message: "Page created", pageId: "dashboard", trigger: "tool", ts: new Date().toISOString() });
const recent = logs.query({ level: "error", limit: 50 });Register dynamic tools at runtime and build LLM system prompts:
const tools = ap.toolRegistry;
// Register a custom tool
tools.register({
schema: { name: "get_weather", description: "Get current weather", parameters: { ... } },
execute: async (args) => ({ ok: true, data: { temp: 22 } }),
});
// Build system prompt with all available tools
const prompt = tools.buildSystemPrompt(ap.getSkillDoc());
// Export as LLM tool definitions
const llmTools = tools.toLLMToolDefs();npm testUses MockBridge and MemoryStorage for fully offline, fast tests:
import { ApHtml, MemoryStorage, MockBridge } from "aphtml";
const bridge = new MockBridge();
const ap = new ApHtml({ storage: new MemoryStorage(), bridge });
await ap.exec("create_page", { name: "test", html: "<html>...</html>" });
await ap.exec("show_page", { name: "test" });
expect(bridge.calls[0].method).toBe("showPage");
bridge.simulateMessage({ type: "user:click", data: { id: "btn" } });1. list_modules → understand available assets
2. search_pages → check if similar page exists
3. get_page → read existing page before editing (includes version history)
4. create_module → add new CSS/JS if needed
5. create_page → build new page using existing modules (version "0")
6. deps_scan → reverse-check which pages/modules reference a module
7. show_page → render in WebView
8. update_page → apply AI-driven corrections live (auto-increments version)
9. get_page(version) → retrieve a previous version to revert or branch
aphtml/
src/
types.ts Core interfaces + Group/Transport/Cloud types
header.ts AP-PAGE-HEADER / AP-MODULE-HEADER parser & generator
deps-scanner.ts Scan HTML deps (mirrors sfhtml module_deps.rs)
tools.ts 23 LLM-callable tool schemas + executor
index.ts Public API: ApHtml class (exec, connectInstance, initGroup)
storage/
adapter.ts StorageAdapter interface
memory.ts MemoryStorage (built-in, volatile, version-aware)
bridge/
adapter.ts BridgeAdapter interface + BRIDGE_CLIENT_JS snippet (with RPC)
mock.ts MockBridge for testing
live-bridge.ts LiveBridge — WebSocket real-time bridge
remote-page-bridge.ts RemotePageBridge — cloud → frontend page sync
rpc-proxy.ts BridgeRpcProxy — Page ↔ Instance bidirectional RPC
transport/
daemon-transport.ts JSON-RPC 2.0 over WebSocket (auto-reconnect, auth)
mock.ts MockTransport for testing
registry/
page-registry.ts PageRegistry (CRUD + show + search)
module-registry.ts ModuleRegistry (CRUD + search + summarize)
nav-registry.ts NavRegistry (navigation slots + page templates)
log-registry.ts LogRegistry (structured logging + query)
tool-registry.ts ToolRegistry (dynamic tools + system prompt builder)
cloud/
cloud-manager.ts CloudManager — machine lifecycle via RPC
auth/
mock.ts MockAuth for testing
file/
file-processor.ts FileProcessor — multi-provider text extraction
vnc/
mock.ts MockVnc for testing
MIT