Fruitful Docs
Get started

Your first package

Read a real package top to bottom, run its checks, and see it render in the product.

Rather than start from an empty directory, this page walks through the smallest committed package, Hacker News, and runs every check on it. By the end you can read any package and know what each file is for. The last section turns it into a package for a site of your own.

The definition

feed-packages/hacker-news/
├── fruitful-feed-package.json      # the definition: which packages, where review evidence is
├── fruitful-package.json           # THE manifest: capture, transform, render, focus
├── fruitful-lexicons.json          # where generated authoring types go
├── fruitful-plugin-build.json      # bundle entrypoint, outfile, released bundle digests
├── lexicons/                       # this package's record Lexicons and its View Lexicon
├── bindings/front-page/            # a dom binding: selectors that produce the extract
├── authoring/src/                  # the transform, in TypeScript
├── plugin/                         # the shipped payload: bundled transform and surfaces
└── review/                         # fixtures, expectations, presentation examples, evidence

Browse the whole thing. The explorer below is the package as it sits in the repository, read at build time — pick a file and read it before you clone anything.

Machine-written files are left out: plugin/hn-page-plugin.js (62 KB of minified bundle), review/fixtures/front-page.html (34 KB, a captured page), review/expectations/front-page.json (22 KB, a generated golden), and the twelve Lexicon type files under authoring/src/generated/ that fruitful lexicon generate writes from lexicons/.

fruitful-feed-package.json lists the packages the definition produces, in dependency order:

{
  "schemaVersion": 2,
  "packages": [
    "lexicons/submission", "lexicons/account", "lexicons/defs", "bindings/front-page",
    { "root": "plugin", "manifest": "fruitful-package.json" }
  ],
  "reviewEvidenceManifest": "review/review-evidence.json"
}

Three Lexicon packages, one binding package, and the Feed Package itself. Each is an immutable, versioned package in the registry, and each has its own manifest: the Lexicon and binding manifests are thin identity cards (name, version, files) that sit inside their directories, while the Feed Package's manifest is the root fruitful-package.json — the object entry above points at it, and its files stay relative to plugin/, the payload directory whose exact bytes ship. Typed source under authoring/ deliberately stays outside that payload so editing it never changes a released digest.

The Lexicon: what a record is

lexicons/submission/lexicon.json defines app.fruitful.feed.hackerNews.submission. It has two defs that matter here:

  • main is the record: a stable submission identity (uri, itemId, title) plus what was observed (submitter, submittedAt, destinationUrl, site, points, commentCount).
  • extract is what the binding produces before the transform runs: itemId, title, rank, destinationUrl, site, submitterUsername, submittedAt, points, commentCount.

The extract is shaped by what the page shows; the record is shaped by what the product needs. The transform bridges the two.

The binding: how the page becomes an extract

A binding is data, not code. bindings/front-page/fruitful-package.json names the engine and what it emits:

{
  "kind": "binding",
  "name": "com.ycombinator.news.binding.front-page",
  "version": "0.3.0",
  "extractor": "dom@1",
  "files": ["binding.json"],
  "emits": "app.fruitful.feed.hackerNews.submission@^1.0.0#extract",
  "document": "binding.json"
}

binding.json is the selector set the dom@1 engine runs over the captured page. entry matches one submission; each field is read relative to it:

{
  "entry": "tr.submission",
  "fields": [
    { "name": "itemId", "selector": ":scope", "attribute": "id" },
    { "name": "title", "selector": "td.title span.titleline > a", "attribute": "textContent" },
    { "name": "destinationUrl", "selector": "td.title span.titleline > a", "attribute": "href" },
    { "name": "submittedAt", "selector": ":scope + tr span.age", "attribute": "title" }
  ]
}

Field names are the extract def's property names, so the engine's output is validated against the Lexicon before the transform sees it.

The manifest: wiring it together

The root fruitful-package.json is the Feed Package manifest — four blocks in data-flow order. Scroll (or click) through the steps and watch it assemble; each panel is an excerpt of the real file.

the header

Identity first: the immutable name and version, the runtime contract the transform is written against, and files — every byte this package ships, relative to plugin/. Nothing undeclared can ride along.

capture

Wire in capture: two routes and two binding pins. submissions is a collection — the front page lists many entries and is re-captured every two hours because it carries a feed. article is an item: each submission's destinationUrl is captured one hop away through the first-party reader binding. That is how a headline becomes a readable article.

transform

Then the transform: the bundled module and the records it may emit — nothing else can leave the sandbox. The transform section below reads the TypeScript this bundle is built from.

render

Then render: the root record, its View, the two A2UI surfaces, and explicit actions. An action is the only way a package offers a link out — render.actions here is why Activity can open the article in Reader.

focus

Last, focus — the extension-side path: when you follow a listed feed, the live site itself changes. Focus is independent of the capture pipeline and never runs package code. A policy has two tools — replace swaps an element for a Fruitful panel, and hide is a list of adblock-syntax cosmetic rules. HN's clean layout needs no hide rules; X hides its sidebar and trends with 9, LinkedIn its right rail with 8.

{
"schema": 3,
"kind": "page-plugin",
"name": "com.ycombinator.news",
"version": "0.15.0",
"runtime": "fruitful-page-plugin@1",
"files": ["hn-page-plugin.js", "presentation/compact.surface.json", "presentation/reader.surface.json"]
}

For a site that does need blocking, hide sits beside replace in the same policy — this is X's, verbatim:

feed-packages/x — home-focus
"focus": [
  {
    "hide": [
      "x.com##[data-testid=\"sidebarColumn\"]",
      "twitter.com##[data-testid=\"sidebarColumn\"]",
      "www.twitter.com##[data-testid=\"sidebarColumn\"]"
    ],
    "replace": { "selector": "[aria-label^=\"Timeline\"]", "with": "panel", "title": "Your Fruitful briefing is ready here." }
  }
]

