Build your first App
You will build a durable Counter with four source files. The button and the Agent Tool call the same Action, SQLite survives restart, and the View refreshes from a Projection after commit.
Before you start
Keep the simulator running with a persistent workspace as described in Getting started. Work from the PocketPi repository root.
1. Create the source directory
apps/counter/
├── app.json
├── schema.sql
├── actions.js
└── view.js2. Declare the App and its public Tool
Create apps/counter/app.json:
{
"format": 1,
"frameworkApi": 1,
"id": "counter",
"title": "Counter",
"description": "A durable counter shared by a person and the Agent",
"version": "0.1.0",
"schemaVersion": 1,
"capabilities": ["data.sqlite"],
"resources": {},
"toolNamespace": "counter",
"tools": [
{
"name": "counter.increment",
"action": "increment",
"description": "Increment the durable counter by 1 to 10.",
"parameters": {
"type": "object",
"properties": {
"by": { "type": "integer", "minimum": 1, "maximum": 10 }
},
"additionalProperties": false
}
}
],
"schedules": []
}counter.increment is the Agent-visible Tool name. increment is the local Action function. The native router maps one to the other after installation.
3. Create the durable Data
Create apps/counter/schema.sql:
CREATE TABLE counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
value INTEGER NOT NULL
);
INSERT INTO counter(id, value) VALUES(1, 0);A fresh install executes this final schema. The one-row check makes the invariant explicit instead of relying on Action code to avoid duplicate counters.
4. Implement the shared Action
Create apps/counter/actions.js:
function increment(args) {
const by = Number(args?.by ?? 1);
if (!Number.isInteger(by) || by < 1 || by > 10) {
throw new Error("by must be an integer from 1 to 10");
}
PocketPi.data.transaction(() => {
PocketPi.data.query(
"UPDATE counter SET value = value + ? WHERE id = 1",
[by],
);
});
const [row] = PocketPi.data.query(
"SELECT value FROM counter WHERE id = 1",
[],
);
return { value: row.value, incrementedBy: by };
}
PocketPi.defineActions({ increment });The transaction owns mutation and publishes one revision only after commit. Returning the current value gives the Agent an immediate domain result; the View still refreshes through SQLite rather than through this return value.
5. Project the Data into a fixed View
Create apps/counter/view.js:
const model = View.state({ value: 0, status: "READY" });
PocketPi.projection.one(
"SELECT value FROM counter WHERE id = 1",
{},
(row) => model.update({ value: row?.value ?? 0 }),
);
function render() {
const state = model.get();
return View.Screen({ children: [
View.Header({
title: "COUNTER",
metaTop: "POCKET APP",
metaBottom: "LOCAL SQLITE",
onBack: () => PocketPi.navigate("pi-agent"),
}),
View.Column({
style: { grow: 1, padding: 24, gap: 20 },
children: [
View.MetricCard({
label: "DURABLE VALUE",
value: () => String(model.get().value),
}),
View.Box({
style: { height: 84 },
children: View.ActionButton({
label: "+1",
onPress: () => PocketPi.action("increment", { by: 1 }),
}),
}),
],
}),
View.Box({
style: { height: 96 },
children: View.StatusBar({ text: state.status, dark: true }),
}),
] });
}
View.mount(render);The button returns an Action event. It does not call the function directly and does not write SQLite from the View Guest. Native routing sends the request to the App's Action Guest.
6. Package the source release
cargo xtask package app counterThe package is written to target/pocketapps/counter.pocketapp.
7. Upload and confirm in the simulator
curl --fail-with-body \
--data-binary @target/pocketapps/counter.pocketapp \
http://127.0.0.1:8080/install- The simulator switches to the shared App review screen.
- Confirm INSTALL.
- Open Apps and choose Counter.
- Tap +1; the value should refresh after the Action commits.
8. Exercise the same Action through the Agent
Return to Pi Agent and ask: “Use counter.increment to add 3.” The installed Tool routes to increment, commits the same database and refreshes the Counter View the next time it is foregrounded.
9. Verify persistence
Stop and restart the simulator with the same --workspace. Counter remains installed and the value remains in its SQLite database even though its previous QuickJS Guests are gone.
What you just proved
- one product behavior is shared by UI and Agent;
- the App, not firmware, owns the schema and Action;
- the View is fixed source and reads through a bounded Projection;
- durable state survives Guest eviction and restart;
- ordinary App installation does not require a firmware rebuild.