This tutorial builds task-counter: a view that counts saved tasks, a collection, and a scheduled script. It does not need an AI account. Tasks are initially empty; use the record-write example in the SDK guide to add a task from your own form.
Chat's development skill is /skills/develop-app/SKILL.md. It links to these guides under /references/developers/. Read permission for each mount is separate. See Pi skills and reference files.
Package layout
Create these files in a new directory:
task-counter/
manifest.json
view.js
scripts/
count-tasks.js
The same layout works in /apps/task-counter on the virtual disk. Use apps.describe for the manifest schema, an index of the SDK contracts, and a working starter. Read the schemas of an entry from its file in /references/sdk/. Write the files with fs.*, then use apps.preview to create a source review or apps.load to install the current revision. The owner can also review a directory in Advanced › Developer › Apps from virtual files. See VFS app loading for the SDK calls and mount permissions. The review runs no code. Before preparing it, check the manifest and read the page code for faults. Chat cannot open app pages before install. After install, the owner can check the page, app data and function in this tutorial.
An app installed from a ZIP or the Marketplace has its source in /apps/<app-id>/. Edit it there and load it again to change the installed app.
A ZIP must contain manifest.json at its root. Do not put the containing task-counter/ directory in the archive. Entry files are JavaScript ES modules. The host supplies the bound SDK client to each installed view and script.
Define the package
Save this complete definition as manifest.json:
{
"formatVersion": 2,
"id": "task-counter",
"name": "Task Counter",
"actions": ["database.read", "database.write", "database.delete", "scripts.run"],
"profiles": [],
"collections": [
{
"name": "tasks",
"schema": {
"type": "object",
"properties": {
"title": { "type": "string", "minLength": 1, "maxLength": 200 },
"done": { "type": "boolean" }
},
"required": ["title", "done"],
"additionalProperties": false
}
}
],
"views": [{ "name": "main", "entry": "view.js" }],
"scripts": [
{
"name": "count-tasks",
"entry": "scripts/count-tasks.js",
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"resultSchema": {
"type": "object",
"properties": {
"total": { "type": "number", "minimum": 0, "maximum": 1000000000 },
"open": { "type": "number", "minimum": 0, "maximum": 1000000000 }
},
"required": ["total", "open"],
"additionalProperties": false
}
}
],
"schedules": [
{
"name": "daily-count",
"script": "count-tasks",
"cron": "0 9 * * *",
"timeZone": "local",
"input": {}
}
]
}
actions is the app's requested permission ceiling. It does not grant access to every resource. The host creates the collection connection database:task-counter:tasks and checks ownership and Access. App-owned collections are separate from another app's collections.
Keep id stable for one app identity. Names use lowercase letters, digits and hyphens, start with a letter, and have at most 64 characters. Built-in app IDs and marketplace and scripts (shell pages under /apps/) are reserved. The definition allows up to 8 views, 16 scripts, 16 collections, 16 profiles and 16 manifest schedules. Object schemas accept scalar string, number, boolean or null fields; nested objects and arrays are not supported in these collection/script schemas. Strings need a maximum length; numbers need finite bounds. Use additionalProperties: false and list required fields explicitly.
Choose an app icon
Format version 1 and 2 manifests accept an optional icon:
"icon": { "symbol": "wallet", "color": "green" }
symbol:wallet,piggy-bank,receipt,shopping-cart,calendar,list-todo,dumbbell,book-open,utensils,plane,heart-pulse,gamepad.color:blue,green,amber,purple,red,grey.
Choose a symbol and color that fit the app's purpose. Other values and extra keys
fail manifest validation. URLs, SVG and images are not accepted. The icon belongs
to the installed revision. An app without an icon keeps its current letter or Box;
bundled apps keep their bundled icons. The SDK results of apps.load and
apps.update do not include icon.
Use another app's data
An app can use the data of another app only when its manifest declares it. Add the optional uses field. Each entry names one connection of another app and the actions that this app uses on it:
"uses": [
{ "connection": "database:costco-shopping-v2:orders", "actions": ["database.read"] },
{ "connection": "scripts:costco-shopping-v2", "actions": ["scripts.run"] }
]
A connection is database:<owner>:<collection>, scripts:<owner> or schedules:<owner>. <owner> and <collection> use the name rules above. Each entry needs 1 or more unique action IDs. The field allows at most 32 entries. The app-scope connection database:<owner> is not declarable.
The host refuses the definition with invalid_args (Invalid data use.) when:
- two entries name the same connection;
- the owner in the connection is the app itself;
- an action is not in the app's
actions; - an action does not belong to the connector of the connection.
The owner app does not have to be installed. Access shows a row for a declared connection when the owner app installs. Declared database data starts at Ask. scripts.run and schedules.manage start Off in Access. The install review sets Ask on them when the user keeps the Scripts or Schedules switch on. If the owner app installs after this app, the declared rows start at their defaults (Ask for database, Off for scripts and schedules). The install switch choice does not apply to them. The desktop owner sets the level in Access. Access does not list undeclared data of another app, and that data stays Off. The install review lists the declared uses under Other apps' data.
A schedule on another app runs only when this app has Allow on both scripts:<owner> scripts.run and schedules:<owner> schedules.manage. Thus, to schedule a script of another app, declare both connections.
Write the script
Save this module as scripts/count-tasks.js:
export default async function countTasks(_input, { cap, signal, reportProgress }) {
reportProgress({ current: 1, total: 2, label: 'Reading tasks' });
let total = 0;
let open = 0;
let cursor;
do {
signal.throwIfAborted();
const page = await cap.call('database.list', {
connection: 'database:task-counter:tasks',
limit: 100,
...(cursor ? { cursor } : {}),
});
total += page.records.length;
open += page.records.filter((record) => !record.body.done).length;
cursor = page.nextCursor;
} while (cursor);
reportProgress({ current: 2, total: 2, label: 'Count ready' });
return { total, open };
}
The host validates input and result against the manifest. A script runs in a hidden sandbox with a deadline. It can call capability entries and send messages to its app's agent (agent.send); each turn is a child of the run. It has no platform client and cannot start another script through scripts.run. Use signal during long work. Accepted work has its own cancellation API.
Write the view
Build real pages with the UI kit: it gives the Redac look, phone layouts, screens and data states. This tutorial page uses plain DOM code only to stay short.
Save this module as view.js:
export function mount(root, client) {
const button = document.createElement('button');
button.textContent = 'Count tasks';
const status = document.createElement('p');
root.append(button, status);
let closed = false;
let stopRun = () => {};
const onClick = async () => {
button.disabled = true;
status.textContent = 'Starting';
try {
await client.ready;
const { apps } = await client.cap.call('scripts.catalog', { appId: 'task-counter' });
const { runId } = await client.cap.call('scripts.run', {
appId: 'task-counter',
definitionRevision: apps[0].revision,
script: 'count-tasks',
input: {},
requestKey: crypto.randomUUID(),
});
if (closed) return;
stopRun();
stopRun = client.cap.watch('scripts.watch', { appId: 'task-counter', runId }, (run) => {
if (closed) return;
status.textContent =
run.state === 'completed'
? `${run.result.total} tasks, ${run.result.open} open`
: run.state;
});
} catch (error) {
if (!closed) status.textContent = error.message;
} finally {
if (!closed) button.disabled = false;
}
};
button.addEventListener('click', onClick);
return () => {
closed = true;
stopRun();
button.removeEventListener('click', onClick);
root.replaceChildren();
};
}
mount is synchronous and returns cleanup. Start asynchronous work inside it or in event handlers. The host disposes the client after cleanup. Closing the view stops its calls and streams; it does not cancel the accepted count run. This tutorial creates a new request key for each distinct count. For writes with uncertain delivery, retain the original request payload and key; see SDK errors and retries.
Pack and install
From the task-counter directory, run:
python3 - <<'PYCODE'
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
files = ('manifest.json', 'view.js', 'scripts/count-tasks.js')
with ZipFile('task-counter.zip', 'w', ZIP_DEFLATED) as archive:
for name in files:
archive.write(Path(name), name)
PYCODE
Turn on Advanced mode in Settings, then open Advanced › Developer › Import ZIP, select the ZIP, review the requested actions, and install the app. Open its main view and select Count tasks. The result is zero until tasks are added. Open Schedules, find Task Counter · daily-count, and enable it. Its retained script result appears in run details; the script does not create a report record or notification.
Owners get approved apps from Apps → Marketplace. Select Get, review the requested actions, and install the app. Publishing is not available in the app yet. See Marketplace.
For a fuller UI with record creation, edits, AI and reports, use the Shopping sample.
Assets, dependencies, and updates
Imported apps cannot use Node, Electron, raw IPC, SQL handles, host modules, or external network calls. Package dependencies and SDK runtime imports must be bundled into local JavaScript assets. Type-only SDK imports disappear when TypeScript is compiled. The host already supplies client; app code does not import the host client factory.
Assets are .js, .css, .png, .jpg, .webp, or .woff2 with safe relative keys. The importer allows at most 256 assets, 1 MiB per asset, 16 MiB total expanded bytes, and a 24 MiB compressed ZIP. The manifest limit is 256 KiB. Archive traversal, duplicate names and symlinks are rejected. Keep build tools, source maps, node_modules and unrelated files out of the ZIP.
Keep the same manifest id for every version of an app. Apps shows one installed entry, with one Access identity, its records and its run history. Each complete package has an opaque content revision. Redac can replace the active revision with newer or older source; it does not compare semantic version numbers. Supply the desired ZIP through Import ZIP, or review the desired VFS source and call apps.update. The review shows the installed and candidate revisions and an Update app button.
Updates validate and stage the package before stopping old runs and closing views. Enabled apps start the candidate; disabled apps stay disabled. The installation pointer and schedule reconciliation commit together. A failed update keeps the previous package and restores its runtime if cleanup succeeds. If cleanup fails, the app stays unavailable until Enable retries it. Changed revisions pause schedules; review and enable them again. An identical revision is a safe retry and does not interrupt work.
Existing Access grants remain under the app ID. Added capabilities use their normal defaults, and removed capabilities no longer pass the manifest ceiling. Creator grants do not become target app grants. Existing collection schemas must remain unchanged. Keep full collection IDs stable. Schema changes are refused before the installed app stops; this API does not add data migrations. Prior packages are not a version archive: to restore older code, supply that ZIP or VFS source again.