How the rules work is Focus policies and why they sit outside the pipeline is Focus is not part of the pipeline. Shipping a release once the manifest is done is Release a version. Every field is described in the Manifest reference.

The transform: extract to records

authoring/src/hn-page-plugin.ts exports two functions. materializeRecords takes the extract items and returns Lexicon records, using builders generated from the Lexicons:

materializeRecords(items) {
  const records = [];
  for (const item of items) {
    const itemId = string(item.fields['itemId']);
    const title = string(item.fields['title']);
    if (!itemId || !title) continue;
    records.push(Submission.$build({ uri: `https://news.ycombinator.com/item?id=${itemId}`, itemId, title, /* ... */ }));
  }
  return records;
}

buildView turns the root record and its related records into the package's View, which the surfaces read. The module is bundled to plugin/hn-page-plugin.js and runs in a sandbox with no host, network, or filesystem access; see Plugin runtime environment for what it may use.

The generated builders come from the Lexicons. Check they are current:

yarn fruitful lexicon generate feed-packages/hacker-news --check

The surfaces: how it renders

plugin/presentation/compact.surface.json and reader.surface.json are A2UI v0.9.1 surface templates that bind to the View by JSON Pointer. The compact surface is a title and a metadata row:

{ "id": "title", "component": "Text", "text": { "path": "/record/title" }, "variant": "h5" }

The package never ships client code. Fruitful renders these components from its trusted catalog.

Review evidence: proving it works

review/review-evidence.json declares cases. Each pairs a fixture (a sanitized captured page) with an expectation (the records the transform must produce from it) and a presentation example:

{
  "id": "public-front-page-capture",
  "displayName": "Front Page",
  "format": "html",
  "fixture": "review/fixtures/front-page.html",
  "url": "https://news.ycombinator.com/",
  "binding": "front-page",
  "expectation": "review/expectations/front-page.json",
  "provenance": { "kind": "sanitized-capture", "evidence": "review/evidence/front-page.json" }
}

Review evidence never ships to users. It is what validation runs against.

Run the checks

These three commands run offline and are exactly what CI runs for every package.

Coverage shows what the binding extracts from each fixture:

yarn fruitful plugin coverage feed-packages/hacker-news --json
{
  "success": true,
  "fixtures": [{
    "fixture": "review/fixtures/front-page.html",
    "coverage": {
      "entrySelector": "tr.submission",
      "matchedEntryCount": 30,
      "extractedEntryCount": 30,
      "requiredFields": ["itemId", "rank", "title"],
      "neverObservedFields": [],
      "fields": [{ "path": "title", "presentCount": 30, "totalCount": 30 }]
    }
  }]
}

Validate runs the whole package over every case and checks execution, determinism, coverage, Lexicons, Views, goldens, and presentation:

yarn fruitful plugin validate feed-packages/hacker-news --json
{
  "success": true,
  "root": { "name": "com.ycombinator.news", "version": "0.15.0" },
  "resolutionDigest": "sha256:…",
  "checks": {
    "execution": "passed", "determinism": "passed", "coverage": "passed",
    "lexicons": "passed", "views": "passed", "goldens": "passed", "presentation": "passed"
  },
  "cases": [{ "caseId": "public-front-page-capture", "recordCount": 59, "goldenAgreement": "matched" }]
}

Generate rebuilds the bundle, expectations, and presentation examples. --check fails if anything committed is stale:

yarn fruitful plugin generate feed-packages/hacker-news --check --json

See it in the product

Try a Hacker News reading workspace, using the same navigation, Activity rows, Reader, and Fruitful theme as the product. This is a selected public snapshot from February 23, 2026. The interactive workspace loads automatically without an account. Its desktop layout scales to fit this page. The small corner control switches between actual size and fitting all three panes on the page.

Loading workspace…

The package's compact surface appears in each Activity row. Its expanded surface supplies the original update in Source details, opened with the information button in the Reader toolbar. Reader displays the linked article from a resource declared by the package.

Select Six Math Essentials or Worg to try the two included articles. When zoomed in, scroll inside the preview to reach the other panes. Previous/next controls work locally, and original-website and discussion links open their public destinations. The remaining stories explicitly show that their article is not included. Following, account settings, capture, and saving are inactive in this example.

The committed example is exported through the product's Activity reads after materializing this package in a disposable local workspace. Regeneration checks the selected records, Views, and resolved articles; repository contributors use yarn preview:export --check to detect stale output. The articles have separate public provenance and attribution, shown after their text. The preview runs in your browser without a connection to the product API. See the component catalog to inspect individual surfaces and resolved data.

To inspect the package's underlying review case in the Desktop app:

yarn fruitful plugin preview feed-packages/hacker-news --json

Inspect the compact row and the reader view. Feed and case names should read clearly; package ids, digests, and versions should not appear in the reading UI.

Now for your own site

  1. Copy feed-packages/hacker-news to feed-packages/<your-site> and rename the packages (name in each fruitful-package.json, reverse-DNS).

  2. Write the extract def for what the page shows, and the record def for what the product needs.

  3. Point the route's urls at the page and capture it. This drives a local browser:

    yarn fruitful authoring capture https://example.com/feed --package feed-packages/<your-site> --json
  4. Write the binding's selectors, then iterate with plugin coverage until every required field is present on every entry. See Choose an extractor engine before writing selectors.

  5. Write materializeRecords and buildView, then lexicon generate --write and plugin generate --write.

  6. Freeze the capture as a fixture with plugin sanitize-fixture, declare the case in review-evidence.json, and run plugin validate until every check passes.

Next: Publish it to the registry.

On this page