Build an app by hand
Everything a Pomodoso app file can say, with a working example of each piece. Prefer to describe it and let a model write it? See Build with an AI assistant.
What an app is
An app is one YAML file. Pomodoso reads it and builds a small app from it: its own screen under Apps, the records it keeps, and, if you want, a widget on Today, a question after pomodoros and a line in your reports.
The file is configuration, not code. It picks from a fixed set of building blocks (the ones on this page) and Pomodoso draws them. It can't run scripts, load anything from the internet or read your other data. That's what lets anyone install an app from the directory without worrying about it. If something isn't on this page, the format can't do it yet.
A file has three main parts:
- Collections: what you store (glasses of water, books, a mood per day).
- Computed values: numbers worked out from those records (glasses today, days in a row on goal).
- Views: the screens, each a list of blocks (a button, a progress bar, a chart) that show and add records.
Your first app
A water tracker, start to finish. Read the steps, then edit the file below: it's checked as you type and the preview updates. To check a file of your own, use the validator.
- Name it.
schema_version: 1, anidin lower-kebab-case (it never changes), anameand aversionlike1.0.0. - Add a setting for what the user should choose on install: here,
goal, glasses a day. Anywhere in the file,$goalreads it. - Declare a collection. Each glass is an
event(something that happens at a moment) with one field,size. It has adefault, so a single tap can add a glass without a form. - Compute what to show.
todaycounts today's glasses;streakcounts days in a row with at least$goal. - Build the screen. A view is a list of blocks, top to bottom: a button that adds a glass, a progress bar, a stat and a chart.
- Optional extras. A
todaywidget (the user decides whether to show it), a question after each pomodoro, and a line in the daily report.
The file
The top-level keys, in the order they usually appear. Only schema_version, id, name, version, collections and views are required. A file is at most 256 KB.
| Key | What it is for |
|---|---|
schema_version | Always 1. |
id | The plugin id, lower-kebab-case (quit-smoking). It never changes between versions. |
name | Shown in the app list. |
version | Semver, 1.0.0. A breaking change needs a new major and a migrate block. |
icon | One word: book, film, grid, no-smoking, water, coffee, run, heart, mood, sleep, money, music, game, plant, pill, food, star, check, calendar, pen, link, code, brain, dumbbell. Anything else shows the initial. |
description | One sentence. |
author | Your name. |
license | Default MIT. |
settings | Values the user sets on install (a starting limit, a goal). Readable as $key. |
collections | What the plugin stores. Each has a key, a kind and fields. Empty ([]) for an app that keeps no data, like a year progress bar. |
computed | Named values calculated from the records, readable as $name. |
views | The screens of the plugin, each a list of blocks. A view with of: <collection> is the screen of one record (opened from a list or board with opens); its blocks read $record (the id) and $record.<field>. layout: wide opens a view full width (a browser tab in the extension). The first view is the main screen and has no of. |
today | Optional widget for the Today tab; the user switches it on. default_on suggests a starting state. |
hooks | Optional questions after pomodoros or at day end, and lines in reports. |
rules | Fields set automatically when a record starts matching when (stamp a date when a book becomes read). only_if_empty keeps a value the user set. |
migrate | How a breaking update carries existing records over: from: "1.x" and steps (rename_collection, rename_field, map_values, drop_field). |
Keys you name yourself (collections, fields, settings, computed values, views) are lower_snake_case: letters, numbers and _, starting with a letter, up to 40 characters. Unknown keys are errors, not ignored, so a typo never goes unnoticed.
Collections and fields
A collection is a kind of record your app keeps. Pick its kind by how the records behave:
| Kind | Use it for |
|---|---|
event | Things that happen at a moment: a cigarette, a glass of water. Each record gets occurred_at. |
item | Things you keep and update: a book, a link. Records have no date of their own beyond created_at. |
daily | One record per day: a mood, a day state. Records have a day; saving the same day again updates it. |
Each collection has fields. Every field needs a key and a type; label is what the form shows (the key, humanized, by default). Fields are required unless you add optional: true or give them a default. Every record also has built-in fields you can sort and filter by without declaring them: created_at, updated_at, plus occurred_at on events and day on dailies. On an item, title_field says which field names the record. Forms for events also ask When (now by default), so one you forgot can be logged at the time it happened.
| Field type | What it holds |
|---|---|
text | One line of text. |
longtext | Multi-line text (notes). |
number | A number; min, max and unit are optional. |
scale | A number picked from a short range (default 1–5), shown as buttons. |
boolean | Yes / no, shown as a checkbox. |
date | A calendar day, YYYY-MM-DD. |
url | A link. |
select | One option from options. Add user_editable: true to let the user rename, recolor and add options. |
multiselect | Several options from options. |
tags | Free-form tags, typed comma-separated. No options. |
rating | Stars (default 1–5). |
duration | Minutes. |
image_url | An image link (https), shown as a cover in boards. |
ref | A record of another item collection (collection) this one belongs to: a card of an issue, a note of a book. Picked from a list; shows its title. Deleting that record deletes the ones that point to it. |
task | A task from the user's Pomodoso task list, picked from them. The app stores only its id and shows its title; the task shows the record back, to open it. Apps can't read or change tasks. |
Select options are written as plain keys ([water, tea, coffee], shown humanized) or as { value, label, color } when you want a different label or a color ("#2D8A7A") for boards and the year grid.
Settings and values
settings are values the user fills in on install, like a starting limit or a goal. Each has a key, a type (number, text, boolean or select), a label and usually a default.
Wherever the file expects a value, write a number or a $reference: a setting, a computed value, or a built-in ($today_date, $now, $tab.url, $tab.title, $tab.image, $record). $tab.url, $tab.title and $tab.image (the page's preview image, the one shown when you share a link) are the page open in the browser, and only work in an add-button's prefill: one tap saves the book, movie or article you're looking at. Inside text, labels and report lines, wrap it in braces: "{$today} of {$goal} glasses".
Computed values
Under computed, each entry gets a name and exactly one operator. Read it with $name; entries can use each other, as long as they don't go in a circle.
Ranges pick a period: today, yesterday, this_week, last_week, this_month, last_month, this_year, all, or a rolling window like 14d or 12mo. where filters records by field: a value ({ drink: tea }), or in, not_in, exists, lt, lte, gt, gte ({ size: { gte: 300 } }). group (day, week or month) turns a count or sum into a series for charts.
count
How many records match. Optional where, range, by (the date field the range applies to) and group (day | week | month, which makes it a series for charts).
computed:
tea_this_week: { count: glass, where: { drink: tea }, range: this_week }sum
Sum of a numeric field over the matching records. Same options as count.
computed:
ml_today: { sum: glass, field: size, range: today }avg
Average of a numeric field. Same options as count.
computed:
avg_size: { avg: glass, field: size, range: 30d }min
Smallest value of a numeric field. Same options as count.
computed:
smallest: { min: glass, field: size }max
Largest value of a numeric field. Same options as count.
computed:
largest: { max: glass, field: size, range: this_month }last
The most recent record (or its field). Optional where.
computed:
last_read: { last: book, where: { status: read }, field: title }first
The oldest record (or its field). Optional where.
computed:
first_glass: { first: glass }streak
Consecutive days, ending today, with a count within at_least / at_most. Optional where.
computed:
goal_streak: { streak: glass, at_least: $goal }taper
A value that changes by a step every period since install: from, down_by or up_by, every (day | week | month), optional min / max. For limits that tighten over time.
computed:
# 3 glasses the first week, one more every week, never above the goal
ramp_up: { taper: { from: 3, up_by: 1, every: week, max: $goal } }delta
Relative change between two values, [a, b] → (a − b) / b. Reads as a percent in templates.
computed:
vs_last_week: { delta: [$week, $last_week] }calendar
How far the current week, month or year has gone, from the date alone (no collection). show: progress (default, a fraction: 0.43 on day 158 of 365), passed (days so far, today included), left (days after today) or total (days in the period).
computed:
year_done: { calendar: year } # 0.43 on day 158 of 365
days_left: { calendar: year, show: left }ratio
One value divided by another, [a, b] → a / b: pages per day, cost per item. Empty when b is 0.
computed:
ml_today: { sum: glass, field: size, range: today }
ml_per_glass: { ratio: [$ml_today, $today] }Blocks
views are the app's screens. Each has an id, an optional label (its tab name when there's more than one) and blocks, shown top to bottom.
The examples below run on a small playground app with sample data: water glasses (an event), books (items) and a mood per day. Try them: nothing is saved.
The playground's file
schema_version: 1
id: docs-playground
name: Playground
version: 1.0.0
icon: star
settings:
- { key: goal, type: number, label: Glasses a day, default: 8, min: 1 }
collections:
- key: glass
kind: event
fields:
- { key: size, type: number, unit: ml, default: 250, min: 0 }
- { key: drink, type: select, options: [water, tea, coffee], default: water }
- key: book
kind: item
title_field: title
fields:
- { key: title, type: text }
- { key: author, type: text, optional: true }
- { key: status, type: select, options: [to_read, reading, read], default: to_read }
- { key: tags, type: tags, optional: true }
- { key: rating, type: rating, optional: true }
- { key: pages, type: number, optional: true, min: 1 }
- { key: current_page, type: number, label: On page, optional: true, min: 0 }
- { key: link, type: url, optional: true }
- { key: cover, type: image_url, optional: true }
- { key: finished_on, type: date, optional: true }
- key: mood
kind: daily
fields:
- key: mood
type: select
options:
- { value: great, color: "#2D8A7A" }
- { value: good, color: "#8CBF7A" }
- { value: meh, color: "#E0B04A" }
- { value: bad, color: "#C8553D" }
computed:
today: { count: glass, range: today }
week: { count: glass, range: this_week }
last_week: { count: glass, range: last_week }
week_change: { delta: [$week, $last_week] }
last: { last: glass }
form
An inline form for a collection. On a daily collection it edits today. On an event collection it also asks "When" (now by default), to log one you forgot.
- type: form collection: book
stat
One number or value with a label. format says how it reads.
- type: stat label: Last glass value: $last format: time_ago
stat-row
Up to four stats side by side.
- type: stat-row
items:
- { label: Today, value: $today }
- { label: This week, value: $week }
- { label: vs last week, value: $week_change, format: percent }progress
A bar of value against max. invert: true for limits (going over is bad). format says how the numbers read (share shows only the percent). style: bar (default), ring (a ring of dots) or dots (one dot per unit when max is a whole number up to 400, like the days of a year); it can be a $setting so the user picks.
- type: progress label: Daily goal value: $today max: $goal - type: progress label: Glasses today value: $today max: $goal style: dots # bar (default), ring or dots; or a $setting
chart
A bar or line chart of a grouped series (count / sum / … with group). Optional dashed reference line.
- type: chart
label: Last 14 days
kind: bar
series: { count: glass, group: day, range: 14d }
reference: $goallist
Records as rows: primary field, up to three secondary ones, optional filter, sort, open (a url field), a swipe action that sets fields, and opens (a view with of this collection: tapping a row opens that record's screen).
- type: list
collection: book
primary: title
secondary: [author, status]
sort: { field: created_at, order: desc }
open: link
swipe: { label: Mark read, set: { status: read } }tag-filter
Chips that filter the lists and boards of the same view by a tags, multiselect or select field.
- type: tag-filter collection: book field: tags - type: list collection: book primary: title secondary: [tags]
board
Items grouped in columns by a select field (group_by), as cards with title, subtitle, image and badge; optional filter (in a record's screen, { issue: $record } shows its cards) and opens. Full width (layout: wide) the columns sit side by side, cards drag between them and each column has its own add.
- type: board
collection: book
group_by: status
card: { title: title, subtitle: author, badge: rating }year-grid
Every day of the year as a colored cell (like a contribution graph), colored by a select field.
- type: year-grid collection: mood color_by: mood legend: true
day-picker
Pick a select value for today, or move to other days.
- type: day-picker collection: mood field: mood
breakdown
How many records have each option of a select, multiselect or tags field, as bars.
- type: breakdown collection: glass field: drink range: this_month
text
A paragraph; {$name} inside it is replaced by the value.
- type: text
text: "{$today} of {$goal} glasses today. Keep going."Records inside records
Some apps need things that belong to other things: the cards of a board, the notes of a book. Three pieces do it, shown here with the Kanban app, where every board opens with its own columns.
- A
reffield points to a record of another item collection:{ key: board, type: ref, collection: board }. The form lets you search and pick one by its title. Deleting a board deletes its cards too. - A record's screen is a view with
of: board. It isn't one of the app's tabs: it opens when you tap a row of alistor a card of aboardthat saysopens: kanban. Its blocks read the record as$record(its id) and$record.title(any of its fields), sofilter: { board: $record }shows only its cards andprefill: { board: $record }adds new ones to it. layout: wideopens a view full width, in its own browser tab from the extension. There aboardshows its columns side by side: drag a card to another column or use its ‹ › buttons, and add straight into a column. The columns are the options of itsgroup_byfield; withuser_editable: trueeach user renames them and adds their own, in the app's settings.
On Today, the list widget shows the records as rows that open their screens: every open board, with its cards done out of all of them (badge and badge_of, read with $record as the row). An optional more row opens another view.
A task field links a record to one of your Pomodoso tasks. You pick the task from your list; the app keeps only its id and shows its title, and the task shows the record back, with a button to open it. The app can't read or change your tasks.
The Kanban app's file
schema_version: 1
id: kanban
name: Kanban
version: 1.0.0
icon: check
description: Boards with To do, In progress and Done. Link a board to a task to open it from there.
author: Pomodoso
license: MIT
collections:
- key: board
kind: item
title_field: title
fields:
- { key: title, type: text, label: Name }
- { key: task, type: task, label: Linked task, optional: true }
- { key: notes, type: longtext, optional: true }
- key: state
type: select
options: [open, closed]
default: open
- key: card
kind: item
title_field: title
fields:
- { key: title, type: text }
- { key: board, type: ref, collection: board }
- key: status
type: select
user_editable: true
options:
- { value: todo, label: To do, color: "#B9B4A7" }
- { value: doing, label: In progress, color: "#E0B04A" }
- { value: done, label: Done, color: "#2D8A7A" }
default: todo
- { key: notes, type: longtext, optional: true }
views:
- id: main
label: Boards
blocks:
- { type: add-button, label: New board, adds: board }
- type: list
collection: board
filter: { state: open }
primary: title
secondary: [task]
sort: { field: created_at, order: desc }
opens: kanban
swipe: { label: Close, set: { state: closed } }
- id: closed
label: Closed
blocks:
- type: list
collection: board
filter: { state: closed }
primary: title
opens: kanban
swipe: { label: Reopen, set: { state: open } }
- id: kanban
of: board
layout: wide
blocks:
- { type: add-button, label: Add a task, adds: card, prefill: { board: $record } }
- type: board
collection: card
filter: { board: $record }
group_by: status
card: { title: title }
today:
default_on: true
widget:
type: list
label: Kanban
collection: board
where: { state: open }
opens: kanban
# Per board: its cards done, out of all of them (in progress counts as not done).
badge: { count: card, where: { board: $record, status: done } }
badge_of: { count: card, where: { board: $record } }
In the preview, tap a board to open it, and move its cards with ‹ ›. The tasks you can link are samples.
Today widget
An app always has its own screen under Apps. It can also show one small widget on the Today tab, if its file has a today block, and only when the user turns it on. default_on: true suggests starting with it on.
mini-counter
A number with a + button that adds to an event collection (adds). Optional max; subtract: true adds a − button that deletes the latest one from today.
today:
default_on: true
widget: { type: mini-counter, label: Water, value: $today, max: $goal, adds: glass, subtract: true }select-today
Option chips that set today's value of a daily collection's select field. after: year-grid shows the year in pixels (colored by that field) once today is set, with a Change link to pick again.
today:
widget: { type: select-today, label: Mood, collection: mood, field: mood, after: year-grid }count-badge
A single number.
today:
widget: { type: count-badge, label: This week, value: $week }stat
A single value with a format.
today:
widget: { type: stat, label: Last glass, value: $last, format: time_ago }stats
Up to four values, two per row, each with its label and optional format (items). Fits half a row on Today, like the other widgets.
today:
widget:
type: stats
label: Water
items:
- { label: Today, value: $today }
- { label: This week, value: $week }
- { label: vs last week, value: $week_change, format: percent }progress
A bar of value against max (default 1), with an optional format, a caption line under it ({$name} works) and a style (bar, ring or dots, or a $setting). Ring and dots take the whole row.
computed:
year_done: { calendar: year }
days_left: { calendar: year, show: left }
today:
widget:
type: progress
label: Year
value: $year_done
format: share
caption: "{$days_left} days left"
style: ringrecord
The latest item of a collection matching where (the book you are reading), by its title field, with a number field you edit right on the card (the page you are on), against an optional max field (its pages, as a bar). Optional caption line with {$name} values.
today:
widget:
type: record
label: Reading
collection: book
where: { status: reading }
title: title
field: current_page # edited right on the card
max: pageslist
The records of a collection matching where as rows (the open boards), each opening its screen (opens, a view with of), with an optional badge per row (a value that reads the row as $record: its cards done) and badge_of (what the badge is out of, shown as 3/5), and an optional more row opening another view of the app. Takes the whole row on Today.
today:
widget:
type: list
label: Reading
collection: book
where: { status: reading }
opens: book_page # a view with `of: book`
badge: $record.current_page # per row: the page you're on
views:
- id: book_page
of: book
blocks:
- { type: text, text: "{$record.title}, on page {$record.current_page}" }Hooks
Hooks connect the app to the rest of Pomodoso: a question during the break after a pomodoro or the next morning, and lines in the daily and weekly reports. A question takes exactly one answer: yes (adds a record) or pick (sets a select on a daily record).
pomodoro_end
After a pomodoro, during the break: ask a question; yes adds a record to an event collection, or pick sets a select on today. At most one plugin question per break.
hooks:
- on: pomodoro_end
ask: Did you drink water during this break?
yes: { add: glass }day_end
The next day: ask about yesterday, with yes (adds a record to an event collection, dated yesterday) or pick (sets a select on yesterday's daily record).
hooks:
- on: day_end
ask: How was yesterday?
pick: { collection: mood, field: mood }daily_report
A line added to the daily report, with {$name} values.
hooks:
- on: daily_report
line: "Water: {$today} of {$goal} glasses"weekly_report
A line added to the weekly report.
hooks:
- on: weekly_report
line: "{$week} glasses this week ({$week_change} vs last week)"Rules
A rule fills fields by itself when a record starts matching when. The usual one stamps a date: when a book becomes read, set finished_on to today. only_if_empty: true keeps a date the user already typed.
rules:
- collection: book
when: { status: read }
set: { finished_on: $today_date }
only_if_empty: trueStat formats
The format of a stat says how its value reads.
| Format | Reads as |
|---|---|
number | 12, 2.5 |
time_ago | 3h 20m ago (for timestamps such as last) |
days | 8 days |
percent | +20%, −34% (for fractions such as delta) |
share | 43.3%: a fraction as a plain percent (calendar progress). In a progress block or widget, the value as a share of max (158 of 365 reads 43.3%). |
duration | 1h 35m (minutes) |
date | Oct 21, 2026 |
Updating an app
Bump version on every change. Adding things (a field, a block, a view) is a minor or patch version, and installed copies update without losing anything.
Renaming or removing a field, collection or option, or changing a field's type, would strand the records people already have. That's a breaking change: it needs a new major version and a migrate block that says how to carry the old records over. Pomodoso compares the new file with the published one and tells you when you need one.
version: 2.0.0
migrate:
from: "1.x"
steps:
- rename_field: { collection: book, from: author, to: writer }
- map_values: { collection: book, field: status, map: { to_read: wishlist } }Steps run in order: rename_collection, rename_field, map_values and drop_field. After a rename_collection, later steps use the new name. An app's id belongs to whoever published it first, so a copy of someone else's app needs a new id.
Check, install, publish
Paste the file into Pomodoso: in the extension, Apps → Add an app → From a file, or in your dashboard → Apps to publish it to the directory. Both run the same checks as the editor above, say exactly what to fix, and show the app before anything is saved.
To check any file without installing it, paste or upload it in the validator and try it there.
Format versions. The app format has its own version, currently 0.2, which goes up when Pomodoso learns new blocks, widgets or values. When you publish, Pomodoso works out the lowest format version your file needs from what it uses. Someone on an older Pomodoso doesn't see an app they couldn't open, and keeps getting the latest version of it that they can. Each app's page says which Pomodoso it works with ("Pomodoso 1.4.1 or later"), and so does the validator.
From a terminal, with the same validator:
npx @pomodoso/plugin validate my-app.yaml
For autocomplete and inline errors in your editor, point it at the JSON Schema: add # yaml-language-server: $schema=https://pomodoso.com/schema/plugin.v1.json as the first line of the file (VS Code with the YAML extension).