docs(skills): refactor app-builder into focused child skills
Split monolithic skill into agent-builder, data-builder, game-builder, and platform-api child skills for better context efficiency.
This commit is contained in:
parent
ea8bab6179
commit
1de6943773
File diff suppressed because it is too large
Load Diff
277
skills/app-builder/agent-builder/SKILL.md
Normal file
277
skills/app-builder/agent-builder/SKILL.md
Normal file
@ -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);
|
||||
}
|
||||
```
|
||||
993
skills/app-builder/data-builder/SKILL.md
Normal file
993
skills/app-builder/data-builder/SKILL.md
Normal file
@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chart Dashboard</title>
|
||||
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
padding: 24px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
body.dark {
|
||||
--app-bg: #0f0f1a; --app-surface: #1a1a2e; --app-border: #2a2a45;
|
||||
--app-text: #e8e8f0; --app-text-muted: #8888a8; --app-accent: #6366f1;
|
||||
background: var(--app-bg); color: var(--app-text);
|
||||
}
|
||||
body.light {
|
||||
--app-bg: #ffffff; --app-surface: #f8f9fa; --app-border: #e2e4e8;
|
||||
--app-text: #1a1a2e; --app-text-muted: #6b7280; --app-accent: #6366f1;
|
||||
background: var(--app-bg); color: var(--app-text);
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 24px; }
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-surface);
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Object Entries</h1>
|
||||
<div class="chart-container">
|
||||
<canvas id="barChart"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
async function init() {
|
||||
try {
|
||||
const theme = await window.dench.app.getTheme();
|
||||
document.body.className = theme;
|
||||
} catch { document.body.className = 'dark'; }
|
||||
|
||||
try {
|
||||
const result = await window.dench.db.query(`
|
||||
SELECT o.name, COUNT(e.id) as entry_count
|
||||
FROM objects o LEFT JOIN entries e ON e.object_id = o.id
|
||||
GROUP BY o.name ORDER BY entry_count DESC
|
||||
`);
|
||||
|
||||
const isDark = document.body.classList.contains('dark');
|
||||
const textColor = isDark ? '#e8e8f0' : '#1a1a2e';
|
||||
const gridColor = isDark ? '#2a2a4530' : '#e2e4e830';
|
||||
|
||||
const ctx = document.getElementById('barChart').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: gridColor },
|
||||
ticks: { color: textColor },
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: textColor },
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
document.querySelector('.chart-container').innerHTML =
|
||||
'<p style="color:#ef4444">Error: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### D3.js Visualization
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>D3 Visualization</title>
|
||||
<script src="https://unpkg.com/d3@7/dist/d3.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
padding: 24px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
body.dark {
|
||||
--app-bg: #0f0f1a; --app-text: #e8e8f0; --app-accent: #6366f1;
|
||||
background: var(--app-bg); color: var(--app-text);
|
||||
}
|
||||
body.light {
|
||||
--app-bg: #ffffff; --app-text: #1a1a2e; --app-accent: #6366f1;
|
||||
background: var(--app-bg); color: var(--app-text);
|
||||
}
|
||||
h1 { font-size: 24px; margin-bottom: 24px; }
|
||||
#chart { width: 100%; }
|
||||
.bar { transition: opacity 0.2s; }
|
||||
.bar:hover { opacity: 0.8; }
|
||||
.axis text { fill: var(--app-text); font-size: 12px; }
|
||||
.axis path, .axis line { stroke: var(--app-text); opacity: 0.2; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Workspace Overview</h1>
|
||||
<div id="chart"></div>
|
||||
<script>
|
||||
async function init() {
|
||||
try {
|
||||
const theme = await window.dench.app.getTheme();
|
||||
document.body.className = theme;
|
||||
} catch { document.body.className = 'dark'; }
|
||||
|
||||
try {
|
||||
const result = await window.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
|
||||
`);
|
||||
const data = result.rows;
|
||||
|
||||
const margin = { top: 20, right: 20, bottom: 40, left: 60 };
|
||||
const width = Math.min(window.innerWidth - 48, 800) - 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})`);
|
||||
|
||||
const x = d3.scaleBand()
|
||||
.domain(data.map(d => d.name))
|
||||
.range([0, width])
|
||||
.padding(0.3);
|
||||
|
||||
const y = d3.scaleLinear()
|
||||
.domain([0, d3.max(data, d => d.count) || 1])
|
||||
.nice()
|
||||
.range([height, 0]);
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'axis')
|
||||
.attr('transform', `translate(0,${height})`)
|
||||
.call(d3.axisBottom(x));
|
||||
|
||||
svg.append('g')
|
||||
.attr('class', 'axis')
|
||||
.call(d3.axisLeft(y).ticks(5));
|
||||
|
||||
svg.selectAll('.bar')
|
||||
.data(data)
|
||||
.join('rect')
|
||||
.attr('class', 'bar')
|
||||
.attr('x', d => x(d.name))
|
||||
.attr('y', d => y(d.count))
|
||||
.attr('width', x.bandwidth())
|
||||
.attr('height', d => height - y(d.count))
|
||||
.attr('rx', 4)
|
||||
.attr('fill', '#6366f1');
|
||||
} catch (err) {
|
||||
document.getElementById('chart').innerHTML =
|
||||
'<p style="color:#ef4444">Error: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### CSS-Only Stat Cards
|
||||
|
||||
No charting library needed — use CSS grid and custom properties for simple metric displays:
|
||||
|
||||
```html
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Records</div>
|
||||
<div class="stat-value" id="total">—</div>
|
||||
<div class="stat-change positive">+12% this week</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Active Users</div>
|
||||
<div class="stat-value" id="active">—</div>
|
||||
<div class="stat-change positive">+5 today</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Objects</div>
|
||||
<div class="stat-value" id="objects">—</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```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
|
||||
<div class="tool-container">
|
||||
<form id="tool-form">
|
||||
<div class="field">
|
||||
<label for="input">Input</label>
|
||||
<textarea id="input" rows="6" placeholder="Paste your data here..."></textarea>
|
||||
</div>
|
||||
<button type="submit">Process</button>
|
||||
<div id="output" class="output-box"></div>
|
||||
</form>
|
||||
</div>
|
||||
```
|
||||
|
||||
```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
|
||||
<script src="https://unpkg.com/sortablejs@1/Sortable.min.js"></script>
|
||||
```
|
||||
|
||||
```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 = `
|
||||
<div class="column-header" style="border-color: ${status.color}">
|
||||
${status.name}
|
||||
</div>
|
||||
<div class="column-body" data-status="${status.name}"></div>
|
||||
`;
|
||||
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 => `
|
||||
<tr>
|
||||
<td>${e.fields["Title"] || ''}</td>
|
||||
<td>${e.fields["Status"] || ''}</td>
|
||||
<td>${new Date(e.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button onclick="editEntry('${e.id}')">Edit</button>
|
||||
<button onclick="deleteEntry('${e.id}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Workspace Dashboard</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 24px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
body.dark {
|
||||
--app-bg: #0f0f1a;
|
||||
--app-surface: #1a1a2e;
|
||||
--app-surface-hover: #252540;
|
||||
--app-border: #2a2a45;
|
||||
--app-text: #e8e8f0;
|
||||
--app-text-muted: #8888a8;
|
||||
--app-accent: #6366f1;
|
||||
--app-success: #22c55e;
|
||||
--app-warning: #f59e0b;
|
||||
--app-error: #ef4444;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
body.light {
|
||||
--app-bg: #ffffff;
|
||||
--app-surface: #f8f9fa;
|
||||
--app-surface-hover: #f0f1f3;
|
||||
--app-border: #e2e4e8;
|
||||
--app-text: #1a1a2e;
|
||||
--app-text-muted: #6b7280;
|
||||
--app-accent: #6366f1;
|
||||
--app-success: #16a34a;
|
||||
--app-warning: #d97706;
|
||||
--app-error: #dc2626;
|
||||
background: var(--app-bg);
|
||||
color: var(--app-text);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h1 { font-size: 24px; font-weight: 700; }
|
||||
|
||||
.header .meta {
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-surface);
|
||||
border: 1px solid var(--app-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
background: var(--app-surface-hover);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.objects-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--app-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.objects-table th {
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--app-text-muted);
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.objects-table td {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--app-border);
|
||||
}
|
||||
|
||||
.objects-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
.objects-table tr:hover td {
|
||||
background: var(--app-surface-hover);
|
||||
}
|
||||
|
||||
.error-box {
|
||||
padding: 16px;
|
||||
background: color-mix(in srgb, var(--app-error) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--app-error) 30%, transparent);
|
||||
border-radius: 8px;
|
||||
color: var(--app-error);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--app-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Workspace Dashboard</h1>
|
||||
<div class="meta" id="last-updated"></div>
|
||||
</div>
|
||||
<div class="stats-grid" id="stats"></div>
|
||||
<div id="table-container">
|
||||
<div class="loading">Loading workspace data...</div>
|
||||
</div>
|
||||
<script>
|
||||
async function init() {
|
||||
try {
|
||||
const theme = await window.dench.app.getTheme();
|
||||
document.body.className = theme;
|
||||
} catch {
|
||||
document.body.className = 'dark';
|
||||
}
|
||||
|
||||
await loadDashboard();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const result = await window.dench.db.query(`
|
||||
SELECT o.name, o.description, o.icon, COUNT(e.id) as entry_count
|
||||
FROM objects o
|
||||
LEFT JOIN entries e ON e.object_id = o.id
|
||||
GROUP BY o.name, o.description, o.icon
|
||||
ORDER BY entry_count DESC
|
||||
`);
|
||||
|
||||
const rows = result.rows || [];
|
||||
const totalObjects = rows.length;
|
||||
const totalEntries = rows.reduce((sum, r) => sum + (r.entry_count || 0), 0);
|
||||
|
||||
const statsEl = document.getElementById('stats');
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Objects</div>
|
||||
<div class="stat-value">${totalObjects}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Entries</div>
|
||||
<div class="stat-value">${totalEntries.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Avg Entries / Object</div>
|
||||
<div class="stat-value">${totalObjects ? Math.round(totalEntries / totalObjects) : 0}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tableContainer = document.getElementById('table-container');
|
||||
if (rows.length === 0) {
|
||||
tableContainer.innerHTML = '<div class="empty-state">No objects in workspace yet.</div>';
|
||||
} else {
|
||||
tableContainer.innerHTML = `
|
||||
<table class="objects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Object</th>
|
||||
<th>Description</th>
|
||||
<th>Entries</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(r => `
|
||||
<tr>
|
||||
<td><strong>${r.name}</strong></td>
|
||||
<td style="color: var(--app-text-muted)">${r.description || '—'}</td>
|
||||
<td>${r.entry_count || 0}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('last-updated').textContent =
|
||||
'Updated ' + new Date().toLocaleTimeString();
|
||||
} catch (err) {
|
||||
document.getElementById('stats').innerHTML = '';
|
||||
document.getElementById('table-container').innerHTML =
|
||||
'<div class="error-box">Error loading data: ' + err.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
1379
skills/app-builder/game-builder/SKILL.md
Normal file
1379
skills/app-builder/game-builder/SKILL.md
Normal file
File diff suppressed because it is too large
Load Diff
338
skills/app-builder/platform-api/SKILL.md
Normal file
338
skills/app-builder/platform-api/SKILL.md
Normal file
@ -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
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, sans-serif;
|
||||
padding: 16px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
body.dark { background: #0f0f1a; color: #e8e8f0; }
|
||||
body.light { background: #fff; color: #1a1a2e; }
|
||||
.metric { font-size: 48px; font-weight: 700; }
|
||||
.label { font-size: 13px; opacity: 0.6; margin-bottom: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">Total Records</div>
|
||||
<div class="metric" id="count">—</div>
|
||||
<script>
|
||||
async function init() {
|
||||
const theme = await dench.app.getTheme().catch(() => "dark");
|
||||
document.body.className = theme;
|
||||
|
||||
const result = await dench.db.query("SELECT SUM(entry_count) as total FROM objects");
|
||||
document.getElementById("count").textContent = result.rows[0]?.total ?? 0;
|
||||
}
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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" });
|
||||
}
|
||||
```
|
||||
Loading…
x
Reference in New Issue
Block a user