diff --git a/skills/app-builder/SKILL.md b/skills/app-builder/SKILL.md
index bff78d6fe91..3edcc5e6f50 100644
--- a/skills/app-builder/SKILL.md
+++ b/skills/app-builder/SKILL.md
@@ -1,6 +1,6 @@
---
name: app-builder
-description: Build and manage DenchClaw apps — self-contained web applications that run inside the workspace with access to DuckDB data and the DenchClaw bridge API. Covers static HTML apps, 2D games with p5.js, 3D experiences with Three.js, data dashboards, interactive tools, and more.
+description: Build and manage DenchClaw apps — self-contained web applications that run inside the workspace with access to DuckDB data, workspace objects, AI chat, and the full DenchClaw platform API.
metadata: { "openclaw": { "inject": true, "always": true, "emoji": "🔨" } }
---
@@ -14,18 +14,15 @@ You can build **Dench Apps** — self-contained web applications that run inside
1. [App Structure](#app-structure)
2. [Manifest Reference](#manifest-reference)
-3. [Bridge API Reference](#bridge-api-reference)
+3. [Bridge API Overview](#bridge-api-overview)
4. [Theme & Styling System](#theme--styling-system)
5. [Loading External Libraries via CDN](#loading-external-libraries-via-cdn)
-6. [2D Games with p5.js](#2d-games-with-p5js)
-7. [3D Games & Experiences with Three.js](#3d-games--experiences-with-threejs)
-8. [Data Dashboards & Visualization](#data-dashboards--visualization)
-9. [Interactive Tools & Utilities](#interactive-tools--utilities)
-10. [Multi-File App Organization](#multi-file-app-organization)
-11. [Asset Management](#asset-management)
-12. [Performance & Best Practices](#performance--best-practices)
-13. [Error Handling Patterns](#error-handling-patterns)
-14. [Full Example Apps](#full-example-apps)
+6. [Multi-File App Organization](#multi-file-app-organization)
+7. [Asset Management](#asset-management)
+8. [Performance & Best Practices](#performance--best-practices)
+9. [Error Handling Patterns](#error-handling-patterns)
+10. [Creating an App — Step by Step Checklist](#creating-an-app--step-by-step-checklist)
+11. [Child Skills](#child-skills)
---
@@ -73,9 +70,36 @@ author: "agent" # Optional. Creator attribution
entry: "index.html" # Optional. Main entry point (default: index.html)
runtime: "static" # Optional. static | esbuild | build (default: static)
+display: "full" # Optional. "full" (default) | "widget"
+widget: # Only used when display: "widget"
+ width: 2 # Grid columns (1-4)
+ height: 1 # Grid rows (1-4)
+ refreshInterval: 60 # Auto-refresh seconds (optional)
+
permissions: # Optional. List of bridge API permissions
- - database # Can query workspace DuckDB via window.dench.db
- - files # Can read workspace files via window.dench.files
+ - database # db.query (SELECT only)
+ - database:write # db.execute (INSERT/UPDATE/DELETE/CREATE)
+ - objects # objects.* CRUD on workspace tables
+ - files # files.read, files.list
+ - files:write # files.write, files.delete, files.mkdir
+ - agent # chat.*, agent.send, tool.register, memory.get
+ - ui # ui.toast, ui.navigate, ui.openEntry, etc.
+ - store # store.* per-app KV storage
+ - http # http.fetch CORS proxy
+ - events # events.on/off real-time subscriptions
+ - apps # apps.send/on inter-app messaging
+ - cron # cron.schedule/list/cancel
+ - webhooks # webhooks.register/on
+ - clipboard # clipboard.read/write
+
+tools: # Optional. Expose app functions as agent-invokable tools
+ - name: "my-tool"
+ description: "What this tool does"
+ inputSchema:
+ type: object
+ properties:
+ input: { type: string }
+ required: ["input"]
```
### Runtime Modes
@@ -101,63 +125,78 @@ Supported image formats: PNG, SVG, JPG, JPEG, WebP. Use square aspect ratio (128
| Permission | Grants | Use When |
|------------|--------|----------|
-| `database` | `window.dench.db.query()` and `window.dench.db.execute()` | App reads/writes workspace DuckDB data |
-| `files` | `window.dench.files.read()` and `window.dench.files.list()` | App reads workspace files or directory tree |
+| `database` | `dench.db.query()` | App reads workspace DuckDB data (SELECT) |
+| `database:write` | `dench.db.execute()` | App writes to DuckDB (INSERT/UPDATE/DELETE/CREATE) |
+| `objects` | `dench.objects.*` | App does CRUD on workspace objects (people, tasks, etc.) |
+| `files` | `dench.files.read()`, `dench.files.list()` | App reads workspace files |
+| `files:write` | `dench.files.write()`, `dench.files.delete()`, `dench.files.mkdir()` | App writes/deletes workspace files |
+| `agent` | `dench.chat.*`, `dench.agent.send()`, `dench.tool.*`, `dench.memory.*` | App interacts with the AI agent |
+| `ui` | `dench.ui.*` | App shows toasts, navigates, opens entries |
+| `store` | `dench.store.*` | App needs persistent key-value storage |
+| `http` | `dench.http.fetch()` | App fetches external URLs (CORS-free) |
+| `events` | `dench.events.*` | App subscribes to real-time workspace events |
+| `apps` | `dench.apps.*` | App communicates with other open apps |
+| `cron` | `dench.cron.*` | App schedules recurring agent tasks |
+| `webhooks` | `dench.webhooks.*` | App receives external webhooks |
+| `clipboard` | `dench.clipboard.*` | App reads/writes the clipboard |
Only request what you need. A game with no data access needs no permissions at all.
---
-## Bridge API Reference
+## Bridge API Overview
-The bridge SDK is auto-injected into every app's HTML. It provides `window.dench` with the following methods. All methods return Promises with a 30-second timeout.
+The bridge SDK is auto-injected into every app's HTML. It provides `window.dench` with the following namespaces. All methods return Promises with a 30-second timeout.
-### Database Access (`database` permission required)
+| Namespace | Permission | Methods | Details In |
+|-----------|------------|---------|------------|
+| `dench.db` | `database` / `database:write` | `query(sql)`, `execute(sql)` | **data-builder** |
+| `dench.objects` | `objects` | `list()`, `get()`, `create()`, `update()`, `delete()`, `bulkDelete()`, `getSchema()`, `getOptions()` | **data-builder** |
+| `dench.files` | `files` / `files:write` | `read()`, `list()`, `write()`, `delete()`, `mkdir()` | below |
+| `dench.app` | *(none)* | `getManifest()`, `getTheme()` | below |
+| `dench.chat` | `agent` | `createSession()`, `send()`, `getHistory()`, `getSessions()`, `abort()`, `isActive()` | **agent-builder** |
+| `dench.agent` | `agent` | `send(message)` | **agent-builder** |
+| `dench.tool` | `agent` | `register(name, handler)` | **agent-builder** |
+| `dench.memory` | `agent` | `get()` | **agent-builder** |
+| `dench.ui` | `ui` | `toast()`, `navigate()`, `openEntry()`, `setTitle()`, `confirm()`, `prompt()` | **platform-api** |
+| `dench.store` | `store` | `get()`, `set()`, `delete()`, `list()`, `clear()` | **platform-api** |
+| `dench.http` | `http` | `fetch(url, opts)` | **platform-api** |
+| `dench.events` | `events` | `on(channel, cb)`, `off(channel)` | **platform-api** |
+| `dench.context` | *(none)* | `getWorkspace()`, `getAppInfo()` | **platform-api** |
+| `dench.apps` | `apps` | `send()`, `on()`, `list()` | **platform-api** |
+| `dench.cron` | `cron` | `schedule()`, `list()`, `run()`, `cancel()` | **platform-api** |
+| `dench.webhooks` | `webhooks` | `register()`, `on()`, `poll()` | **platform-api** |
+| `dench.clipboard` | `clipboard` | `read()`, `write()` | **platform-api** |
-```javascript
-// Run a SELECT query — returns { rows: [...], columns: [...] }
-const result = await window.dench.db.query("SELECT * FROM objects");
-console.log(result.rows); // Array of row objects
-console.log(result.columns); // Array of column name strings
-
-// Run a mutation (INSERT, UPDATE, DELETE, CREATE TABLE, etc.)
-await window.dench.db.execute("INSERT INTO ...");
-
-// Parameterized-style queries (use string interpolation carefully)
-const objectName = "people";
-const entries = await window.dench.db.query(
- `SELECT * FROM entries WHERE object_id = (SELECT id FROM objects WHERE name = '${objectName}')`
-);
-```
-
-### File Access (`files` permission required)
-
-```javascript
-// Read a workspace file by relative path
-const fileContent = await window.dench.files.read("path/to/file.md");
-
-// List the workspace directory tree
-const tree = await window.dench.files.list();
-// Returns nested tree structure: { name, path, type, children? }[]
-```
-
-### App Utilities (no permission required)
+### Core APIs (no child skill needed)
```javascript
// Get the app's own parsed manifest
-const manifest = await window.dench.app.getManifest();
-// Returns: { name, description, icon, version, author, entry, runtime, permissions }
+const manifest = await dench.app.getManifest();
// Get current DenchClaw UI theme
-const theme = await window.dench.app.getTheme();
+const theme = await dench.app.getTheme();
// Returns: "dark" or "light"
```
-### Agent Communication (no permission required)
+### File Access (`files` / `files:write` permission)
```javascript
-// Send a message to the DenchClaw agent (triggers a chat message)
-await window.dench.agent.send("Analyze the data in the people table");
+// Read a workspace file
+const content = await dench.files.read("path/to/file.md");
+
+// List workspace directory tree (optionally scoped to a directory)
+const tree = await dench.files.list();
+const subTree = await dench.files.list("documents/");
+
+// Write a file (files:write permission)
+await dench.files.write("path/to/file.md", "# Hello\n\nFile content here.");
+
+// Delete a file (files:write permission)
+await dench.files.delete("path/to/old-file.md");
+
+// Create a directory (files:write permission)
+await dench.files.mkdir("path/to/new-dir");
```
### Waiting for Bridge Readiness
@@ -165,7 +204,6 @@ await window.dench.agent.send("Analyze the data in the people table");
The bridge script is injected into `
`, so it's available by the time your scripts run. However, if you use `defer` or `type="module"` scripts, you can safely access `window.dench` immediately since module scripts run after the document is parsed.
```javascript
-// Safe pattern for any script loading order
function whenDenchReady(fn) {
if (window.dench) return fn();
const check = setInterval(() => {
@@ -174,7 +212,7 @@ function whenDenchReady(fn) {
}
whenDenchReady(async () => {
- const theme = await window.dench.app.getTheme();
+ const theme = await dench.app.getTheme();
document.body.className = theme;
});
```
@@ -239,7 +277,7 @@ Always apply the theme as the first action in your app:
```javascript
async function initTheme() {
try {
- const theme = await window.dench.app.getTheme();
+ const theme = await dench.app.getTheme();
document.body.className = theme;
} catch {
document.body.className = 'dark';
@@ -332,1177 +370,6 @@ For Three.js and other module-based libraries, use import maps:
---
-## 2D Games with p5.js
-
-**Always use p5.js for 2D games, simulations, generative art, and interactive 2D experiences.** p5.js is the default choice for anything 2D unless the user specifically requests something else.
-
-### When to Use p5.js
-
-- 2D games (platformer, puzzle, arcade, card games, board games)
-- Generative art and creative coding
-- Physics simulations and particle systems
-- Interactive data visualizations with animation
-- Educational simulations and demonstrations
-- Drawing and painting tools
-- Any 2D canvas-based interactive experience
-
-### p5.js App Template
-
-```
-apps/my-game.dench.app/
- .dench.yaml
- index.html
- sketch.js
- assets/ # sprites, sounds, fonts
-```
-
-**`.dench.yaml`:**
-```yaml
-name: "My Game"
-description: "A fun 2D game built with p5.js"
-icon: "gamepad-2"
-version: "1.0.0"
-entry: "index.html"
-runtime: "static"
-```
-
-No permissions needed unless the game reads/writes workspace data.
-
-**`index.html`:**
-```html
-
-
-
-
-
- My Game
-
-
-
-
-
-
-
-```
-
-**`sketch.js` (game loop skeleton):**
-```javascript
-let isDark = true;
-
-function setup() {
- createCanvas(windowWidth, windowHeight);
-
- // Detect theme from DenchClaw
- if (window.dench) {
- window.dench.app.getTheme().then(theme => {
- isDark = theme === 'dark';
- }).catch(() => {});
- }
-}
-
-function draw() {
- background(isDark ? 15 : 245);
-
- // Game rendering goes here
-}
-
-function windowResized() {
- resizeCanvas(windowWidth, windowHeight);
-}
-```
-
-### p5.js Instance Mode (Recommended for Complex Apps)
-
-Use instance mode to avoid global namespace pollution. This is especially important for multi-file apps:
-
-```javascript
-const sketch = (p) => {
- let isDark = true;
- let player;
-
- p.setup = () => {
- p.createCanvas(p.windowWidth, p.windowHeight);
- player = { x: p.width / 2, y: p.height / 2, size: 30, speed: 4 };
-
- if (window.dench) {
- window.dench.app.getTheme().then(theme => { isDark = theme === 'dark'; }).catch(() => {});
- }
- };
-
- p.draw = () => {
- p.background(isDark ? 15 : 245);
-
- // Input handling
- if (p.keyIsDown(p.LEFT_ARROW) || p.keyIsDown(65)) player.x -= player.speed;
- if (p.keyIsDown(p.RIGHT_ARROW) || p.keyIsDown(68)) player.x += player.speed;
- if (p.keyIsDown(p.UP_ARROW) || p.keyIsDown(87)) player.y -= player.speed;
- if (p.keyIsDown(p.DOWN_ARROW) || p.keyIsDown(83)) player.y += player.speed;
-
- // Keep in bounds
- player.x = p.constrain(player.x, 0, p.width);
- player.y = p.constrain(player.y, 0, p.height);
-
- // Draw player
- p.fill(isDark ? '#6366f1' : '#4f46e5');
- p.noStroke();
- p.ellipse(player.x, player.y, player.size);
- };
-
- p.windowResized = () => {
- p.resizeCanvas(p.windowWidth, p.windowHeight);
- };
-};
-
-new p5(sketch);
-```
-
-### p5.js Game Architecture Patterns
-
-#### Game State Machine
-
-```javascript
-const GameState = { MENU: 'menu', PLAYING: 'playing', PAUSED: 'paused', GAME_OVER: 'gameover' };
-let state = GameState.MENU;
-let score = 0;
-let highScore = 0;
-
-function draw() {
- switch (state) {
- case GameState.MENU: drawMenu(); break;
- case GameState.PLAYING: drawGame(); break;
- case GameState.PAUSED: drawPause(); break;
- case GameState.GAME_OVER: drawGameOver(); break;
- }
-}
-
-function keyPressed() {
- if (state === GameState.MENU && (key === ' ' || key === 'Enter')) {
- state = GameState.PLAYING;
- resetGame();
- } else if (state === GameState.PLAYING && key === 'Escape') {
- state = GameState.PAUSED;
- } else if (state === GameState.PAUSED && key === 'Escape') {
- state = GameState.PLAYING;
- } else if (state === GameState.GAME_OVER && (key === ' ' || key === 'Enter')) {
- state = GameState.PLAYING;
- resetGame();
- }
-}
-
-function drawMenu() {
- background(15);
- fill(255);
- textAlign(CENTER, CENTER);
- textSize(48);
- text('MY GAME', width / 2, height / 2 - 60);
- textSize(18);
- fill(150);
- text('Press SPACE or ENTER to start', width / 2, height / 2 + 20);
- if (highScore > 0) {
- textSize(14);
- text('High Score: ' + highScore, width / 2, height / 2 + 60);
- }
-}
-
-function drawGameOver() {
- background(15);
- fill('#ef4444');
- textAlign(CENTER, CENTER);
- textSize(48);
- text('GAME OVER', width / 2, height / 2 - 60);
- fill(255);
- textSize(24);
- text('Score: ' + score, width / 2, height / 2);
- textSize(16);
- fill(150);
- text('Press SPACE to play again', width / 2, height / 2 + 50);
-}
-```
-
-#### Sprite Management
-
-```javascript
-class Sprite {
- constructor(x, y, w, h) {
- this.pos = createVector(x, y);
- this.vel = createVector(0, 0);
- this.w = w;
- this.h = h;
- this.alive = true;
- }
-
- update() {
- this.pos.add(this.vel);
- }
-
- draw() {
- rectMode(CENTER);
- rect(this.pos.x, this.pos.y, this.w, this.h);
- }
-
- collidesWith(other) {
- return (
- this.pos.x - this.w / 2 < other.pos.x + other.w / 2 &&
- this.pos.x + this.w / 2 > other.pos.x - other.w / 2 &&
- this.pos.y - this.h / 2 < other.pos.y + other.h / 2 &&
- this.pos.y + this.h / 2 > other.pos.y - other.h / 2
- );
- }
-
- isOffscreen() {
- return (
- this.pos.x < -this.w || this.pos.x > width + this.w ||
- this.pos.y < -this.h || this.pos.y > height + this.h
- );
- }
-}
-```
-
-#### Particle System
-
-```javascript
-class Particle {
- constructor(x, y, color) {
- this.pos = createVector(x, y);
- this.vel = p5.Vector.random2D().mult(random(1, 5));
- this.acc = createVector(0, 0.1);
- this.color = color;
- this.alpha = 255;
- this.size = random(3, 8);
- this.life = 1.0;
- this.decay = random(0.01, 0.04);
- }
-
- update() {
- this.vel.add(this.acc);
- this.pos.add(this.vel);
- this.life -= this.decay;
- this.alpha = this.life * 255;
- }
-
- draw() {
- noStroke();
- fill(red(this.color), green(this.color), blue(this.color), this.alpha);
- ellipse(this.pos.x, this.pos.y, this.size);
- }
-
- isDead() {
- return this.life <= 0;
- }
-}
-
-let particles = [];
-
-function spawnExplosion(x, y, col, count = 30) {
- for (let i = 0; i < count; i++) {
- particles.push(new Particle(x, y, col));
- }
-}
-
-function updateParticles() {
- for (let i = particles.length - 1; i >= 0; i--) {
- particles[i].update();
- particles[i].draw();
- if (particles[i].isDead()) particles.splice(i, 1);
- }
-}
-```
-
-#### Camera / Scrolling
-
-```javascript
-let camera = { x: 0, y: 0 };
-
-function draw() {
- background(15);
-
- // Follow player
- camera.x = lerp(camera.x, player.x - width / 2, 0.1);
- camera.y = lerp(camera.y, player.y - height / 2, 0.1);
-
- push();
- translate(-camera.x, -camera.y);
-
- // Draw world (in world coordinates)
- drawWorld();
- drawPlayer();
- drawEnemies();
-
- pop();
-
- // Draw HUD (in screen coordinates)
- drawHUD();
-}
-```
-
-#### Tilemap Rendering
-
-```javascript
-const TILE_SIZE = 32;
-const tilemap = [
- [1, 1, 1, 1, 1, 1, 1, 1],
- [1, 0, 0, 0, 0, 0, 0, 1],
- [1, 0, 0, 2, 0, 0, 0, 1],
- [1, 0, 0, 0, 0, 3, 0, 1],
- [1, 1, 1, 1, 1, 1, 1, 1],
-];
-
-const TILE_COLORS = {
- 0: null, // empty
- 1: '#4a4a6a', // wall
- 2: '#22c55e', // item
- 3: '#ef4444', // enemy
-};
-
-function drawTilemap() {
- for (let row = 0; row < tilemap.length; row++) {
- for (let col = 0; col < tilemap[row].length; col++) {
- const tile = tilemap[row][col];
- if (TILE_COLORS[tile]) {
- fill(TILE_COLORS[tile]);
- noStroke();
- rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
- }
- }
- }
-}
-```
-
-#### Sound Effects (using p5.sound or Howler.js)
-
-For sound, prefer Howler.js since p5.sound adds significant bundle size:
-
-```html
-
-```
-
-```javascript
-const sounds = {
- jump: new Howl({ src: ['assets/jump.wav'], volume: 0.5 }),
- hit: new Howl({ src: ['assets/hit.wav'], volume: 0.7 }),
- coin: new Howl({ src: ['assets/coin.wav'], volume: 0.4 }),
- music: new Howl({ src: ['assets/music.mp3'], loop: true, volume: 0.3 }),
-};
-```
-
-If no sound assets are available, generate simple audio with Tone.js or the Web Audio API:
-
-```javascript
-function playBeep(freq = 440, duration = 0.1) {
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
- const osc = ctx.createOscillator();
- const gain = ctx.createGain();
- osc.connect(gain);
- gain.connect(ctx.destination);
- osc.frequency.value = freq;
- gain.gain.setValueAtTime(0.3, ctx.currentTime);
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
- osc.start();
- osc.stop(ctx.currentTime + duration);
-}
-```
-
-### p5.js with Physics (Matter.js)
-
-For games needing realistic 2D physics (platformers, ragdoll, pinball):
-
-```html
-
-
-```
-
-```javascript
-const { Engine, World, Bodies, Body, Events } = Matter;
-
-let engine, world;
-let ground, player;
-
-function setup() {
- createCanvas(windowWidth, windowHeight);
- engine = Engine.create();
- world = engine.world;
-
- ground = Bodies.rectangle(width / 2, height - 20, width, 40, { isStatic: true });
- player = Bodies.circle(width / 2, height / 2, 20, { restitution: 0.5 });
-
- World.add(world, [ground, player]);
-}
-
-function draw() {
- Engine.update(engine);
- background(15);
-
- // Draw ground
- fill('#4a4a6a');
- rectMode(CENTER);
- rect(ground.position.x, ground.position.y, width, 40);
-
- // Draw player
- fill('#6366f1');
- ellipse(player.position.x, player.position.y, 40);
-}
-
-function keyPressed() {
- if (key === ' ') {
- Body.applyForce(player, player.position, { x: 0, y: -0.05 });
- }
-}
-```
-
-### p5.js Responsive Canvas
-
-Always handle window resizing and use the full viewport:
-
-```javascript
-function setup() {
- createCanvas(windowWidth, windowHeight);
-}
-
-function windowResized() {
- resizeCanvas(windowWidth, windowHeight);
-}
-```
-
-For fixed-aspect-ratio games (e.g., retro pixel games), scale the canvas:
-
-```javascript
-const GAME_W = 320;
-const GAME_H = 240;
-let scaleFactor;
-
-function setup() {
- createCanvas(windowWidth, windowHeight);
- pixelDensity(1);
- noSmooth();
- calcScale();
-}
-
-function calcScale() {
- scaleFactor = min(windowWidth / GAME_W, windowHeight / GAME_H);
-}
-
-function draw() {
- background(0);
- push();
- translate((width - GAME_W * scaleFactor) / 2, (height - GAME_H * scaleFactor) / 2);
- scale(scaleFactor);
-
- // All game drawing at GAME_W x GAME_H logical resolution
- drawGameWorld();
-
- pop();
-}
-
-function windowResized() {
- resizeCanvas(windowWidth, windowHeight);
- calcScale();
-}
-```
-
-### Touch / Mobile Input for p5.js Games
-
-```javascript
-let touchActive = false;
-let touchX = 0, touchY = 0;
-
-function touchStarted() {
- touchActive = true;
- touchX = mouseX;
- touchY = mouseY;
- return false; // prevent default
-}
-
-function touchMoved() {
- touchX = mouseX;
- touchY = mouseY;
- return false;
-}
-
-function touchEnded() {
- touchActive = false;
- return false;
-}
-
-// Unified input: works for both mouse and touch
-function getInputX() { return mouseX; }
-function getInputY() { return mouseY; }
-function isInputActive() { return mouseIsPressed || touchActive; }
-```
-
-### p5.js High Score Persistence with DuckDB
-
-If the game has a `database` permission, persist high scores:
-
-```javascript
-async function loadHighScore() {
- try {
- await window.dench.db.execute(`
- CREATE TABLE IF NOT EXISTS game_scores (
- game TEXT, score INTEGER, played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
- `);
- const result = await window.dench.db.query(
- `SELECT MAX(score) as high_score FROM game_scores WHERE game = 'my-game'`
- );
- return result.rows?.[0]?.high_score || 0;
- } catch { return 0; }
-}
-
-async function saveScore(score) {
- try {
- await window.dench.db.execute(
- `INSERT INTO game_scores (game, score) VALUES ('my-game', ${score})`
- );
- } catch {}
-}
-```
-
----
-
-## 3D Games & Experiences with Three.js
-
-**Always use Three.js for 3D games, visualizations, and interactive 3D experiences.** Three.js is the default choice for anything 3D.
-
-### When to Use Three.js
-
-- 3D games (first-person, third-person, flying, racing)
-- 3D product viewers and configurators
-- Terrain and world visualization
-- 3D data visualization (3D scatter plots, network graphs)
-- Architectural walkthroughs
-- Generative 3D art
-- Physics-based 3D simulations
-
-### Three.js App Template
-
-```
-apps/my-3d-app.dench.app/
- .dench.yaml
- index.html
- app.js # Main Three.js module
- assets/
- model.glb # 3D models (optional)
- texture.jpg # Textures (optional)
-```
-
-**`.dench.yaml`:**
-```yaml
-name: "3D World"
-description: "An interactive 3D experience"
-icon: "box"
-version: "1.0.0"
-entry: "index.html"
-runtime: "static"
-```
-
-**`index.html`:**
-```html
-
-
-
-
-
- 3D World
-
-
-
-
- Loading...
-
-
-
-```
-
-**`app.js` (Three.js module skeleton):**
-```javascript
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-// --- Scene setup ---
-const scene = new THREE.Scene();
-scene.background = new THREE.Color(0x0f0f1a);
-scene.fog = new THREE.Fog(0x0f0f1a, 50, 200);
-
-const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
-camera.position.set(0, 5, 10);
-
-const renderer = new THREE.WebGLRenderer({ antialias: true });
-renderer.setSize(window.innerWidth, window.innerHeight);
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
-renderer.shadowMap.enabled = true;
-renderer.shadowMap.type = THREE.PCFSoftShadowMap;
-renderer.toneMapping = THREE.ACESFilmicToneMapping;
-renderer.toneMappingExposure = 1.0;
-document.body.appendChild(renderer.domElement);
-
-// --- Controls ---
-const controls = new OrbitControls(camera, renderer.domElement);
-controls.enableDamping = true;
-controls.dampingFactor = 0.05;
-controls.maxPolarAngle = Math.PI / 2;
-
-// --- Lighting ---
-const ambientLight = new THREE.AmbientLight(0x404060, 0.5);
-scene.add(ambientLight);
-
-const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
-directionalLight.position.set(10, 20, 10);
-directionalLight.castShadow = true;
-directionalLight.shadow.mapSize.set(2048, 2048);
-directionalLight.shadow.camera.near = 0.5;
-directionalLight.shadow.camera.far = 100;
-directionalLight.shadow.camera.left = -30;
-directionalLight.shadow.camera.right = 30;
-directionalLight.shadow.camera.top = 30;
-directionalLight.shadow.camera.bottom = -30;
-scene.add(directionalLight);
-
-// --- Ground ---
-const groundGeo = new THREE.PlaneGeometry(200, 200);
-const groundMat = new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 });
-const ground = new THREE.Mesh(groundGeo, groundMat);
-ground.rotation.x = -Math.PI / 2;
-ground.receiveShadow = true;
-scene.add(ground);
-
-// --- Objects ---
-const geometry = new THREE.BoxGeometry(2, 2, 2);
-const material = new THREE.MeshStandardMaterial({
- color: 0x6366f1,
- roughness: 0.3,
- metalness: 0.5,
-});
-const cube = new THREE.Mesh(geometry, material);
-cube.position.y = 1;
-cube.castShadow = true;
-scene.add(cube);
-
-// --- Theme ---
-if (window.dench) {
- window.dench.app.getTheme().then(theme => {
- if (theme === 'light') {
- scene.background = new THREE.Color(0xf0f0f5);
- scene.fog = new THREE.Fog(0xf0f0f5, 50, 200);
- groundMat.color.set(0xe8e8f0);
- }
- }).catch(() => {});
-}
-
-// --- Hide loading screen ---
-document.getElementById('loading')?.classList.add('hidden');
-
-// --- Animation loop ---
-const clock = new THREE.Clock();
-
-function animate() {
- requestAnimationFrame(animate);
- const dt = clock.getDelta();
- const elapsed = clock.getElapsedTime();
-
- cube.rotation.y = elapsed * 0.5;
- cube.position.y = 1 + Math.sin(elapsed) * 0.5;
-
- controls.update();
- renderer.render(scene, camera);
-}
-
-animate();
-
-// --- Resize ---
-window.addEventListener('resize', () => {
- camera.aspect = window.innerWidth / window.innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize(window.innerWidth, window.innerHeight);
-});
-```
-
-### Three.js Common Addons
-
-Load additional Three.js modules as needed via the import map:
-
-```javascript
-// First-person controls
-import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
-
-// GLTF model loading
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-
-// Post-processing
-import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
-import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
-import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
-
-// Environment maps
-import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
-
-// Text
-import { FontLoader } from 'three/addons/loaders/FontLoader.js';
-import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
-
-// Sky
-import { Sky } from 'three/addons/objects/Sky.js';
-
-// Water
-import { Water } from 'three/addons/objects/Water.js';
-
-// Physics integration (use cannon-es via CDN)
-// Add to importmap: "cannon-es": "https://unpkg.com/cannon-es@0.20/dist/cannon-es.js"
-import * as CANNON from 'cannon-es';
-```
-
-### Three.js First-Person Game Pattern
-
-```javascript
-import * as THREE from 'three';
-import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
-
-const scene = new THREE.Scene();
-const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
-const renderer = new THREE.WebGLRenderer({ antialias: true });
-renderer.setSize(window.innerWidth, window.innerHeight);
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
-document.body.appendChild(renderer.domElement);
-
-const controls = new PointerLockControls(camera, document.body);
-
-// Click to enter pointer lock
-document.addEventListener('click', () => {
- if (!controls.isLocked) controls.lock();
-});
-
-// Movement state
-const velocity = new THREE.Vector3();
-const direction = new THREE.Vector3();
-const keys = { forward: false, backward: false, left: false, right: false, jump: false };
-
-document.addEventListener('keydown', (e) => {
- switch (e.code) {
- case 'KeyW': case 'ArrowUp': keys.forward = true; break;
- case 'KeyS': case 'ArrowDown': keys.backward = true; break;
- case 'KeyA': case 'ArrowLeft': keys.left = true; break;
- case 'KeyD': case 'ArrowRight': keys.right = true; break;
- case 'Space': keys.jump = true; break;
- }
-});
-
-document.addEventListener('keyup', (e) => {
- switch (e.code) {
- case 'KeyW': case 'ArrowUp': keys.forward = false; break;
- case 'KeyS': case 'ArrowDown': keys.backward = false; break;
- case 'KeyA': case 'ArrowLeft': keys.left = false; break;
- case 'KeyD': case 'ArrowRight': keys.right = false; break;
- case 'Space': keys.jump = false; break;
- }
-});
-
-let onGround = true;
-const MOVE_SPEED = 50;
-const JUMP_FORCE = 12;
-const GRAVITY = 30;
-
-const clock = new THREE.Clock();
-
-function animate() {
- requestAnimationFrame(animate);
- const dt = Math.min(clock.getDelta(), 0.1);
-
- if (controls.isLocked) {
- // Apply friction
- velocity.x -= velocity.x * 10.0 * dt;
- velocity.z -= velocity.z * 10.0 * dt;
- velocity.y -= GRAVITY * dt;
-
- direction.z = Number(keys.forward) - Number(keys.backward);
- direction.x = Number(keys.right) - Number(keys.left);
- direction.normalize();
-
- if (keys.forward || keys.backward) velocity.z -= direction.z * MOVE_SPEED * dt;
- if (keys.left || keys.right) velocity.x -= direction.x * MOVE_SPEED * dt;
- if (keys.jump && onGround) { velocity.y = JUMP_FORCE; onGround = false; }
-
- controls.moveRight(-velocity.x * dt);
- controls.moveForward(-velocity.z * dt);
- camera.position.y += velocity.y * dt;
-
- if (camera.position.y < 1.7) {
- velocity.y = 0;
- camera.position.y = 1.7;
- onGround = true;
- }
- }
-
- renderer.render(scene, camera);
-}
-
-animate();
-```
-
-### Three.js GLTF Model Loading
-
-```javascript
-import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
-import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
-
-const loader = new GLTFLoader();
-const dracoLoader = new DRACOLoader();
-dracoLoader.setDecoderPath('https://unpkg.com/three@0.170/examples/jsm/libs/draco/');
-loader.setDRACOLoader(dracoLoader);
-
-// Load a model from the app's assets folder
-loader.load('assets/model.glb', (gltf) => {
- const model = gltf.scene;
- model.traverse((child) => {
- if (child.isMesh) {
- child.castShadow = true;
- child.receiveShadow = true;
- }
- });
- model.scale.setScalar(1);
- scene.add(model);
-
- // If the model has animations
- if (gltf.animations.length > 0) {
- const mixer = new THREE.AnimationMixer(model);
- const action = mixer.clipAction(gltf.animations[0]);
- action.play();
- // In animate loop: mixer.update(dt);
- }
-}, undefined, (error) => {
- console.error('Model load error:', error);
-});
-```
-
-### Three.js Post-Processing (Bloom, etc.)
-
-```javascript
-import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
-import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
-import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
-
-const composer = new EffectComposer(renderer);
-composer.addPass(new RenderPass(scene, camera));
-
-const bloomPass = new UnrealBloomPass(
- new THREE.Vector2(window.innerWidth, window.innerHeight),
- 0.5, // strength
- 0.4, // radius
- 0.85 // threshold
-);
-composer.addPass(bloomPass);
-
-// In animate loop, replace renderer.render(scene, camera) with:
-// composer.render();
-
-// On resize, also update:
-// composer.setSize(window.innerWidth, window.innerHeight);
-```
-
-### Three.js Procedural Terrain
-
-```javascript
-function createTerrain(width, depth, resolution) {
- const geometry = new THREE.PlaneGeometry(width, depth, resolution, resolution);
- const vertices = geometry.attributes.position.array;
-
- for (let i = 0; i < vertices.length; i += 3) {
- const x = vertices[i];
- const z = vertices[i + 1];
- vertices[i + 2] = noise(x * 0.02, z * 0.02) * 15;
- }
-
- geometry.computeVertexNormals();
-
- const material = new THREE.MeshStandardMaterial({
- color: 0x3a7d44,
- roughness: 0.9,
- flatShading: true,
- });
-
- const mesh = new THREE.Mesh(geometry, material);
- mesh.rotation.x = -Math.PI / 2;
- mesh.receiveShadow = true;
- return mesh;
-}
-
-// Simple noise function (for procedural generation without dependencies)
-function noise(x, y) {
- const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
- return n - Math.floor(n);
-}
-```
-
-### Three.js HUD / UI Overlay
-
-Since Three.js renders to a canvas, use HTML overlays for UI:
-
-```html
-
-```
-
-```javascript
-function updateHUD(score, health) {
- document.getElementById('score').textContent = `Score: ${score}`;
- document.getElementById('health-fill').style.width = `${health}%`;
- document.getElementById('health-fill').style.background =
- health > 50 ? '#22c55e' : health > 25 ? '#f59e0b' : '#ef4444';
-}
-```
-
----
-
-## Data Dashboards & Visualization
-
-For data-heavy apps that query the workspace DuckDB, use Chart.js, D3.js, or plain HTML/CSS.
-
-### Chart.js Dashboard
-
-```html
-
-```
-
-```javascript
-async function renderChart() {
- const result = await window.dench.db.query(
- "SELECT name, entry_count FROM objects ORDER BY entry_count DESC"
- );
-
- const ctx = document.getElementById('myChart').getContext('2d');
- new Chart(ctx, {
- type: 'bar',
- data: {
- labels: result.rows.map(r => r.name),
- datasets: [{
- label: 'Entries',
- data: result.rows.map(r => r.entry_count),
- backgroundColor: '#6366f180',
- borderColor: '#6366f1',
- borderWidth: 1,
- borderRadius: 6,
- }]
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: { legend: { display: false } },
- scales: {
- y: { beginAtZero: true, grid: { color: '#2a2a4530' } },
- x: { grid: { display: false } },
- }
- }
- });
-}
-```
-
-### D3.js Visualization
-
-```html
-
-```
-
-```javascript
-async function renderViz() {
- const result = await window.dench.db.query("SELECT * FROM pivot_people LIMIT 100");
- const data = result.rows;
-
- const margin = { top: 20, right: 20, bottom: 40, left: 60 };
- const width = window.innerWidth - margin.left - margin.right;
- const height = 400 - margin.top - margin.bottom;
-
- const svg = d3.select('#chart')
- .append('svg')
- .attr('width', width + margin.left + margin.right)
- .attr('height', height + margin.top + margin.bottom)
- .append('g')
- .attr('transform', `translate(${margin.left},${margin.top})`);
-
- // Build scales, axes, bindings as needed
-}
-```
-
-### CSS-Only Stat Cards (No Library Needed)
-
-For simple metric displays, plain HTML/CSS is often better than a charting library:
-
-```html
-
-
-
Total Records
-
—
-
+12% this week
-
-
-```
-
-```css
-.stats-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 16px;
- padding: 24px;
-}
-
-.stat-card {
- padding: 20px;
- border-radius: 12px;
- background: var(--app-surface);
- border: 1px solid var(--app-border);
-}
-
-.stat-label {
- font-size: 13px;
- color: var(--app-text-muted);
- margin-bottom: 8px;
-}
-
-.stat-value {
- font-size: 36px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
-}
-
-.stat-change {
- font-size: 12px;
- margin-top: 4px;
-}
-
-.stat-change.positive { color: var(--app-success); }
-.stat-change.negative { color: var(--app-error); }
-```
-
----
-
-## Interactive Tools & Utilities
-
-### Form-Based Tools
-
-For tools that collect input and process it:
-
-```html
-
-```
-
-```css
-.field { margin-bottom: 16px; }
-.field label {
- display: block;
- font-size: 13px;
- font-weight: 500;
- color: var(--app-text-muted);
- margin-bottom: 6px;
-}
-.field textarea, .field input, .field select {
- width: 100%;
- padding: 10px 12px;
- border-radius: 8px;
- border: 1px solid var(--app-border);
- background: var(--app-surface);
- color: var(--app-text);
- font-size: 14px;
- font-family: inherit;
- resize: vertical;
-}
-.field textarea:focus, .field input:focus {
- outline: none;
- border-color: var(--app-accent);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 20%, transparent);
-}
-button[type="submit"] {
- padding: 10px 20px;
- border: none;
- border-radius: 8px;
- background: var(--app-accent);
- color: white;
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: background 0.15s;
-}
-button[type="submit"]:hover { background: var(--app-accent-hover); }
-.output-box {
- margin-top: 16px;
- padding: 16px;
- border-radius: 8px;
- background: var(--app-surface);
- border: 1px solid var(--app-border);
- font-family: 'SF Mono', 'Fira Code', monospace;
- font-size: 13px;
- white-space: pre-wrap;
- max-height: 400px;
- overflow: auto;
-}
-```
-
-### Kanban / Drag-and-Drop
-
-For sortable/draggable interfaces, use the native HTML Drag and Drop API or load SortableJS:
-
-```html
-
-```
-
-```javascript
-document.querySelectorAll('.kanban-column').forEach(col => {
- Sortable.create(col, {
- group: 'tasks',
- animation: 150,
- ghostClass: 'drag-ghost',
- onEnd: (evt) => {
- // Persist order change via DuckDB if needed
- },
- });
-});
-```
-
----
-
## Multi-File App Organization
For complex apps, split code across multiple files:
@@ -1544,7 +411,7 @@ const ui = new UI(game);
async function init() {
if (window.dench) {
- const theme = await window.dench.app.getTheme();
+ const theme = await dench.app.getTheme();
renderer.setTheme(theme);
}
game.start();
@@ -1714,7 +581,7 @@ Always wrap bridge calls in try/catch:
```javascript
async function loadData() {
try {
- const result = await window.dench.db.query("SELECT * FROM objects");
+ const result = await dench.db.query("SELECT * FROM objects");
return result.rows || [];
} catch (err) {
console.error('Failed to load data:', err.message);
@@ -1747,17 +614,15 @@ function showError(message) {
```javascript
async function init() {
- // Apply theme (degrade gracefully if bridge unavailable)
try {
- const theme = await window.dench.app.getTheme();
+ const theme = await dench.app.getTheme();
document.body.className = theme;
} catch {
document.body.className = 'dark';
}
- // Load data (show fallback UI if unavailable)
try {
- const data = await window.dench.db.query("SELECT * FROM objects");
+ const data = await dench.db.query("SELECT * FROM objects");
renderDashboard(data.rows);
} catch {
renderEmptyState('No data available. Make sure the app has database permission.');
@@ -1767,569 +632,37 @@ async function init() {
---
-## Full Example Apps
-
-### Example 1: Arcade Game (p5.js)
-
-A complete asteroid-dodge game with scoring, particles, and game states.
-
-**`.dench.yaml`:**
-```yaml
-name: "Asteroid Dodge"
-description: "Dodge the falling asteroids! Arrow keys or WASD to move."
-icon: "rocket"
-version: "1.0.0"
-entry: "index.html"
-runtime: "static"
-```
-
-**`index.html`:**
-```html
-
-
-
-
-
- Asteroid Dodge
-
-
-
-
-
-
-
-```
-
-**`game.js`:**
-```javascript
-const State = { MENU: 0, PLAY: 1, OVER: 2 };
-let state = State.MENU;
-let player, asteroids, particles, stars;
-let score, highScore = 0, spawnTimer, difficulty;
-
-function setup() {
- createCanvas(windowWidth, windowHeight);
- textFont('system-ui');
-
- stars = Array.from({ length: 100 }, () => ({
- x: random(width), y: random(height), s: random(1, 3), b: random(100, 255)
- }));
-
- if (window.dench) {
- window.dench.app.getTheme().catch(() => {});
- }
-}
-
-function resetGame() {
- player = { x: width / 2, y: height - 80, size: 24, speed: 5, lives: 3, invincible: 0 };
- asteroids = [];
- particles = [];
- score = 0;
- spawnTimer = 0;
- difficulty = 1;
-}
-
-function draw() {
- background(10, 10, 26);
- drawStars();
-
- switch (state) {
- case State.MENU: drawMenu(); break;
- case State.PLAY: updateGame(); drawGame(); drawHUD(); break;
- case State.OVER: drawGame(); drawHUD(); drawGameOver(); break;
- }
-}
-
-function drawStars() {
- noStroke();
- for (const s of stars) {
- fill(255, s.b);
- ellipse(s.x, s.y, s.s);
- s.y += s.s * 0.3;
- if (s.y > height) { s.y = 0; s.x = random(width); }
- }
-}
-
-function drawMenu() {
- fill(255);
- textAlign(CENTER, CENTER);
- textSize(min(width * 0.08, 56));
- textStyle(BOLD);
- text('ASTEROID DODGE', width / 2, height / 2 - 60);
- textSize(min(width * 0.03, 18));
- textStyle(NORMAL);
- fill(180);
- text('Arrow keys or WASD to move', width / 2, height / 2 + 10);
- fill(99, 102, 241);
- text('Press SPACE or ENTER to start', width / 2, height / 2 + 50);
- if (highScore > 0) {
- fill(120);
- textSize(14);
- text('High Score: ' + highScore, width / 2, height / 2 + 90);
- }
-}
-
-function updateGame() {
- // Player movement
- if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) player.x -= player.speed;
- if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) player.x += player.speed;
- if (keyIsDown(UP_ARROW) || keyIsDown(87)) player.y -= player.speed;
- if (keyIsDown(DOWN_ARROW) || keyIsDown(83)) player.y += player.speed;
- player.x = constrain(player.x, player.size, width - player.size);
- player.y = constrain(player.y, player.size, height - player.size);
- if (player.invincible > 0) player.invincible--;
-
- // Spawn asteroids
- difficulty = 1 + score / 500;
- spawnTimer++;
- if (spawnTimer > max(15, 45 - difficulty * 3)) {
- asteroids.push({
- x: random(width), y: -30,
- size: random(15, 35),
- vy: random(2, 4) * difficulty,
- vx: random(-1, 1),
- rot: random(TWO_PI),
- rotSpeed: random(-0.05, 0.05),
- });
- spawnTimer = 0;
- }
-
- // Update asteroids
- for (let i = asteroids.length - 1; i >= 0; i--) {
- const a = asteroids[i];
- a.y += a.vy;
- a.x += a.vx;
- a.rot += a.rotSpeed;
-
- if (a.y > height + 50) {
- asteroids.splice(i, 1);
- score += 10;
- continue;
- }
-
- // Collision
- if (player.invincible <= 0 && dist(player.x, player.y, a.x, a.y) < player.size + a.size / 2) {
- spawnParticles(a.x, a.y, color(239, 68, 68), 20);
- asteroids.splice(i, 1);
- player.lives--;
- player.invincible = 90;
- if (player.lives <= 0) {
- highScore = max(highScore, score);
- state = State.OVER;
- }
- }
- }
-
- // Update particles
- for (let i = particles.length - 1; i >= 0; i--) {
- const p = particles[i];
- p.x += p.vx; p.y += p.vy; p.vy += 0.05; p.life -= 0.02;
- if (p.life <= 0) particles.splice(i, 1);
- }
-
- score++;
-}
-
-function drawGame() {
- // Draw asteroids
- for (const a of asteroids) {
- push();
- translate(a.x, a.y);
- rotate(a.rot);
- fill(120, 120, 140);
- stroke(80, 80, 100);
- strokeWeight(1);
- beginShape();
- for (let i = 0; i < 7; i++) {
- const angle = map(i, 0, 7, 0, TWO_PI);
- const r = a.size / 2 * (0.7 + 0.3 * sin(i * 2.5));
- vertex(cos(angle) * r, sin(angle) * r);
- }
- endShape(CLOSE);
- pop();
- }
-
- // Draw particles
- noStroke();
- for (const p of particles) {
- fill(red(p.col), green(p.col), blue(p.col), p.life * 255);
- ellipse(p.x, p.y, p.size * p.life);
- }
-
- // Draw player
- if (state === State.PLAY) {
- if (player.invincible <= 0 || frameCount % 6 < 3) {
- push();
- translate(player.x, player.y);
- fill(99, 102, 241);
- noStroke();
- triangle(0, -player.size, -player.size * 0.6, player.size * 0.6, player.size * 0.6, player.size * 0.6);
- fill(129, 140, 248);
- triangle(0, -player.size * 0.5, -player.size * 0.3, player.size * 0.3, player.size * 0.3, player.size * 0.3);
- pop();
- }
- }
-}
-
-function drawHUD() {
- fill(255);
- noStroke();
- textAlign(LEFT, TOP);
- textSize(20);
- textStyle(BOLD);
- text('Score: ' + score, 20, 20);
- textStyle(NORMAL);
- textSize(14);
- fill(200);
- for (let i = 0; i < player.lives; i++) {
- fill(239, 68, 68);
- ellipse(20 + i * 22, 55, 14);
- }
-}
-
-function drawGameOver() {
- fill(0, 0, 0, 150);
- rect(0, 0, width, height);
- fill(239, 68, 68);
- textAlign(CENTER, CENTER);
- textSize(min(width * 0.07, 48));
- textStyle(BOLD);
- text('GAME OVER', width / 2, height / 2 - 40);
- fill(255);
- textSize(22);
- textStyle(NORMAL);
- text('Score: ' + score, width / 2, height / 2 + 10);
- fill(180);
- textSize(16);
- text('Press SPACE to play again', width / 2, height / 2 + 50);
-}
-
-function spawnParticles(x, y, col, count) {
- for (let i = 0; i < count; i++) {
- const angle = random(TWO_PI);
- const speed = random(1, 5);
- particles.push({
- x, y, vx: cos(angle) * speed, vy: sin(angle) * speed,
- size: random(4, 10), col, life: 1.0,
- });
- }
-}
-
-function keyPressed() {
- if (state === State.MENU && (key === ' ' || key === 'Enter')) {
- state = State.PLAY;
- resetGame();
- } else if (state === State.OVER && (key === ' ' || key === 'Enter')) {
- state = State.PLAY;
- resetGame();
- }
-}
-
-function windowResized() {
- resizeCanvas(windowWidth, windowHeight);
-}
-```
-
-### Example 2: 3D Scene Viewer (Three.js)
-
-**`.dench.yaml`:**
-```yaml
-name: "3D Playground"
-description: "Interactive 3D scene with orbit controls"
-icon: "box"
-version: "1.0.0"
-entry: "index.html"
-runtime: "static"
-```
-
-**`index.html`:**
-```html
-
-
-
-
-
- 3D Playground
-
-
-
-
-
-
-
-```
-
-**`scene.js`:**
-```javascript
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-const scene = new THREE.Scene();
-let bgColor = 0x0f0f1a;
-
-if (window.dench) {
- window.dench.app.getTheme().then(t => {
- bgColor = t === 'light' ? 0xf0f0f5 : 0x0f0f1a;
- scene.background = new THREE.Color(bgColor);
- scene.fog = new THREE.Fog(bgColor, 30, 100);
- }).catch(() => {});
-}
-
-scene.background = new THREE.Color(bgColor);
-scene.fog = new THREE.Fog(bgColor, 30, 100);
-
-const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500);
-camera.position.set(8, 6, 12);
-
-const renderer = new THREE.WebGLRenderer({ antialias: true });
-renderer.setSize(innerWidth, innerHeight);
-renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
-renderer.shadowMap.enabled = true;
-renderer.shadowMap.type = THREE.PCFSoftShadowMap;
-document.body.appendChild(renderer.domElement);
-
-const controls = new OrbitControls(camera, renderer.domElement);
-controls.enableDamping = true;
-
-scene.add(new THREE.AmbientLight(0x404060, 0.6));
-const sun = new THREE.DirectionalLight(0xffffff, 1.5);
-sun.position.set(10, 20, 10);
-sun.castShadow = true;
-sun.shadow.mapSize.set(1024, 1024);
-scene.add(sun);
-
-const ground = new THREE.Mesh(
- new THREE.PlaneGeometry(60, 60),
- new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 })
-);
-ground.rotation.x = -Math.PI / 2;
-ground.receiveShadow = true;
-scene.add(ground);
-
-const shapes = [];
-const colors = [0x6366f1, 0x22c55e, 0xf59e0b, 0xef4444, 0x06b6d4];
-
-for (let i = 0; i < 12; i++) {
- const geos = [
- new THREE.BoxGeometry(1, 1, 1),
- new THREE.SphereGeometry(0.6, 32, 32),
- new THREE.ConeGeometry(0.5, 1.2, 6),
- new THREE.TorusGeometry(0.5, 0.2, 16, 32),
- new THREE.OctahedronGeometry(0.6),
- ];
- const geo = geos[Math.floor(Math.random() * geos.length)];
- const mat = new THREE.MeshStandardMaterial({
- color: colors[Math.floor(Math.random() * colors.length)],
- roughness: 0.3, metalness: 0.5,
- });
- const mesh = new THREE.Mesh(geo, mat);
- mesh.position.set(
- (Math.random() - 0.5) * 16,
- 0.5 + Math.random() * 3,
- (Math.random() - 0.5) * 16
- );
- mesh.castShadow = true;
- mesh.userData = {
- baseY: mesh.position.y,
- phase: Math.random() * Math.PI * 2,
- rotSpeed: (Math.random() - 0.5) * 0.02,
- };
- scene.add(mesh);
- shapes.push(mesh);
-}
-
-const clock = new THREE.Clock();
-
-function animate() {
- requestAnimationFrame(animate);
- const t = clock.getElapsedTime();
-
- for (const s of shapes) {
- s.position.y = s.userData.baseY + Math.sin(t + s.userData.phase) * 0.4;
- s.rotation.y += s.userData.rotSpeed;
- }
-
- controls.update();
- renderer.render(scene, camera);
-}
-animate();
-
-addEventListener('resize', () => {
- camera.aspect = innerWidth / innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize(innerWidth, innerHeight);
-});
-```
-
-### Example 3: Data Dashboard
-
-**`.dench.yaml`:**
-```yaml
-name: "Dashboard"
-description: "Workspace overview dashboard"
-icon: "layout-dashboard"
-version: "1.0.0"
-entry: "index.html"
-runtime: "static"
-permissions:
- - database
-```
-
-**`index.html`:**
-```html
-
-
-
-
-
- Dashboard
-
-
-
- Workspace Dashboard
-
-
-
-
-```
-
----
-
-## DuckDB Data Integration
-
-Apps with the `database` permission can query the workspace DuckDB.
-
-### Common Queries
-
-```javascript
-// List all objects (tables)
-const objects = await window.dench.db.query("SELECT * FROM objects");
-
-// Get entries from an object by name
-const people = await window.dench.db.query(
- "SELECT * FROM entries WHERE object_id = (SELECT id FROM objects WHERE name = 'people')"
-);
-
-// Get field definitions
-const fields = await window.dench.db.query(
- "SELECT * FROM fields WHERE object_id = (SELECT id FROM objects WHERE name = 'people')"
-);
-
-// Use PIVOT views for tabular display
-const data = await window.dench.db.query("SELECT * FROM pivot_people");
-
-// Aggregate queries
-const stats = await window.dench.db.query(`
- SELECT
- o.name,
- COUNT(e.id) as count,
- MIN(e.created_at) as earliest,
- MAX(e.created_at) as latest
- FROM objects o
- LEFT JOIN entries e ON e.object_id = o.id
- GROUP BY o.name
- ORDER BY count DESC
-`);
-```
-
-### Creating App-Specific Tables
-
-Apps can create their own tables for storing app-specific data:
-
-```javascript
-await window.dench.db.execute(`
- CREATE TABLE IF NOT EXISTS app_settings (
- key TEXT PRIMARY KEY,
- value TEXT,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
-`);
-
-await window.dench.db.execute(
- `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('theme_preference', 'dark')`
-);
-```
-
----
-
## Creating an App — Step by Step Checklist
When asked to build an app, follow these steps:
-1. **Determine the app type** — game (2D/3D), dashboard, tool, visualization, etc.
+1. **Determine the app type** — game (2D/3D), dashboard, tool, visualization, AI chat, widget, etc.
2. **Choose the right library**:
- - 2D game / simulation / generative art → **p5.js** (always)
- - 3D game / scene / visualization → **Three.js** (always)
- - Data dashboard → **Chart.js** or **plain HTML/CSS**
+ - 2D game / simulation / generative art → **p5.js** (always) — see **game-builder** child skill
+ - 3D game / scene / visualization → **Three.js** (always) — see **game-builder** child skill
+ - Data dashboard / CRUD app → **Chart.js** or **plain HTML/CSS** — see **data-builder** child skill
+ - AI-powered app / chat UI → use `dench.chat.*` API — see **agent-builder** child skill
- Interactive tool / form → **plain HTML/CSS/JS**
3. **Create the app folder**: `apps/.dench.app/`
-4. **Create `.dench.yaml`** with manifest (always include `name`, `entry`, `runtime`)
+4. **Create `.dench.yaml`** with manifest (always include `name`, `entry`, `runtime`, and needed `permissions`)
5. **Create `index.html`** as the entry point with CDN script tags
6. **Create separate JS file(s)** for app logic — avoid massive inline scripts
-7. **Apply theme** via `window.dench.app.getTheme()` on init
+7. **Apply theme** via `dench.app.getTheme()` on init
8. **Handle window resizing** (canvas-based apps must call `resizeCanvas` / update renderer)
9. **Add error handling** for all bridge API calls
10. **Test the app** opens correctly as a tab in DenchClaw
+
+---
+
+## Child Skills
+
+This skill covers app fundamentals. For specialized APIs, see these child skills (all inside the `app-builder/` skill folder):
+
+| Skill | Path | Covers |
+|-------|------|--------|
+| **Game Builder** | `app-builder/game-builder/SKILL.md` | 2D games with p5.js, 3D games with Three.js, physics (Matter.js), audio, sprites, particles, tilemaps, game state machines, complete game examples |
+| **Data Builder** | `app-builder/data-builder/SKILL.md` | Workspace objects CRUD (`dench.objects.*`), DuckDB queries and mutations (`dench.db.*`), Chart.js and D3.js dashboards, stat cards, interactive tools, CRUD form patterns |
+| **Agent Builder** | `app-builder/agent-builder/SKILL.md` | AI chat API (`dench.chat.*`), streaming responses, app-as-tool (`dench.tool.*`), agent memory access, Gateway WebSocket protocol, chat UI patterns |
+| **Platform API** | `app-builder/platform-api/SKILL.md` | UI integration (`dench.ui.*`), per-app KV store (`dench.store.*`), HTTP proxy (`dench.http.*`), real-time events (`dench.events.*`), inter-app messaging (`dench.apps.*`), cron scheduling (`dench.cron.*`), webhooks (`dench.webhooks.*`), clipboard (`dench.clipboard.*`), widget mode, context |
+
+All child skills are seeded into the workspace alongside this parent skill and can be read at `{{WORKSPACE_PATH}}/skills/app-builder//SKILL.md`.
diff --git a/skills/app-builder/agent-builder/SKILL.md b/skills/app-builder/agent-builder/SKILL.md
new file mode 100644
index 00000000000..63021e1919f
--- /dev/null
+++ b/skills/app-builder/agent-builder/SKILL.md
@@ -0,0 +1,277 @@
+---
+name: agent-builder
+description: Build AI-powered DenchClaw apps that interact with the OpenClaw agent — create chat sessions, send and receive messages with streaming, expose app tools for agent invocation, and access agent memory.
+metadata: { "openclaw": { "inject": true, "always": true, "emoji": "🤖" } }
+---
+
+# App Agent Builder
+
+This skill covers building apps that interact with the AI agent. For core app structure and manifest basics, see the parent **app-builder** skill (`app-builder/SKILL.md`).
+
+## Chat API (`agent` permission required)
+
+### Creating Sessions
+
+```javascript
+// Create a new chat session
+const { sessionId } = await dench.chat.createSession("My Analysis Session");
+
+// List existing sessions
+const sessions = await dench.chat.getSessions({ limit: 20 });
+// Returns array of { id, title, createdAt, ... }
+```
+
+### Sending Messages with Streaming
+
+```javascript
+const result = await dench.chat.send(sessionId, "Analyze the people table and summarize trends", {
+ onEvent(event) {
+ switch (event.type) {
+ case "text-delta":
+ appendToChat(event.data);
+ break;
+ case "reasoning-delta":
+ updateThinking(event.data);
+ break;
+ case "tool-input-start":
+ showToolCall(event.name, event.args);
+ break;
+ case "tool-output-available":
+ showToolResult(event.result);
+ break;
+ }
+ }
+});
+// result contains the full accumulated response: { text, toolCalls, reasoning }
+```
+
+### Chat History & Control
+
+```javascript
+// Get message history for a session
+const messages = await dench.chat.getHistory(sessionId);
+// Returns array of { role, content, toolCalls?, ... }
+
+// Check if a session has an active run
+const isActive = await dench.chat.isActive(sessionId);
+
+// Abort an active run
+await dench.chat.abort(sessionId);
+```
+
+### Simple Agent Message (fire-and-forget)
+
+```javascript
+// Send a one-off message to the agent (no streaming, no session management)
+await dench.agent.send("Remind me to check the reports tomorrow at 9am");
+```
+
+## App-as-Tool (`agent` permission required)
+
+Apps can expose themselves as tools that the agent can invoke. Declare tools in the manifest:
+
+```yaml
+name: "Chart Analyzer"
+permissions:
+ - agent
+tools:
+ - name: "analyze-chart"
+ description: "Generates a visual chart analysis from structured data"
+ inputSchema:
+ type: object
+ properties:
+ data:
+ type: array
+ description: "Array of data points"
+ chartType:
+ type: string
+ enum: ["bar", "line", "pie", "scatter"]
+ required: ["data", "chartType"]
+```
+
+Register tool handlers in the app:
+
+```javascript
+dench.tool.register("analyze-chart", async (input) => {
+ const { data, chartType } = input;
+
+ // Process the data and generate the chart
+ const chart = renderChart(data, chartType);
+ const analysis = analyzeData(data);
+
+ // Return result to the agent
+ return {
+ analysis: analysis,
+ chartImageUrl: chart.toDataURL(),
+ summary: `Generated ${chartType} chart with ${data.length} data points`
+ };
+});
+```
+
+When the agent invokes the tool, the app receives the input, processes it, and returns the result. The app must be open (active tab) for tool invocation to work.
+
+## Agent Memory Access (`agent` permission required)
+
+```javascript
+// Read the agent's memory (MEMORY.md + daily logs)
+const memory = await dench.memory.get();
+// Returns { memory: "...", dailyLogs: [...] }
+```
+
+## Gateway WebSocket Protocol (Advanced)
+
+For advanced apps that want to build their own chat UI or need direct Gateway access, here's the WebSocket protocol:
+
+### Connection
+
+```javascript
+const ws = new WebSocket("ws://127.0.0.1:18789");
+// Port is configurable via gateway.port in ~/.openclaw-dench/openclaw.json
+```
+
+### Frame Types
+
+```javascript
+// Request (client -> gateway)
+{ type: "req", id: "uuid", method: "agent", params: { ... } }
+
+// Response (gateway -> client)
+{ type: "res", id: "uuid", ok: true, payload: { ... } }
+
+// Event (gateway -> client)
+{ type: "event", event: "agent", seq: 1, payload: { ... } }
+```
+
+### Connection Handshake
+
+```javascript
+ws.onopen = () => {
+ ws.send(JSON.stringify({
+ type: "req",
+ id: crypto.randomUUID(),
+ method: "connect",
+ params: {
+ minProtocol: 3,
+ maxProtocol: 3,
+ client: { id: "my-app", version: "1.0", platform: "web", mode: "backend" },
+ role: "user",
+ scopes: ["agent", "chat"],
+ caps: ["tool-events"]
+ }
+ }));
+};
+```
+
+### Sending Messages
+
+```javascript
+// Start an agent run
+ws.send(JSON.stringify({
+ type: "req",
+ id: crypto.randomUUID(),
+ method: "agent",
+ params: {
+ message: "Hello, analyze this data",
+ sessionKey: "agent:main:web:my-session-id",
+ channel: "webchat",
+ lane: "web"
+ }
+}));
+
+// Abort a run
+ws.send(JSON.stringify({
+ type: "req",
+ id: crypto.randomUUID(),
+ method: "chat.abort",
+ params: { sessionKey: "agent:main:web:my-session-id" }
+}));
+```
+
+### Agent Events
+
+```javascript
+ws.onmessage = (e) => {
+ const frame = JSON.parse(e.data);
+ if (frame.type !== "event" || frame.event !== "agent") return;
+
+ const { stream, data } = frame.payload;
+
+ switch (stream) {
+ case "lifecycle":
+ // data.phase: "start" | "end" | "error"
+ break;
+ case "thinking":
+ // data.delta: incremental reasoning text
+ break;
+ case "assistant":
+ // data.delta: incremental response text
+ // data.text: full text (on completion)
+ // data.stopReason: "end_turn" | "tool_use" | etc.
+ break;
+ case "tool":
+ // data.phase: "start" | "update" | "result"
+ // data.name, data.args, data.result
+ break;
+ }
+};
+```
+
+NOTE: For most apps, the bridge chat API (`dench.chat.*`) is much simpler than direct WebSocket usage. Use the Gateway WS only when you need full control over the connection.
+
+## Patterns
+
+### Chat UI App
+
+```javascript
+let currentSessionId = null;
+const chatContainer = document.getElementById("chat");
+
+async function startNewChat() {
+ const { sessionId } = await dench.chat.createSession("App Chat");
+ currentSessionId = sessionId;
+ chatContainer.innerHTML = "";
+}
+
+async function sendMessage(text) {
+ appendMessage("user", text);
+ const responseEl = appendMessage("assistant", "");
+
+ await dench.chat.send(currentSessionId, text, {
+ onEvent(event) {
+ if (event.type === "text-delta") {
+ responseEl.textContent += event.data;
+ }
+ }
+ });
+}
+
+function appendMessage(role, content) {
+ const div = document.createElement("div");
+ div.className = `message ${role}`;
+ div.textContent = content;
+ chatContainer.appendChild(div);
+ chatContainer.scrollTop = chatContainer.scrollHeight;
+ return div;
+}
+```
+
+### Agent-Powered Data Analysis
+
+```javascript
+async function analyzeData(objectName) {
+ const schema = await dench.objects.getSchema(objectName);
+ const { sessionId } = await dench.chat.createSession("Data Analysis");
+
+ const result = await dench.chat.send(sessionId,
+ `Analyze the ${objectName} object. It has these fields: ${schema.fields.map(f => f.name).join(", ")}. ` +
+ `Query the data and provide insights.`,
+ {
+ onEvent(event) {
+ if (event.type === "text-delta") updateAnalysisPanel(event.data);
+ }
+ }
+ );
+
+ showFinalAnalysis(result.text);
+}
+```
diff --git a/skills/app-builder/data-builder/SKILL.md b/skills/app-builder/data-builder/SKILL.md
new file mode 100644
index 00000000000..86f98945673
--- /dev/null
+++ b/skills/app-builder/data-builder/SKILL.md
@@ -0,0 +1,993 @@
+---
+name: data-builder
+description: Build data-driven DenchClaw apps with full CRUD access to workspace objects (.object.yaml tables), DuckDB queries and mutations, data dashboards with Chart.js and D3.js, and interactive tools.
+metadata: { "openclaw": { "inject": true, "always": true, "emoji": "📊" } }
+---
+
+# App Data Builder
+
+This skill covers building data apps that interact with workspace objects and DuckDB. For core app structure and manifest basics, see the parent **app-builder** skill (`app-builder/SKILL.md`).
+
+---
+
+## Objects CRUD API (`objects` permission required)
+
+The `window.dench.objects.*` API provides full CRUD access to workspace objects (`.object.yaml` tables). Add `objects` to your manifest permissions:
+
+```yaml
+permissions:
+ - objects
+```
+
+### List Entries
+
+```javascript
+const result = await dench.objects.list("people", {
+ search: "john",
+ filters: JSON.stringify([{ field: "Status", operator: "eq", value: "Active" }]),
+ sort: JSON.stringify({ field: "Full Name", direction: "asc" }),
+ page: 1,
+ pageSize: 50
+});
+// Returns { object, fields, entries, totalCount, statuses }
+```
+
+Filter operators: `eq`, `neq`, `contains`, `not_contains`, `starts_with`, `ends_with`, `gt`, `gte`, `lt`, `lte`, `is_empty`, `is_not_empty`.
+
+### Get a Single Entry
+
+```javascript
+const entry = await dench.objects.get("people", "entry_id_here");
+// Returns { entry: { id, fields: { "Full Name": "...", ... }, created_at, updated_at } }
+```
+
+### Create an Entry
+
+```javascript
+const { entryId } = await dench.objects.create("people", {
+ "Full Name": "Jane Doe",
+ "Email Address": "jane@example.com",
+ "Status": "Active"
+});
+```
+
+### Update an Entry
+
+```javascript
+await dench.objects.update("people", entryId, {
+ "Status": "Lead"
+});
+```
+
+### Delete an Entry
+
+```javascript
+await dench.objects.delete("people", entryId);
+```
+
+### Bulk Delete Entries
+
+```javascript
+await dench.objects.bulkDelete("people", [id1, id2, id3]);
+```
+
+### Get Object Schema
+
+```javascript
+const schema = await dench.objects.getSchema("people");
+// Returns { object, fields, statuses }
+```
+
+### Get Relation Options
+
+```javascript
+const options = await dench.objects.getOptions("people", "jane");
+// Returns filtered list of entries matching query
+```
+
+Use this for building relation dropdowns and autocomplete fields that reference entries in other objects.
+
+---
+
+## Database Access (`database` / `database:write` permissions)
+
+The `database` permission grants read-only query access. The `database:write` permission grants full mutation access (INSERT, UPDATE, DELETE, CREATE TABLE, etc.).
+
+```yaml
+permissions:
+ - database # SELECT queries only
+ - database:write # SELECT + mutations
+```
+
+### Read Queries (`database` permission)
+
+```javascript
+const result = await dench.db.query("SELECT * FROM objects");
+// Returns { rows: [...] }
+```
+
+### Mutations (`database:write` permission)
+
+```javascript
+await dench.db.execute("INSERT INTO game_scores (game, score) VALUES ('my-game', 1500)");
+await dench.db.execute("CREATE TABLE IF NOT EXISTS app_data (key TEXT PRIMARY KEY, value TEXT)");
+await dench.db.execute("UPDATE app_data SET value = 'new' WHERE key = 'setting1'");
+await dench.db.execute("DELETE FROM app_data WHERE key = 'old'");
+```
+
+### DuckDB Workspace Schema
+
+The workspace database uses an Entity-Attribute-Value (EAV) schema:
+
+| Table | Columns | Description |
+|-------|---------|-------------|
+| `objects` | id, name, description, icon | Workspace object definitions |
+| `fields` | id, object_id, name, type, required, position | Field definitions for each object |
+| `entries` | id, object_id, created_at, updated_at | Row entries in each object |
+| `entry_fields` | id, entry_id, field_id, value | Individual cell values (EAV) |
+| `statuses` | id, object_id, name, color, position | Status options for status-type fields |
+
+**PIVOT views** provide columnar access to object data:
+
+```sql
+SELECT * FROM v_people
+-- Returns rows with columns like: id, "Full Name", "Email Address", "Status", ...
+```
+
+The view name is `v_{object_name}` where the object name is lowercased with spaces replaced by underscores.
+
+### Common Queries
+
+```javascript
+// List all objects
+const objects = await dench.db.query("SELECT * FROM objects");
+
+// Get entries via PIVOT view
+const people = await dench.db.query("SELECT * FROM v_people");
+
+// Aggregate stats
+const stats = await dench.db.query(`
+ SELECT o.name, COUNT(e.id) as count
+ FROM objects o LEFT JOIN entries e ON e.object_id = o.id
+ GROUP BY o.name ORDER BY count DESC
+`);
+
+// Get field definitions
+const fields = await dench.db.query(
+ "SELECT * FROM fields WHERE object_id = (SELECT id FROM objects WHERE name = 'people')"
+);
+```
+
+### Creating App-Specific Tables
+
+Apps can create their own tables for storing app-specific data. Always use `CREATE TABLE IF NOT EXISTS` for idempotency:
+
+```javascript
+await dench.db.execute(`
+ CREATE TABLE IF NOT EXISTS app_settings (
+ key TEXT PRIMARY KEY, value TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+`);
+```
+
+---
+
+## Data Dashboards & Visualization
+
+### Chart.js Dashboard
+
+```html
+
+
+
+
+
+ Chart Dashboard
+
+
+
+
+ Object Entries
+
+
+
+
+
+
+```
+
+### D3.js Visualization
+
+```html
+
+
+
+
+
+ D3 Visualization
+
+
+
+
+ Workspace Overview
+
+
+
+
+```
+
+### CSS-Only Stat Cards
+
+No charting library needed — use CSS grid and custom properties for simple metric displays:
+
+```html
+
+
+
Total Records
+
—
+
+12% this week
+
+
+
Active Users
+
—
+
+5 today
+
+
+
+```
+
+```css
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ padding: 24px;
+}
+
+.stat-card {
+ padding: 20px;
+ border-radius: 12px;
+ background: var(--app-surface);
+ border: 1px solid var(--app-border);
+}
+
+.stat-label {
+ font-size: 13px;
+ color: var(--app-text-muted);
+ margin-bottom: 8px;
+}
+
+.stat-value {
+ font-size: 36px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+}
+
+.stat-change {
+ font-size: 12px;
+ margin-top: 4px;
+}
+
+.stat-change.positive { color: var(--app-success); }
+.stat-change.negative { color: var(--app-error); }
+```
+
+---
+
+## Interactive Tools & Utilities
+
+### Form-Based Tools
+
+Template for tools that collect input, process it, and display output:
+
+```html
+
+```
+
+```css
+.tool-container {
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+.field { margin-bottom: 16px; }
+
+.field label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--app-text-muted);
+ margin-bottom: 6px;
+}
+
+.field textarea, .field input, .field select {
+ width: 100%;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--app-border);
+ background: var(--app-surface);
+ color: var(--app-text);
+ font-size: 14px;
+ font-family: inherit;
+ resize: vertical;
+}
+
+.field textarea:focus, .field input:focus {
+ outline: none;
+ border-color: var(--app-accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 20%, transparent);
+}
+
+button[type="submit"] {
+ padding: 10px 20px;
+ border: none;
+ border-radius: 8px;
+ background: var(--app-accent);
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+button[type="submit"]:hover { background: var(--app-accent-hover); }
+
+.output-box {
+ margin-top: 16px;
+ padding: 16px;
+ border-radius: 8px;
+ background: var(--app-surface);
+ border: 1px solid var(--app-border);
+ font-family: 'SF Mono', 'Fira Code', monospace;
+ font-size: 13px;
+ white-space: pre-wrap;
+ max-height: 400px;
+ overflow: auto;
+}
+```
+
+### Kanban / Drag-and-Drop
+
+Use SortableJS for draggable interfaces backed by workspace objects:
+
+```html
+
+```
+
+```javascript
+async function initKanban() {
+ const schema = await dench.objects.getSchema("tasks");
+ const statuses = schema.statuses || [];
+
+ const board = document.getElementById('board');
+ for (const status of statuses) {
+ const column = document.createElement('div');
+ column.className = 'kanban-column';
+ column.dataset.status = status.name;
+ column.innerHTML = `
+
+ ${status.name}
+
+
+ `;
+ board.appendChild(column);
+ }
+
+ const result = await dench.objects.list("tasks", { pageSize: 200 });
+ for (const entry of result.entries) {
+ const status = entry.fields["Status"] || statuses[0]?.name;
+ const body = board.querySelector(`.column-body[data-status="${status}"]`);
+ if (body) {
+ const card = document.createElement('div');
+ card.className = 'kanban-card';
+ card.dataset.id = entry.id;
+ card.textContent = entry.fields["Title"] || entry.id;
+ body.appendChild(card);
+ }
+ }
+
+ document.querySelectorAll('.column-body').forEach(col => {
+ Sortable.create(col, {
+ group: 'tasks',
+ animation: 150,
+ ghostClass: 'drag-ghost',
+ onEnd: async (evt) => {
+ const entryId = evt.item.dataset.id;
+ const newStatus = evt.to.dataset.status;
+ try {
+ await dench.objects.update("tasks", entryId, { "Status": newStatus });
+ } catch (err) {
+ console.error('Failed to update status:', err);
+ }
+ },
+ });
+ });
+}
+```
+
+---
+
+## Patterns
+
+### CRUD Form App Pattern
+
+A complete pattern for a form that creates, reads, updates, and deletes entries in a workspace object:
+
+```javascript
+let currentEntries = [];
+let editingId = null;
+
+async function loadEntries() {
+ const result = await dench.objects.list("tasks", { pageSize: 100 });
+ currentEntries = result.entries;
+ renderTable(result.entries);
+}
+
+function renderTable(entries) {
+ const tbody = document.getElementById('entries-body');
+ tbody.innerHTML = entries.map(e => `
+
+ | ${e.fields["Title"] || ''} |
+ ${e.fields["Status"] || ''} |
+ ${new Date(e.created_at).toLocaleDateString()} |
+
+
+
+ |
+
+ `).join('');
+}
+
+async function createEntry(formData) {
+ try {
+ const { entryId } = await dench.objects.create("tasks", formData);
+ await loadEntries();
+ dench.ui.toast("Entry created", { type: "success" });
+ resetForm();
+ } catch (err) {
+ dench.ui.toast("Failed: " + err.message, { type: "error" });
+ }
+}
+
+async function updateEntry(id, formData) {
+ try {
+ await dench.objects.update("tasks", id, formData);
+ await loadEntries();
+ dench.ui.toast("Entry updated", { type: "success" });
+ resetForm();
+ } catch (err) {
+ dench.ui.toast("Failed: " + err.message, { type: "error" });
+ }
+}
+
+async function deleteEntry(id) {
+ if (!confirm('Delete this entry?')) return;
+ try {
+ await dench.objects.delete("tasks", id);
+ await loadEntries();
+ dench.ui.toast("Entry deleted", { type: "success" });
+ } catch (err) {
+ dench.ui.toast("Failed: " + err.message, { type: "error" });
+ }
+}
+
+function editEntry(id) {
+ const entry = currentEntries.find(e => e.id === id);
+ if (!entry) return;
+ editingId = id;
+ document.getElementById('title').value = entry.fields["Title"] || '';
+ document.getElementById('status').value = entry.fields["Status"] || '';
+ document.getElementById('submit-btn').textContent = 'Update';
+}
+
+function resetForm() {
+ editingId = null;
+ document.getElementById('task-form').reset();
+ document.getElementById('submit-btn').textContent = 'Create';
+}
+
+document.getElementById('task-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const formData = {
+ "Title": document.getElementById('title').value,
+ "Status": document.getElementById('status').value,
+ };
+ if (editingId) {
+ await updateEntry(editingId, formData);
+ } else {
+ await createEntry(formData);
+ }
+});
+
+loadEntries();
+```
+
+### Dashboard with Live Refresh
+
+Pattern for an auto-refreshing dashboard that polls for updated data:
+
+```javascript
+const REFRESH_INTERVAL = 30000; // 30 seconds
+let refreshTimer = null;
+
+async function loadDashboard() {
+ try {
+ const stats = await dench.db.query(`
+ SELECT o.name, COUNT(e.id) as count
+ FROM objects o LEFT JOIN entries e ON e.object_id = o.id
+ GROUP BY o.name ORDER BY count DESC
+ `);
+
+ renderStats(stats.rows);
+ document.getElementById('last-updated').textContent =
+ 'Updated: ' + new Date().toLocaleTimeString();
+ } catch (err) {
+ console.error('Refresh failed:', err);
+ }
+}
+
+function startAutoRefresh() {
+ loadDashboard();
+ refreshTimer = setInterval(loadDashboard, REFRESH_INTERVAL);
+}
+
+function stopAutoRefresh() {
+ if (refreshTimer) {
+ clearInterval(refreshTimer);
+ refreshTimer = null;
+ }
+}
+
+document.addEventListener('visibilitychange', () => {
+ if (document.hidden) {
+ stopAutoRefresh();
+ } else {
+ startAutoRefresh();
+ }
+});
+
+startAutoRefresh();
+```
+
+---
+
+## Full Example: Data Dashboard
+
+A complete workspace dashboard app with stat cards, theme support, and live data from DuckDB.
+
+**`.dench.yaml`:**
+
+```yaml
+name: "Dashboard"
+description: "Workspace overview dashboard"
+icon: "layout-dashboard"
+version: "1.0.0"
+entry: "index.html"
+runtime: "static"
+permissions:
+ - database
+```
+
+**`index.html`:**
+
+```html
+
+
+
+
+
+ Workspace Dashboard
+
+
+
+
+
+
+
Loading workspace data...
+
+
+
+
+```
diff --git a/skills/app-builder/game-builder/SKILL.md b/skills/app-builder/game-builder/SKILL.md
new file mode 100644
index 00000000000..086b648843a
--- /dev/null
+++ b/skills/app-builder/game-builder/SKILL.md
@@ -0,0 +1,1379 @@
+---
+name: game-builder
+description: Build 2D and 3D games as DenchClaw apps using p5.js, Three.js, Matter.js, and other game libraries. Covers game architecture, sprites, physics, particles, audio, tilemaps, and complete game examples.
+metadata: { "openclaw": { "inject": true, "always": true, "emoji": "🎮" } }
+---
+
+# App Game Builder
+
+This skill covers building 2D and 3D games as DenchClaw apps. For core app structure, manifest reference, and bridge API basics, see the parent **app-builder** skill (`app-builder/SKILL.md`).
+
+---
+
+## 2D Games with p5.js
+
+**Always use p5.js for 2D games, simulations, generative art, and interactive 2D experiences.** p5.js is the default choice for anything 2D unless the user specifically requests something else.
+
+### When to Use p5.js
+
+- 2D games (platformer, puzzle, arcade, card games, board games)
+- Generative art and creative coding
+- Physics simulations and particle systems
+- Interactive data visualizations with animation
+- Educational simulations and demonstrations
+- Drawing and painting tools
+- Any 2D canvas-based interactive experience
+
+### p5.js App Template
+
+```
+apps/my-game.dench.app/
+ .dench.yaml
+ index.html
+ sketch.js
+ assets/ # sprites, sounds, fonts
+```
+
+**`.dench.yaml`:**
+```yaml
+name: "My Game"
+description: "A fun 2D game built with p5.js"
+icon: "gamepad-2"
+version: "1.0.0"
+entry: "index.html"
+runtime: "static"
+```
+
+No permissions needed unless the game reads/writes workspace data.
+
+**`index.html`:**
+```html
+
+
+
+
+
+ My Game
+
+
+
+
+
+
+
+```
+
+**`sketch.js` (game loop skeleton):**
+```javascript
+let isDark = true;
+
+function setup() {
+ createCanvas(windowWidth, windowHeight);
+
+ // Detect theme from DenchClaw
+ if (window.dench) {
+ window.dench.app.getTheme().then(theme => {
+ isDark = theme === 'dark';
+ }).catch(() => {});
+ }
+}
+
+function draw() {
+ background(isDark ? 15 : 245);
+
+ // Game rendering goes here
+}
+
+function windowResized() {
+ resizeCanvas(windowWidth, windowHeight);
+}
+```
+
+### p5.js Instance Mode (Recommended for Complex Apps)
+
+Use instance mode to avoid global namespace pollution. This is especially important for multi-file apps:
+
+```javascript
+const sketch = (p) => {
+ let isDark = true;
+ let player;
+
+ p.setup = () => {
+ p.createCanvas(p.windowWidth, p.windowHeight);
+ player = { x: p.width / 2, y: p.height / 2, size: 30, speed: 4 };
+
+ if (window.dench) {
+ window.dench.app.getTheme().then(theme => { isDark = theme === 'dark'; }).catch(() => {});
+ }
+ };
+
+ p.draw = () => {
+ p.background(isDark ? 15 : 245);
+
+ // Input handling
+ if (p.keyIsDown(p.LEFT_ARROW) || p.keyIsDown(65)) player.x -= player.speed;
+ if (p.keyIsDown(p.RIGHT_ARROW) || p.keyIsDown(68)) player.x += player.speed;
+ if (p.keyIsDown(p.UP_ARROW) || p.keyIsDown(87)) player.y -= player.speed;
+ if (p.keyIsDown(p.DOWN_ARROW) || p.keyIsDown(83)) player.y += player.speed;
+
+ // Keep in bounds
+ player.x = p.constrain(player.x, 0, p.width);
+ player.y = p.constrain(player.y, 0, p.height);
+
+ // Draw player
+ p.fill(isDark ? '#6366f1' : '#4f46e5');
+ p.noStroke();
+ p.ellipse(player.x, player.y, player.size);
+ };
+
+ p.windowResized = () => {
+ p.resizeCanvas(p.windowWidth, p.windowHeight);
+ };
+};
+
+new p5(sketch);
+```
+
+### p5.js Game Architecture Patterns
+
+#### Game State Machine
+
+```javascript
+const GameState = { MENU: 'menu', PLAYING: 'playing', PAUSED: 'paused', GAME_OVER: 'gameover' };
+let state = GameState.MENU;
+let score = 0;
+let highScore = 0;
+
+function draw() {
+ switch (state) {
+ case GameState.MENU: drawMenu(); break;
+ case GameState.PLAYING: drawGame(); break;
+ case GameState.PAUSED: drawPause(); break;
+ case GameState.GAME_OVER: drawGameOver(); break;
+ }
+}
+
+function keyPressed() {
+ if (state === GameState.MENU && (key === ' ' || key === 'Enter')) {
+ state = GameState.PLAYING;
+ resetGame();
+ } else if (state === GameState.PLAYING && key === 'Escape') {
+ state = GameState.PAUSED;
+ } else if (state === GameState.PAUSED && key === 'Escape') {
+ state = GameState.PLAYING;
+ } else if (state === GameState.GAME_OVER && (key === ' ' || key === 'Enter')) {
+ state = GameState.PLAYING;
+ resetGame();
+ }
+}
+
+function drawMenu() {
+ background(15);
+ fill(255);
+ textAlign(CENTER, CENTER);
+ textSize(48);
+ text('MY GAME', width / 2, height / 2 - 60);
+ textSize(18);
+ fill(150);
+ text('Press SPACE or ENTER to start', width / 2, height / 2 + 20);
+ if (highScore > 0) {
+ textSize(14);
+ text('High Score: ' + highScore, width / 2, height / 2 + 60);
+ }
+}
+
+function drawGameOver() {
+ background(15);
+ fill('#ef4444');
+ textAlign(CENTER, CENTER);
+ textSize(48);
+ text('GAME OVER', width / 2, height / 2 - 60);
+ fill(255);
+ textSize(24);
+ text('Score: ' + score, width / 2, height / 2);
+ textSize(16);
+ fill(150);
+ text('Press SPACE to play again', width / 2, height / 2 + 50);
+}
+```
+
+#### Sprite Management
+
+```javascript
+class Sprite {
+ constructor(x, y, w, h) {
+ this.pos = createVector(x, y);
+ this.vel = createVector(0, 0);
+ this.w = w;
+ this.h = h;
+ this.alive = true;
+ }
+
+ update() {
+ this.pos.add(this.vel);
+ }
+
+ draw() {
+ rectMode(CENTER);
+ rect(this.pos.x, this.pos.y, this.w, this.h);
+ }
+
+ collidesWith(other) {
+ return (
+ this.pos.x - this.w / 2 < other.pos.x + other.w / 2 &&
+ this.pos.x + this.w / 2 > other.pos.x - other.w / 2 &&
+ this.pos.y - this.h / 2 < other.pos.y + other.h / 2 &&
+ this.pos.y + this.h / 2 > other.pos.y - other.h / 2
+ );
+ }
+
+ isOffscreen() {
+ return (
+ this.pos.x < -this.w || this.pos.x > width + this.w ||
+ this.pos.y < -this.h || this.pos.y > height + this.h
+ );
+ }
+}
+```
+
+#### Particle System
+
+```javascript
+class Particle {
+ constructor(x, y, color) {
+ this.pos = createVector(x, y);
+ this.vel = p5.Vector.random2D().mult(random(1, 5));
+ this.acc = createVector(0, 0.1);
+ this.color = color;
+ this.alpha = 255;
+ this.size = random(3, 8);
+ this.life = 1.0;
+ this.decay = random(0.01, 0.04);
+ }
+
+ update() {
+ this.vel.add(this.acc);
+ this.pos.add(this.vel);
+ this.life -= this.decay;
+ this.alpha = this.life * 255;
+ }
+
+ draw() {
+ noStroke();
+ fill(red(this.color), green(this.color), blue(this.color), this.alpha);
+ ellipse(this.pos.x, this.pos.y, this.size);
+ }
+
+ isDead() {
+ return this.life <= 0;
+ }
+}
+
+let particles = [];
+
+function spawnExplosion(x, y, col, count = 30) {
+ for (let i = 0; i < count; i++) {
+ particles.push(new Particle(x, y, col));
+ }
+}
+
+function updateParticles() {
+ for (let i = particles.length - 1; i >= 0; i--) {
+ particles[i].update();
+ particles[i].draw();
+ if (particles[i].isDead()) particles.splice(i, 1);
+ }
+}
+```
+
+#### Camera / Scrolling
+
+```javascript
+let camera = { x: 0, y: 0 };
+
+function draw() {
+ background(15);
+
+ // Follow player
+ camera.x = lerp(camera.x, player.x - width / 2, 0.1);
+ camera.y = lerp(camera.y, player.y - height / 2, 0.1);
+
+ push();
+ translate(-camera.x, -camera.y);
+
+ // Draw world (in world coordinates)
+ drawWorld();
+ drawPlayer();
+ drawEnemies();
+
+ pop();
+
+ // Draw HUD (in screen coordinates)
+ drawHUD();
+}
+```
+
+#### Tilemap Rendering
+
+```javascript
+const TILE_SIZE = 32;
+const tilemap = [
+ [1, 1, 1, 1, 1, 1, 1, 1],
+ [1, 0, 0, 0, 0, 0, 0, 1],
+ [1, 0, 0, 2, 0, 0, 0, 1],
+ [1, 0, 0, 0, 0, 3, 0, 1],
+ [1, 1, 1, 1, 1, 1, 1, 1],
+];
+
+const TILE_COLORS = {
+ 0: null, // empty
+ 1: '#4a4a6a', // wall
+ 2: '#22c55e', // item
+ 3: '#ef4444', // enemy
+};
+
+function drawTilemap() {
+ for (let row = 0; row < tilemap.length; row++) {
+ for (let col = 0; col < tilemap[row].length; col++) {
+ const tile = tilemap[row][col];
+ if (TILE_COLORS[tile]) {
+ fill(TILE_COLORS[tile]);
+ noStroke();
+ rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
+ }
+ }
+ }
+}
+```
+
+#### Sound Effects (using p5.sound or Howler.js)
+
+For sound, prefer Howler.js since p5.sound adds significant bundle size:
+
+```html
+
+```
+
+```javascript
+const sounds = {
+ jump: new Howl({ src: ['assets/jump.wav'], volume: 0.5 }),
+ hit: new Howl({ src: ['assets/hit.wav'], volume: 0.7 }),
+ coin: new Howl({ src: ['assets/coin.wav'], volume: 0.4 }),
+ music: new Howl({ src: ['assets/music.mp3'], loop: true, volume: 0.3 }),
+};
+```
+
+If no sound assets are available, generate simple audio with Tone.js or the Web Audio API:
+
+```javascript
+function playBeep(freq = 440, duration = 0.1) {
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain);
+ gain.connect(ctx.destination);
+ osc.frequency.value = freq;
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
+ osc.start();
+ osc.stop(ctx.currentTime + duration);
+}
+```
+
+### p5.js with Physics (Matter.js)
+
+For games needing realistic 2D physics (platformers, ragdoll, pinball):
+
+```html
+
+
+```
+
+```javascript
+const { Engine, World, Bodies, Body, Events } = Matter;
+
+let engine, world;
+let ground, player;
+
+function setup() {
+ createCanvas(windowWidth, windowHeight);
+ engine = Engine.create();
+ world = engine.world;
+
+ ground = Bodies.rectangle(width / 2, height - 20, width, 40, { isStatic: true });
+ player = Bodies.circle(width / 2, height / 2, 20, { restitution: 0.5 });
+
+ World.add(world, [ground, player]);
+}
+
+function draw() {
+ Engine.update(engine);
+ background(15);
+
+ // Draw ground
+ fill('#4a4a6a');
+ rectMode(CENTER);
+ rect(ground.position.x, ground.position.y, width, 40);
+
+ // Draw player
+ fill('#6366f1');
+ ellipse(player.position.x, player.position.y, 40);
+}
+
+function keyPressed() {
+ if (key === ' ') {
+ Body.applyForce(player, player.position, { x: 0, y: -0.05 });
+ }
+}
+```
+
+### p5.js Responsive Canvas
+
+Always handle window resizing and use the full viewport:
+
+```javascript
+function setup() {
+ createCanvas(windowWidth, windowHeight);
+}
+
+function windowResized() {
+ resizeCanvas(windowWidth, windowHeight);
+}
+```
+
+For fixed-aspect-ratio games (e.g., retro pixel games), scale the canvas:
+
+```javascript
+const GAME_W = 320;
+const GAME_H = 240;
+let scaleFactor;
+
+function setup() {
+ createCanvas(windowWidth, windowHeight);
+ pixelDensity(1);
+ noSmooth();
+ calcScale();
+}
+
+function calcScale() {
+ scaleFactor = min(windowWidth / GAME_W, windowHeight / GAME_H);
+}
+
+function draw() {
+ background(0);
+ push();
+ translate((width - GAME_W * scaleFactor) / 2, (height - GAME_H * scaleFactor) / 2);
+ scale(scaleFactor);
+
+ // All game drawing at GAME_W x GAME_H logical resolution
+ drawGameWorld();
+
+ pop();
+}
+
+function windowResized() {
+ resizeCanvas(windowWidth, windowHeight);
+ calcScale();
+}
+```
+
+### Touch / Mobile Input for p5.js Games
+
+```javascript
+let touchActive = false;
+let touchX = 0, touchY = 0;
+
+function touchStarted() {
+ touchActive = true;
+ touchX = mouseX;
+ touchY = mouseY;
+ return false; // prevent default
+}
+
+function touchMoved() {
+ touchX = mouseX;
+ touchY = mouseY;
+ return false;
+}
+
+function touchEnded() {
+ touchActive = false;
+ return false;
+}
+
+// Unified input: works for both mouse and touch
+function getInputX() { return mouseX; }
+function getInputY() { return mouseY; }
+function isInputActive() { return mouseIsPressed || touchActive; }
+```
+
+### p5.js High Score Persistence with DuckDB
+
+If the game has a `database` permission, persist high scores:
+
+```javascript
+async function loadHighScore() {
+ try {
+ await window.dench.db.execute(`
+ CREATE TABLE IF NOT EXISTS game_scores (
+ game TEXT, score INTEGER, played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ const result = await window.dench.db.query(
+ `SELECT MAX(score) as high_score FROM game_scores WHERE game = 'my-game'`
+ );
+ return result.rows?.[0]?.high_score || 0;
+ } catch { return 0; }
+}
+
+async function saveScore(score) {
+ try {
+ await window.dench.db.execute(
+ `INSERT INTO game_scores (game, score) VALUES ('my-game', ${score})`
+ );
+ } catch {}
+}
+```
+
+---
+
+## 3D Games & Experiences with Three.js
+
+**Always use Three.js for 3D games, visualizations, and interactive 3D experiences.** Three.js is the default choice for anything 3D.
+
+### When to Use Three.js
+
+- 3D games (first-person, third-person, flying, racing)
+- 3D product viewers and configurators
+- Terrain and world visualization
+- 3D data visualization (3D scatter plots, network graphs)
+- Architectural walkthroughs
+- Generative 3D art
+- Physics-based 3D simulations
+
+### Three.js App Template
+
+```
+apps/my-3d-app.dench.app/
+ .dench.yaml
+ index.html
+ app.js # Main Three.js module
+ assets/
+ model.glb # 3D models (optional)
+ texture.jpg # Textures (optional)
+```
+
+**`.dench.yaml`:**
+```yaml
+name: "3D World"
+description: "An interactive 3D experience"
+icon: "box"
+version: "1.0.0"
+entry: "index.html"
+runtime: "static"
+```
+
+**`index.html`:**
+```html
+
+
+
+
+
+ 3D World
+
+
+
+
+ Loading...
+
+
+
+```
+
+**`app.js` (Three.js module skeleton):**
+```javascript
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+// --- Scene setup ---
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x0f0f1a);
+scene.fog = new THREE.Fog(0x0f0f1a, 50, 200);
+
+const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
+camera.position.set(0, 5, 10);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+renderer.toneMapping = THREE.ACESFilmicToneMapping;
+renderer.toneMappingExposure = 1.0;
+document.body.appendChild(renderer.domElement);
+
+// --- Controls ---
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.05;
+controls.maxPolarAngle = Math.PI / 2;
+
+// --- Lighting ---
+const ambientLight = new THREE.AmbientLight(0x404060, 0.5);
+scene.add(ambientLight);
+
+const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
+directionalLight.position.set(10, 20, 10);
+directionalLight.castShadow = true;
+directionalLight.shadow.mapSize.set(2048, 2048);
+directionalLight.shadow.camera.near = 0.5;
+directionalLight.shadow.camera.far = 100;
+directionalLight.shadow.camera.left = -30;
+directionalLight.shadow.camera.right = 30;
+directionalLight.shadow.camera.top = 30;
+directionalLight.shadow.camera.bottom = -30;
+scene.add(directionalLight);
+
+// --- Ground ---
+const groundGeo = new THREE.PlaneGeometry(200, 200);
+const groundMat = new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 });
+const ground = new THREE.Mesh(groundGeo, groundMat);
+ground.rotation.x = -Math.PI / 2;
+ground.receiveShadow = true;
+scene.add(ground);
+
+// --- Objects ---
+const geometry = new THREE.BoxGeometry(2, 2, 2);
+const material = new THREE.MeshStandardMaterial({
+ color: 0x6366f1,
+ roughness: 0.3,
+ metalness: 0.5,
+});
+const cube = new THREE.Mesh(geometry, material);
+cube.position.y = 1;
+cube.castShadow = true;
+scene.add(cube);
+
+// --- Theme ---
+if (window.dench) {
+ window.dench.app.getTheme().then(theme => {
+ if (theme === 'light') {
+ scene.background = new THREE.Color(0xf0f0f5);
+ scene.fog = new THREE.Fog(0xf0f0f5, 50, 200);
+ groundMat.color.set(0xe8e8f0);
+ }
+ }).catch(() => {});
+}
+
+// --- Hide loading screen ---
+document.getElementById('loading')?.classList.add('hidden');
+
+// --- Animation loop ---
+const clock = new THREE.Clock();
+
+function animate() {
+ requestAnimationFrame(animate);
+ const dt = clock.getDelta();
+ const elapsed = clock.getElapsedTime();
+
+ cube.rotation.y = elapsed * 0.5;
+ cube.position.y = 1 + Math.sin(elapsed) * 0.5;
+
+ controls.update();
+ renderer.render(scene, camera);
+}
+
+animate();
+
+// --- Resize ---
+window.addEventListener('resize', () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+```
+
+### Three.js Common Addons
+
+Load additional Three.js modules as needed via the import map:
+
+```javascript
+// First-person controls
+import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
+
+// GLTF model loading
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+// Post-processing
+import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
+
+// Environment maps
+import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
+
+// Text
+import { FontLoader } from 'three/addons/loaders/FontLoader.js';
+import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
+
+// Sky
+import { Sky } from 'three/addons/objects/Sky.js';
+
+// Water
+import { Water } from 'three/addons/objects/Water.js';
+
+// Physics integration (use cannon-es via CDN)
+// Add to importmap: "cannon-es": "https://unpkg.com/cannon-es@0.20/dist/cannon-es.js"
+import * as CANNON from 'cannon-es';
+```
+
+### Three.js First-Person Game Pattern
+
+```javascript
+import * as THREE from 'three';
+import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
+
+const scene = new THREE.Scene();
+const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+document.body.appendChild(renderer.domElement);
+
+const controls = new PointerLockControls(camera, document.body);
+
+// Click to enter pointer lock
+document.addEventListener('click', () => {
+ if (!controls.isLocked) controls.lock();
+});
+
+// Movement state
+const velocity = new THREE.Vector3();
+const direction = new THREE.Vector3();
+const keys = { forward: false, backward: false, left: false, right: false, jump: false };
+
+document.addEventListener('keydown', (e) => {
+ switch (e.code) {
+ case 'KeyW': case 'ArrowUp': keys.forward = true; break;
+ case 'KeyS': case 'ArrowDown': keys.backward = true; break;
+ case 'KeyA': case 'ArrowLeft': keys.left = true; break;
+ case 'KeyD': case 'ArrowRight': keys.right = true; break;
+ case 'Space': keys.jump = true; break;
+ }
+});
+
+document.addEventListener('keyup', (e) => {
+ switch (e.code) {
+ case 'KeyW': case 'ArrowUp': keys.forward = false; break;
+ case 'KeyS': case 'ArrowDown': keys.backward = false; break;
+ case 'KeyA': case 'ArrowLeft': keys.left = false; break;
+ case 'KeyD': case 'ArrowRight': keys.right = false; break;
+ case 'Space': keys.jump = false; break;
+ }
+});
+
+let onGround = true;
+const MOVE_SPEED = 50;
+const JUMP_FORCE = 12;
+const GRAVITY = 30;
+
+const clock = new THREE.Clock();
+
+function animate() {
+ requestAnimationFrame(animate);
+ const dt = Math.min(clock.getDelta(), 0.1);
+
+ if (controls.isLocked) {
+ // Apply friction
+ velocity.x -= velocity.x * 10.0 * dt;
+ velocity.z -= velocity.z * 10.0 * dt;
+ velocity.y -= GRAVITY * dt;
+
+ direction.z = Number(keys.forward) - Number(keys.backward);
+ direction.x = Number(keys.right) - Number(keys.left);
+ direction.normalize();
+
+ if (keys.forward || keys.backward) velocity.z -= direction.z * MOVE_SPEED * dt;
+ if (keys.left || keys.right) velocity.x -= direction.x * MOVE_SPEED * dt;
+ if (keys.jump && onGround) { velocity.y = JUMP_FORCE; onGround = false; }
+
+ controls.moveRight(-velocity.x * dt);
+ controls.moveForward(-velocity.z * dt);
+ camera.position.y += velocity.y * dt;
+
+ if (camera.position.y < 1.7) {
+ velocity.y = 0;
+ camera.position.y = 1.7;
+ onGround = true;
+ }
+ }
+
+ renderer.render(scene, camera);
+}
+
+animate();
+```
+
+### Three.js GLTF Model Loading
+
+```javascript
+import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
+
+const loader = new GLTFLoader();
+const dracoLoader = new DRACOLoader();
+dracoLoader.setDecoderPath('https://unpkg.com/three@0.170/examples/jsm/libs/draco/');
+loader.setDRACOLoader(dracoLoader);
+
+// Load a model from the app's assets folder
+loader.load('assets/model.glb', (gltf) => {
+ const model = gltf.scene;
+ model.traverse((child) => {
+ if (child.isMesh) {
+ child.castShadow = true;
+ child.receiveShadow = true;
+ }
+ });
+ model.scale.setScalar(1);
+ scene.add(model);
+
+ // If the model has animations
+ if (gltf.animations.length > 0) {
+ const mixer = new THREE.AnimationMixer(model);
+ const action = mixer.clipAction(gltf.animations[0]);
+ action.play();
+ // In animate loop: mixer.update(dt);
+ }
+}, undefined, (error) => {
+ console.error('Model load error:', error);
+});
+```
+
+### Three.js Post-Processing (Bloom, etc.)
+
+```javascript
+import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
+import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
+
+const composer = new EffectComposer(renderer);
+composer.addPass(new RenderPass(scene, camera));
+
+const bloomPass = new UnrealBloomPass(
+ new THREE.Vector2(window.innerWidth, window.innerHeight),
+ 0.5, // strength
+ 0.4, // radius
+ 0.85 // threshold
+);
+composer.addPass(bloomPass);
+
+// In animate loop, replace renderer.render(scene, camera) with:
+// composer.render();
+
+// On resize, also update:
+// composer.setSize(window.innerWidth, window.innerHeight);
+```
+
+### Three.js Procedural Terrain
+
+```javascript
+function createTerrain(width, depth, resolution) {
+ const geometry = new THREE.PlaneGeometry(width, depth, resolution, resolution);
+ const vertices = geometry.attributes.position.array;
+
+ for (let i = 0; i < vertices.length; i += 3) {
+ const x = vertices[i];
+ const z = vertices[i + 1];
+ vertices[i + 2] = noise(x * 0.02, z * 0.02) * 15;
+ }
+
+ geometry.computeVertexNormals();
+
+ const material = new THREE.MeshStandardMaterial({
+ color: 0x3a7d44,
+ roughness: 0.9,
+ flatShading: true,
+ });
+
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.rotation.x = -Math.PI / 2;
+ mesh.receiveShadow = true;
+ return mesh;
+}
+
+// Simple noise function (for procedural generation without dependencies)
+function noise(x, y) {
+ const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
+ return n - Math.floor(n);
+}
+```
+
+### Three.js HUD / UI Overlay
+
+Since Three.js renders to a canvas, use HTML overlays for UI:
+
+```html
+
+```
+
+```javascript
+function updateHUD(score, health) {
+ document.getElementById('score').textContent = `Score: ${score}`;
+ document.getElementById('health-fill').style.width = `${health}%`;
+ document.getElementById('health-fill').style.background =
+ health > 50 ? '#22c55e' : health > 25 ? '#f59e0b' : '#ef4444';
+}
+```
+
+---
+
+## Full Game Examples
+
+### Example 1: Arcade Game (p5.js)
+
+A complete asteroid-dodge game with scoring, particles, and game states.
+
+**`.dench.yaml`:**
+```yaml
+name: "Asteroid Dodge"
+description: "Dodge the falling asteroids! Arrow keys or WASD to move."
+icon: "rocket"
+version: "1.0.0"
+entry: "index.html"
+runtime: "static"
+```
+
+**`index.html`:**
+```html
+
+
+
+
+
+ Asteroid Dodge
+
+
+
+
+
+
+
+```
+
+**`game.js`:**
+```javascript
+const State = { MENU: 0, PLAY: 1, OVER: 2 };
+let state = State.MENU;
+let player, asteroids, particles, stars;
+let score, highScore = 0, spawnTimer, difficulty;
+
+function setup() {
+ createCanvas(windowWidth, windowHeight);
+ textFont('system-ui');
+
+ stars = Array.from({ length: 100 }, () => ({
+ x: random(width), y: random(height), s: random(1, 3), b: random(100, 255)
+ }));
+
+ if (window.dench) {
+ window.dench.app.getTheme().catch(() => {});
+ }
+}
+
+function resetGame() {
+ player = { x: width / 2, y: height - 80, size: 24, speed: 5, lives: 3, invincible: 0 };
+ asteroids = [];
+ particles = [];
+ score = 0;
+ spawnTimer = 0;
+ difficulty = 1;
+}
+
+function draw() {
+ background(10, 10, 26);
+ drawStars();
+
+ switch (state) {
+ case State.MENU: drawMenu(); break;
+ case State.PLAY: updateGame(); drawGame(); drawHUD(); break;
+ case State.OVER: drawGame(); drawHUD(); drawGameOver(); break;
+ }
+}
+
+function drawStars() {
+ noStroke();
+ for (const s of stars) {
+ fill(255, s.b);
+ ellipse(s.x, s.y, s.s);
+ s.y += s.s * 0.3;
+ if (s.y > height) { s.y = 0; s.x = random(width); }
+ }
+}
+
+function drawMenu() {
+ fill(255);
+ textAlign(CENTER, CENTER);
+ textSize(min(width * 0.08, 56));
+ textStyle(BOLD);
+ text('ASTEROID DODGE', width / 2, height / 2 - 60);
+ textSize(min(width * 0.03, 18));
+ textStyle(NORMAL);
+ fill(180);
+ text('Arrow keys or WASD to move', width / 2, height / 2 + 10);
+ fill(99, 102, 241);
+ text('Press SPACE or ENTER to start', width / 2, height / 2 + 50);
+ if (highScore > 0) {
+ fill(120);
+ textSize(14);
+ text('High Score: ' + highScore, width / 2, height / 2 + 90);
+ }
+}
+
+function updateGame() {
+ // Player movement
+ if (keyIsDown(LEFT_ARROW) || keyIsDown(65)) player.x -= player.speed;
+ if (keyIsDown(RIGHT_ARROW) || keyIsDown(68)) player.x += player.speed;
+ if (keyIsDown(UP_ARROW) || keyIsDown(87)) player.y -= player.speed;
+ if (keyIsDown(DOWN_ARROW) || keyIsDown(83)) player.y += player.speed;
+ player.x = constrain(player.x, player.size, width - player.size);
+ player.y = constrain(player.y, player.size, height - player.size);
+ if (player.invincible > 0) player.invincible--;
+
+ // Spawn asteroids
+ difficulty = 1 + score / 500;
+ spawnTimer++;
+ if (spawnTimer > max(15, 45 - difficulty * 3)) {
+ asteroids.push({
+ x: random(width), y: -30,
+ size: random(15, 35),
+ vy: random(2, 4) * difficulty,
+ vx: random(-1, 1),
+ rot: random(TWO_PI),
+ rotSpeed: random(-0.05, 0.05),
+ });
+ spawnTimer = 0;
+ }
+
+ // Update asteroids
+ for (let i = asteroids.length - 1; i >= 0; i--) {
+ const a = asteroids[i];
+ a.y += a.vy;
+ a.x += a.vx;
+ a.rot += a.rotSpeed;
+
+ if (a.y > height + 50) {
+ asteroids.splice(i, 1);
+ score += 10;
+ continue;
+ }
+
+ // Collision
+ if (player.invincible <= 0 && dist(player.x, player.y, a.x, a.y) < player.size + a.size / 2) {
+ spawnParticles(a.x, a.y, color(239, 68, 68), 20);
+ asteroids.splice(i, 1);
+ player.lives--;
+ player.invincible = 90;
+ if (player.lives <= 0) {
+ highScore = max(highScore, score);
+ state = State.OVER;
+ }
+ }
+ }
+
+ // Update particles
+ for (let i = particles.length - 1; i >= 0; i--) {
+ const p = particles[i];
+ p.x += p.vx; p.y += p.vy; p.vy += 0.05; p.life -= 0.02;
+ if (p.life <= 0) particles.splice(i, 1);
+ }
+
+ score++;
+}
+
+function drawGame() {
+ // Draw asteroids
+ for (const a of asteroids) {
+ push();
+ translate(a.x, a.y);
+ rotate(a.rot);
+ fill(120, 120, 140);
+ stroke(80, 80, 100);
+ strokeWeight(1);
+ beginShape();
+ for (let i = 0; i < 7; i++) {
+ const angle = map(i, 0, 7, 0, TWO_PI);
+ const r = a.size / 2 * (0.7 + 0.3 * sin(i * 2.5));
+ vertex(cos(angle) * r, sin(angle) * r);
+ }
+ endShape(CLOSE);
+ pop();
+ }
+
+ // Draw particles
+ noStroke();
+ for (const p of particles) {
+ fill(red(p.col), green(p.col), blue(p.col), p.life * 255);
+ ellipse(p.x, p.y, p.size * p.life);
+ }
+
+ // Draw player
+ if (state === State.PLAY) {
+ if (player.invincible <= 0 || frameCount % 6 < 3) {
+ push();
+ translate(player.x, player.y);
+ fill(99, 102, 241);
+ noStroke();
+ triangle(0, -player.size, -player.size * 0.6, player.size * 0.6, player.size * 0.6, player.size * 0.6);
+ fill(129, 140, 248);
+ triangle(0, -player.size * 0.5, -player.size * 0.3, player.size * 0.3, player.size * 0.3, player.size * 0.3);
+ pop();
+ }
+ }
+}
+
+function drawHUD() {
+ fill(255);
+ noStroke();
+ textAlign(LEFT, TOP);
+ textSize(20);
+ textStyle(BOLD);
+ text('Score: ' + score, 20, 20);
+ textStyle(NORMAL);
+ textSize(14);
+ fill(200);
+ for (let i = 0; i < player.lives; i++) {
+ fill(239, 68, 68);
+ ellipse(20 + i * 22, 55, 14);
+ }
+}
+
+function drawGameOver() {
+ fill(0, 0, 0, 150);
+ rect(0, 0, width, height);
+ fill(239, 68, 68);
+ textAlign(CENTER, CENTER);
+ textSize(min(width * 0.07, 48));
+ textStyle(BOLD);
+ text('GAME OVER', width / 2, height / 2 - 40);
+ fill(255);
+ textSize(22);
+ textStyle(NORMAL);
+ text('Score: ' + score, width / 2, height / 2 + 10);
+ fill(180);
+ textSize(16);
+ text('Press SPACE to play again', width / 2, height / 2 + 50);
+}
+
+function spawnParticles(x, y, col, count) {
+ for (let i = 0; i < count; i++) {
+ const angle = random(TWO_PI);
+ const speed = random(1, 5);
+ particles.push({
+ x, y, vx: cos(angle) * speed, vy: sin(angle) * speed,
+ size: random(4, 10), col, life: 1.0,
+ });
+ }
+}
+
+function keyPressed() {
+ if (state === State.MENU && (key === ' ' || key === 'Enter')) {
+ state = State.PLAY;
+ resetGame();
+ } else if (state === State.OVER && (key === ' ' || key === 'Enter')) {
+ state = State.PLAY;
+ resetGame();
+ }
+}
+
+function windowResized() {
+ resizeCanvas(windowWidth, windowHeight);
+}
+```
+
+### Example 2: 3D Scene Viewer (Three.js)
+
+**`.dench.yaml`:**
+```yaml
+name: "3D Playground"
+description: "Interactive 3D scene with orbit controls"
+icon: "box"
+version: "1.0.0"
+entry: "index.html"
+runtime: "static"
+```
+
+**`index.html`:**
+```html
+
+
+
+
+
+ 3D Playground
+
+
+
+
+
+
+
+```
+
+**`scene.js`:**
+```javascript
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+const scene = new THREE.Scene();
+let bgColor = 0x0f0f1a;
+
+if (window.dench) {
+ window.dench.app.getTheme().then(t => {
+ bgColor = t === 'light' ? 0xf0f0f5 : 0x0f0f1a;
+ scene.background = new THREE.Color(bgColor);
+ scene.fog = new THREE.Fog(bgColor, 30, 100);
+ }).catch(() => {});
+}
+
+scene.background = new THREE.Color(bgColor);
+scene.fog = new THREE.Fog(bgColor, 30, 100);
+
+const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 500);
+camera.position.set(8, 6, 12);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setSize(innerWidth, innerHeight);
+renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+document.body.appendChild(renderer.domElement);
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+
+scene.add(new THREE.AmbientLight(0x404060, 0.6));
+const sun = new THREE.DirectionalLight(0xffffff, 1.5);
+sun.position.set(10, 20, 10);
+sun.castShadow = true;
+sun.shadow.mapSize.set(1024, 1024);
+scene.add(sun);
+
+const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(60, 60),
+ new THREE.MeshStandardMaterial({ color: 0x1a1a2e, roughness: 0.8 })
+);
+ground.rotation.x = -Math.PI / 2;
+ground.receiveShadow = true;
+scene.add(ground);
+
+const shapes = [];
+const colors = [0x6366f1, 0x22c55e, 0xf59e0b, 0xef4444, 0x06b6d4];
+
+for (let i = 0; i < 12; i++) {
+ const geos = [
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.SphereGeometry(0.6, 32, 32),
+ new THREE.ConeGeometry(0.5, 1.2, 6),
+ new THREE.TorusGeometry(0.5, 0.2, 16, 32),
+ new THREE.OctahedronGeometry(0.6),
+ ];
+ const geo = geos[Math.floor(Math.random() * geos.length)];
+ const mat = new THREE.MeshStandardMaterial({
+ color: colors[Math.floor(Math.random() * colors.length)],
+ roughness: 0.3, metalness: 0.5,
+ });
+ const mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(
+ (Math.random() - 0.5) * 16,
+ 0.5 + Math.random() * 3,
+ (Math.random() - 0.5) * 16
+ );
+ mesh.castShadow = true;
+ mesh.userData = {
+ baseY: mesh.position.y,
+ phase: Math.random() * Math.PI * 2,
+ rotSpeed: (Math.random() - 0.5) * 0.02,
+ };
+ scene.add(mesh);
+ shapes.push(mesh);
+}
+
+const clock = new THREE.Clock();
+
+function animate() {
+ requestAnimationFrame(animate);
+ const t = clock.getElapsedTime();
+
+ for (const s of shapes) {
+ s.position.y = s.userData.baseY + Math.sin(t + s.userData.phase) * 0.4;
+ s.rotation.y += s.userData.rotSpeed;
+ }
+
+ controls.update();
+ renderer.render(scene, camera);
+}
+animate();
+
+addEventListener('resize', () => {
+ camera.aspect = innerWidth / innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(innerWidth, innerHeight);
+});
+```
diff --git a/skills/app-builder/platform-api/SKILL.md b/skills/app-builder/platform-api/SKILL.md
new file mode 100644
index 00000000000..a495cfaf551
--- /dev/null
+++ b/skills/app-builder/platform-api/SKILL.md
@@ -0,0 +1,338 @@
+---
+name: platform-api
+description: Platform API reference for DenchClaw apps — UI integration, per-app storage, HTTP proxy, real-time events, inter-app messaging, cron scheduling, webhooks, clipboard, context, and widget mode.
+metadata: { "openclaw": { "inject": true, "always": true, "emoji": "⚡" } }
+---
+
+# App Platform API
+
+This skill documents the platform-level APIs available to DenchClaw apps. For core app structure, see the parent **app-builder** skill. For data/objects, see **data-builder**. For AI chat, see **agent-builder**.
+
+## UI Integration (`ui` permission required)
+
+```javascript
+// Show a toast notification in the parent DenchClaw UI
+await dench.ui.toast("Record saved successfully", { type: "success" });
+await dench.ui.toast("Something went wrong", { type: "error" });
+await dench.ui.toast("Processing...", { type: "info" });
+
+// Navigate DenchClaw to a workspace path (opens object, file, or app)
+await dench.ui.navigate("/people"); // open the people object
+await dench.ui.navigate("/apps/my-app.dench.app"); // open another app
+
+// Open an entry detail modal
+await dench.ui.openEntry("people", "entry_id_here");
+
+// Update the app's tab title dynamically
+await dench.ui.setTitle("My App — 5 results");
+
+// Show a confirmation dialog
+const confirmed = await dench.ui.confirm("Delete this record?");
+if (confirmed) { /* proceed */ }
+
+// Show a prompt dialog
+const name = await dench.ui.prompt("Enter a name:", "Default Name");
+if (name !== null) { /* use name */ }
+```
+
+## Per-App KV Store (`store` permission required)
+
+Persistent key-value storage scoped to each app. Data survives app reloads and is stored in the workspace.
+
+```javascript
+// Store a value (any JSON-serializable value)
+await dench.store.set("lastQuery", { sql: "SELECT * FROM people", timestamp: Date.now() });
+await dench.store.set("theme", "custom-dark");
+await dench.store.set("counter", 42);
+
+// Read a value
+const lastQuery = await dench.store.get("lastQuery");
+// Returns the value, or null if not found
+
+// Delete a key
+await dench.store.delete("lastQuery");
+
+// List all keys
+const keys = await dench.store.list();
+// Returns ["theme", "counter"]
+
+// Clear all stored data
+await dench.store.clear();
+```
+
+Storage is backed by a JSON file at `{workspace}/.dench-app-data/{appName}/store.json`. Each app gets its own isolated namespace.
+
+## HTTP Proxy (`http` permission required)
+
+Make HTTP requests from apps without CORS restrictions. Requests are proxied through the DenchClaw server.
+
+```javascript
+// Simple GET
+const data = await dench.http.fetch("https://api.example.com/data");
+// Returns { status, statusText, headers, body }
+
+// POST with headers and body
+const result = await dench.http.fetch("https://api.example.com/submit", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": "Bearer sk-..."
+ },
+ body: JSON.stringify({ query: "test" })
+});
+
+console.log(result.status); // 200
+console.log(result.body); // response body as string
+```
+
+Security: requests to localhost, private IPs, and internal DenchClaw URLs are blocked.
+
+## Real-time Events (`events` permission required)
+
+Subscribe to workspace events for live updates.
+
+```javascript
+// Subscribe to theme changes
+dench.events.on("theme.changed", (data) => {
+ document.body.className = data.theme;
+});
+
+// Subscribe to object entry changes
+dench.events.on("object.entry.created", (data) => {
+ console.log(`New entry in ${data.objectName}: ${data.entryId}`);
+ refreshList();
+});
+
+dench.events.on("object.entry.updated", (data) => {
+ console.log(`Entry ${data.entryId} updated in ${data.objectName}`);
+});
+
+dench.events.on("object.entry.deleted", (data) => {
+ console.log(`Entry ${data.entryId} deleted from ${data.objectName}`);
+});
+
+// App visibility events
+dench.events.on("app.visible", () => { resumeAnimations(); });
+dench.events.on("app.hidden", () => { pauseAnimations(); });
+
+// File change events
+dench.events.on("file.changed", (data) => {
+ console.log(`File changed: ${data.path}`);
+});
+
+// Unsubscribe
+dench.events.off("theme.changed");
+```
+
+## Context (no permission required)
+
+```javascript
+// Get workspace info
+const workspace = await dench.context.getWorkspace();
+// Returns { name, path, agentId }
+
+// Get app info
+const app = await dench.context.getAppInfo();
+// Returns { appPath, folderName, permissions, manifest }
+```
+
+## Inter-App Messaging (`apps` permission required)
+
+Apps can communicate with other open apps for composite workflows.
+
+```javascript
+// Send a message to another app
+await dench.apps.send("analytics-dashboard.dench.app", {
+ action: "refresh",
+ filter: { status: "Active" }
+});
+
+// Listen for messages from other apps
+dench.apps.on("message", (event) => {
+ console.log(`Message from ${event.from}:`, event.message);
+ if (event.message.action === "refresh") {
+ reloadData(event.message.filter);
+ }
+});
+
+// List currently active (open) apps
+const activeApps = await dench.apps.list();
+// Returns [{ name: "analytics-dashboard.dench.app", manifest: {...} }, ...]
+```
+
+## Cron Scheduling (`cron` permission required)
+
+Schedule recurring tasks that send messages to the agent.
+
+```javascript
+// Schedule a cron job
+const { jobId } = await dench.cron.schedule({
+ expression: "0 9 * * *", // 9 AM daily
+ message: "Generate the daily sales report and save it to workspace",
+ channel: "announce" // "announce" (delivers result) or "none" (silent)
+});
+
+// List all cron jobs
+const jobs = await dench.cron.list();
+// Returns array of { id, expression, message, enabled, nextRunAt, ... }
+
+// Run a job immediately
+await dench.cron.run(jobId);
+
+// Cancel/remove a job
+await dench.cron.cancel(jobId);
+```
+
+## Webhooks (`webhooks` permission required)
+
+Receive external HTTP webhooks inside your app.
+
+```javascript
+// Register a webhook endpoint
+const hook = await dench.webhooks.register("github-push");
+console.log(hook.url);
+// e.g. "https://your-denchclaw-host/api/apps/webhooks/my-app.dench.app/github-push"
+
+// Listen for incoming webhooks
+dench.webhooks.on("github-push", (payload) => {
+ console.log("Received webhook:", payload);
+ // payload: { method: "POST", headers: {...}, body: "...", receivedAt: 1234567890 }
+ processGithubPush(JSON.parse(payload.body));
+});
+
+// Poll for webhooks (useful for catching events received while app was closed)
+const events = await dench.webhooks.poll("github-push", { since: lastTimestamp });
+```
+
+## Clipboard (`clipboard` permission required)
+
+```javascript
+// Write to clipboard
+await dench.clipboard.write("Copied text content");
+
+// Read from clipboard
+const text = await dench.clipboard.read();
+```
+
+Note: clipboard operations are proxied through the parent DenchClaw window.
+
+## Widget Mode
+
+Apps can render as compact widgets in a dashboard grid instead of full-page tabs.
+
+### Manifest Configuration
+
+```yaml
+name: "Quick Stats"
+display: "widget"
+widget:
+ width: 2 # Grid columns (1-4)
+ height: 1 # Grid rows (1-4)
+ refreshInterval: 60 # Auto-refresh in seconds (optional)
+permissions:
+ - database
+```
+
+### Widget Design Guidelines
+
+- Keep the UI compact — widgets have limited space
+- Use large, readable numbers and minimal text
+- Avoid scroll bars — all content should be visible
+- Support both light and dark themes
+- Use the refresh interval for auto-updating data
+
+### Widget Example
+
+```html
+
+
+
+
+
+
+ Total Records
+ —
+
+
+
+```
+
+Widget-mode apps appear in the DenchClaw dashboard view alongside other widgets, arranged in a responsive grid.
+
+## Patterns
+
+### Multi-App Dashboard
+
+Build a dashboard that aggregates data from multiple widget apps:
+
+```javascript
+// In the main dashboard app
+async function loadWidgetData() {
+ const apps = await dench.apps.list();
+ const widgets = apps.filter(a => a.manifest.display === "widget");
+
+ for (const widget of widgets) {
+ // Request data from each widget app
+ await dench.apps.send(widget.name, { action: "getData" });
+ }
+}
+
+dench.apps.on("message", (event) => {
+ if (event.message.action === "dataResponse") {
+ updateDashboardPanel(event.from, event.message.data);
+ }
+});
+```
+
+### External API Integration
+
+```javascript
+// Fetch data from an external API via proxy
+async function loadWeather(city) {
+ const result = await dench.http.fetch(
+ `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=YOUR_KEY`
+ );
+ if (result.status === 200) {
+ const weather = JSON.parse(result.body);
+ displayWeather(weather);
+ }
+}
+```
+
+### Automation Workflow
+
+```javascript
+// Set up a cron job that processes data and sends results
+async function setupAutomation() {
+ const { jobId } = await dench.cron.schedule({
+ expression: "0 */6 * * *",
+ message: "Check the tasks object for overdue items and send a summary to Telegram"
+ });
+
+ await dench.store.set("automationJobId", jobId);
+ dench.ui.toast("Automation scheduled every 6 hours", { type: "success" });
+}
+```