Pomodoso
Add to Chrome
APP DOCS

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.

  1. Name it. schema_version: 1, an id in lower-kebab-case (it never changes), a name and a version like 1.0.0.
  2. Add a setting for what the user should choose on install: here, goal, glasses a day. Anywhere in the file, $goal reads it.
  3. Declare a collection. Each glass is an event (something that happens at a moment) with one field, size. It has a default, so a single tap can add a glass without a form.
  4. Compute what to show. today counts today's glasses; streak counts days in a row with at least $goal.
  5. 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.
  6. Optional extras. A today widget (the user decides whether to show it), a question after each pomodoro, and a line in the daily report.
Valid: Water · my-water · v1.0.0 · works with Pomodoso 1.4.0 or later

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.

KeyWhat it is for
schema_versionAlways 1.
idThe plugin id, lower-kebab-case (quit-smoking). It never changes between versions.
nameShown in the app list.
versionSemver, 1.0.0. A breaking change needs a new major and a migrate block.
iconOne 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.
descriptionOne sentence.
authorYour name.
licenseDefault MIT.
settingsValues the user sets on install (a starting limit, a goal). Readable as $key.
collectionsWhat the plugin stores. Each has a key, a kind and fields. Empty ([]) for an app that keeps no data, like a year progress bar.
computedNamed values calculated from the records, readable as $name.
viewsThe 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.
todayOptional widget for the Today tab; the user switches it on. default_on suggests a starting state.
hooksOptional questions after pomodoros or at day end, and lines in reports.
rulesFields 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.
migrateHow 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:

KindUse it for
eventThings that happen at a moment: a cigarette, a glass of water. Each record gets occurred_at.
itemThings you keep and update: a book, a link. Records have no date of their own beyond created_at.
dailyOne 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 typeWhat it holds
textOne line of text.
longtextMulti-line text (notes).
numberA number; min, max and unit are optional.
scaleA number picked from a short range (default 1–5), shown as buttons.
booleanYes / no, shown as a checkbox.
dateA calendar day, YYYY-MM-DD.
urlA link.
selectOne option from options. Add user_editable: true to let the user rename, recolor and add options.
multiselectSeveral options from options.
tagsFree-form tags, typed comma-separated. No options.
ratingStars (default 1–5).
durationMinutes.
image_urlAn image link (https), shown as a cover in boards.
refA 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.
taskA 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 }

counter-button

A big button that adds a record to an event collection in one tap. set fills fields; details: form opens a form instead. subtract: true adds a − button that deletes the latest one from today (a tap by mistake).

- type: counter-button
  label: Drank a glass
  adds: glass
  set: { drink: water }
  subtract: true            # a − button for a tap by mistake

add-button

A button that opens a form to add an item. prefill can use $tab.url, $tab.title and $tab.image (the current page and its preview image), so one tap saves the page you are on.

- type: add-button
  label: Save this page as a book
  adds: book
  prefill: { title: $tab.title, link: $tab.url, cover: $tab.image }

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: $goal

list

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 ref field 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 a list or a card of a board that says opens: kanban. Its blocks read the record as $record (its id) and $record.title (any of its fields), so filter: { board: $record } shows only its cards and prefill: { board: $record } adds new ones to it.
  • layout: wide opens a view full width, in its own browser tab from the extension. There a board shows 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 its group_by field; with user_editable: true each 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: ring

record

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: pages

list

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: true

Stat formats

The format of a stat says how its value reads.

FormatReads as
number12, 2.5
time_ago3h 20m ago (for timestamps such as last)
days8 days
percent+20%, −34% (for fractions such as delta)
share43.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%).
duration1h 35m (minutes)
dateOct 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).