Fruitful Docs
Reference

Manifest

Every definition and field of the schema 3 Feed Package manifest (fruitful-package.json).

The Feed Package manifest (fruitful-package.json, schema 3). Four blocks in data-flow order: capture, transform, render, focus. Every schema the manifest refers to is a Lexicon; the manifest itself is version pins and wiring. Executable code is declared in exactly two places: capture.hooks and transform.hooks. Cross-field rules (a route's login exists, hook exports match the module, a binding's emits matches transform.input, a from route names a pinned record) are declared as data in FEED_PACKAGE_MANIFEST_RELATIONSHIPS and enforced by the registry, not by this document.

Add a $schema line to fruitful-package.json for validation and autocomplete. Fruitful ignores the key.

{ "$schema": "https://fruitful.app/schemas/fruitful-package.schema.json", "schema": 3, "kind": "page-plugin" }

Read top to bottom, the four blocks are the lifecycle of one followed page:

  1. capture: a feed is offered (Explore, schedule), its route captures the page (login, load, ready, hooks, scroll), and the binding, through its extractor engine, produces the extract.
  2. transform: the transform module turns the extract into Lexicon-validated records.
  3. render: the root record becomes an Activity item through the package View and A2UI surfaces.
  4. focus: independently of the pipeline, the browser extension hides or replaces distractions on the live site.

Executable code is declared in exactly two places: capture.hooks.file and transform.hooks.file. Rules that span fields are on the Relationships page; the engines a binding can name are on Extractor engines.

Examples come from the 33 committed first-party packages under feed-packages/.

main

A Feed Package manifest. Read top to bottom it is the lifecycle: a route is captured, the extract is transformed into records, records are rendered, and Focus is what the extension does on the live site meanwhile.

FieldTypeRequiredDescription
schema3yesManifest schema version. Schema 3 is the four-block layout; there is no dual-shape parsing.
kind"page-plugin"yesPackage kind discriminator shared with lexicon and extractor package manifests.
namestringyesReverse-DNS package name, for example com.linkedin.posts. Immutable across versions.
versionstringyesSemantic version of this immutable release, for example 2.0.0.
runtime"fruitful-page-plugin@1"yesModule contract id for transform.hooks. It fixes the transform entry points (normalize, materializeRecords, buildView), so the manifest never restates them.
filesstring[]yesEvery file shipped in the package, as package-relative paths. Input to the release digest; a path referenced anywhere in the manifest must appear here.
capturecaptureyesWhat can be followed, how each page is captured, and how the extract is produced.
transformtransformyesThe code that turns an extract into records, and the record Lexicons it may emit.
renderrendernoHow the root record becomes an Activity item. Required for follow and save feeds; absent for enrichment-only packages.
focusfocusPolicy[]noBrowser-extension policies applied on the live site when a listed feed is followed. Independent of the capture pipeline; never runs package code.

Example:

{
  "schema": 3,
  "kind": "page-plugin",
  "name": "com.linkedin.posts",
  "version": "1.8.0",
  "runtime": "fruitful-page-plugin@1",
  "files": [
    "linkedin-page-plugin.js",
    "linkedin-capture-hooks.js",
    "presentation/compact.surface.json",
    "presentation/reader.surface.json"
  ],
  "capture": {
    "routes": [
      {
        "id": "newsfeed",
        "kind": "collection",
        "feed": {
          "name": "LinkedIn Newsfeed",
          "schedule": {
            "cron": "0 0 */4 * * *",
            "label": "Every 4 hours",
            "runOn": "desktop",
            "captureOnFollow": true
          },
          "explore": {
            "group": "feeds",
            "platform": "LinkedIn",
            "targets": [
              {
                "id": "linkedin-newsfeed",
                "title": "LinkedIn Newsfeed",
                "url": "https://www.linkedin.com/feed/"
              }
            ]
          }
        },
        "urls": [
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/feed"
            }
          },
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/feed/*"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/feed"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/feed/*"
            }
          }
        ],
        "runsOn": [
          "desktop"
        ],
        "login": "linkedin",
        "ready": {
          "selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
          "timeoutMs": 20000,
          "count": 1
        },
        "scroll": {
          "kind": "infinite",
          "container": "[data-testid=\"mainFeed\"]",
          "item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
          "minItems": 60,
          "maxAttempts": 25
        },
        "binding": "newsfeed"
      },
      {
        "id": "company-posts",
        "kind": "collection",
        "feed": {
          "name": "LinkedIn Company Posts",
          "schedule": {
            "cron": "0 0 9 * * *",
            "label": "Daily",
            "runOn": "desktop",
            "captureOnFollow": true
          },
          "explore": {
            "group": "people-companies",
            "platform": "LinkedIn",
            "targets": [
              {
                "id": "elevenlabs-linkedin-posts",
                "title": "ElevenLabs LinkedIn Posts",
                "url": "https://www.linkedin.com/company/elevenlabs/posts/",
                "input": {
                  "label": "Company slug",
                  "placeholder": "elevenlabs",
                  "urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
                  "valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
                }
              }
            ]
          }
        },
        "urls": [
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/company/:slug/posts"
            }
          },
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/company/:slug/posts/"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/company/:slug/posts"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/company/:slug/posts/"
            }
          }
        ],
        "runsOn": [
          "desktop"
        ],
        "login": "linkedin",
        "ready": {
          "selector": ".feed-shared-update-v2",
          "timeoutMs": 20000,
          "count": 1
        },
        "on": {
          "afterReady": {
            "hook": "prepareCompanyPosts",
            "onError": "continue"
          }
        },
        "scroll": {
          "kind": "finite",
          "maxAttempts": 5
        },
        "binding": "classic"
      },
      {
        "id": "profile-posts",
        "kind": "collection",
        "feed": {
          "name": "LinkedIn Profile Posts",
          "schedule": {
            "cron": "0 0 9 * * *",
            "label": "Daily",
            "runOn": "desktop",
            "captureOnFollow": true
          },
          "explore": {
            "group": "people-companies",
            "platform": "LinkedIn",
            "targets": [
              {
                "id": "satya-nadella-linkedin-posts",
                "title": "Satya Nadella LinkedIn Posts",
                "url": "https://www.linkedin.com/in/satyanadella/recent-activity/all/",
                "input": {
                  "label": "Profile slug",
                  "placeholder": "satyanadella",
                  "urlTemplate": "https://www.linkedin.com/in/{value}/recent-activity/all/",
                  "valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
                }
              }
            ]
          }
        },
        "urls": [
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/in/:slug/recent-activity"
            }
          },
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/in/:slug/recent-activity/*"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/in/:slug/recent-activity"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/in/:slug/recent-activity/*"
            }
          }
        ],
        "runsOn": [
          "desktop"
        ],
        "login": "linkedin",
        "ready": {
          "selector": ".feed-shared-update-v2",
          "timeoutMs": 20000,
          "count": 1
        },
        "scroll": {
          "kind": "finite",
          "maxAttempts": 5
        },
        "binding": "classic"
      }
    ],
    "logins": [
      {
        "id": "linkedin",
        "name": "LinkedIn",
        "reason": "LinkedIn captures need a logged-in browser session.",
        "checkUrl": "https://www.linkedin.com/in/me/",
        "signInUrl": "https://www.linkedin.com/login",
        "signedIn": {
          "selectors": [
            "[data-testid=\"mainFeed\"]",
            "[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
            "section[data-view-name=\"profile-card\"]"
          ]
        },
        "signedOut": {
          "reason": "LinkedIn redirected to a sign-in or checkpoint page.",
          "urls": [
            {
              "pattern": {
                "hostname": "linkedin.com",
                "pathname": "/uas/login*"
              }
            },
            {
              "pattern": {
                "hostname": "linkedin.com",
                "pathname": "/checkpoint/*"
              }
            },
            {
              "pattern": {
                "hostname": "linkedin.com",
                "pathname": "/login*"
              }
            },
            {
              "pattern": {
                "hostname": "*.linkedin.com",
                "pathname": "/uas/login*"
              }
            },
            {
              "pattern": {
                "hostname": "*.linkedin.com",
                "pathname": "/checkpoint/*"
              }
            },
            {
              "pattern": {
                "hostname": "*.linkedin.com",
                "pathname": "/login*"
              }
            }
          ],
          "html": [
            "id=\"rememberme-div\"",
            "action=\"/checkpoint/rm/login-submit\""
          ]
        }
      }
    ],
    "hooks": {
      "file": "linkedin-capture-hooks.js",
      "exports": [
        {
          "name": "prepareCompanyPosts",
          "capabilities": [
            "browser.dom.read",
            "browser.dom.click"
          ]
        }
      ]
    },
    "bindings": [
      {
        "id": "classic",
        "package": "com.linkedin.posts.binding.classic@^2.1.0"
      },
      {
        "id": "newsfeed",
        "package": "com.linkedin.posts.binding.newsfeed@^2.1.0"
      }
    ]
  },
  "transform": {
    "hooks": {
      "file": "linkedin-page-plugin.js"
    },
    "records": [
      "app.fruitful.identity.linkedinAccount@^1.0.0",
      "app.fruitful.content.linkedinPost@^1.0.0",
      "app.fruitful.activity.repost@^1.0.0"
    ]
  },
  "render": {
    "root": "app.fruitful.content.linkedinPost",
    "view": "app.fruitful.feed.linkedin.defs@^1.0.0#postView",
    "occurredAt": "/publishedAt",
    "headline": {
      "path": "/text",
      "default": "LinkedIn post"
    },
    "surfaces": {
      "compact": "presentation/compact.surface.json",
      "expanded": "presentation/reader.surface.json"
    },
    "actions": [
      {
        "key": "linkedin-browser",
        "label": "LinkedIn",
        "view": "fruitful.browser.v1",
        "url": "/record/url",
        "rel": [
          "canonical"
        ]
      }
    ]
  },
  "focus": [
    {
      "id": "newsfeed-focus",
      "feeds": [
        "newsfeed"
      ],
      "urls": [
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/feed"
          }
        },
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/feed/*"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/feed"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/feed/*"
          }
        }
      ],
      "hide": [
        "www.linkedin.com##aside.scaffold-layout__aside",
        "linkedin.com##aside.scaffold-layout__aside",
        "www.linkedin.com##.feed-right-rail",
        "linkedin.com##.feed-right-rail",
        "www.linkedin.com##[data-test-id=\"feed-right-rail\"]",
        "linkedin.com##[data-test-id=\"feed-right-rail\"]",
        "www.linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))",
        "linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))"
      ],
      "replace": {
        "selector": "[data-testid=\"mainFeed\"]",
        "with": "panel",
        "title": "Read your LinkedIn updates in Fruitful"
      }
    }
  ]
}

capture

The capture block. Routes are the unit; a route's feed is how it is offered to people, a route's detailOf is which master it details; hooks are the only capture-time code; bindings configure the platform extractor engines that turn a page into records.

FieldTypeRequiredDescription
routesroute[]yesEvery page this package knows how to capture, one route each. A route says what it yields (kind), how it is reached (feed for people, detailOf for entries of a record type, neither for a pasted URL), and how the page becomes a record (binding). URL resolution across routes must be unambiguous.
loginslogin[]noSigned-in browser sessions a route may require. Referenced from routes by id.
hookscaptureHooksnoThe capture-hook module and the capabilities each export is granted. One of the two places code may appear.
bindingsbindingDependency[]yesBinding packages this package pins, each with the alias routes use. A binding configures one platform extractor engine (dom, reader, media, rss, http) for one site; first-party bindings such as app.fruitful.binding.reader are pinned like any other.

Example:

{
  "routes": [
    {
      "id": "newsfeed",
      "kind": "collection",
      "feed": {
        "name": "LinkedIn Newsfeed",
        "schedule": {
          "cron": "0 0 */4 * * *",
          "label": "Every 4 hours",
          "runOn": "desktop",
          "captureOnFollow": true
        },
        "explore": {
          "group": "feeds",
          "platform": "LinkedIn",
          "targets": [
            {
              "id": "linkedin-newsfeed",
              "title": "LinkedIn Newsfeed",
              "url": "https://www.linkedin.com/feed/"
            }
          ]
        }
      },
      "urls": [
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/feed"
          }
        },
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/feed/*"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/feed"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/feed/*"
          }
        }
      ],
      "runsOn": [
        "desktop"
      ],
      "login": "linkedin",
      "ready": {
        "selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
        "timeoutMs": 20000,
        "count": 1
      },
      "scroll": {
        "kind": "infinite",
        "container": "[data-testid=\"mainFeed\"]",
        "item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
        "minItems": 60,
        "maxAttempts": 25
      },
      "binding": "newsfeed"
    },
    {
      "id": "company-posts",
      "kind": "collection",
      "feed": {
        "name": "LinkedIn Company Posts",
        "schedule": {
          "cron": "0 0 9 * * *",
          "label": "Daily",
          "runOn": "desktop",
          "captureOnFollow": true
        },
        "explore": {
          "group": "people-companies",
          "platform": "LinkedIn",
          "targets": [
            {
              "id": "elevenlabs-linkedin-posts",
              "title": "ElevenLabs LinkedIn Posts",
              "url": "https://www.linkedin.com/company/elevenlabs/posts/",
              "input": {
                "label": "Company slug",
                "placeholder": "elevenlabs",
                "urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
                "valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
              }
            }
          ]
        }
      },
      "urls": [
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/company/:slug/posts"
          }
        },
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/company/:slug/posts/"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/company/:slug/posts"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/company/:slug/posts/"
          }
        }
      ],
      "runsOn": [
        "desktop"
      ],
      "login": "linkedin",
      "ready": {
        "selector": ".feed-shared-update-v2",
        "timeoutMs": 20000,
        "count": 1
      },
      "on": {
        "afterReady": {
          "hook": "prepareCompanyPosts",
          "onError": "continue"
        }
      },
      "scroll": {
        "kind": "finite",
        "maxAttempts": 5
      },
      "binding": "classic"
    },
    {
      "id": "profile-posts",
      "kind": "collection",
      "feed": {
        "name": "LinkedIn Profile Posts",
        "schedule": {
          "cron": "0 0 9 * * *",
          "label": "Daily",
          "runOn": "desktop",
          "captureOnFollow": true
        },
        "explore": {
          "group": "people-companies",
          "platform": "LinkedIn",
          "targets": [
            {
              "id": "satya-nadella-linkedin-posts",
              "title": "Satya Nadella LinkedIn Posts",
              "url": "https://www.linkedin.com/in/satyanadella/recent-activity/all/",
              "input": {
                "label": "Profile slug",
                "placeholder": "satyanadella",
                "urlTemplate": "https://www.linkedin.com/in/{value}/recent-activity/all/",
                "valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
              }
            }
          ]
        }
      },
      "urls": [
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/in/:slug/recent-activity"
          }
        },
        {
          "pattern": {
            "hostname": "linkedin.com",
            "pathname": "/in/:slug/recent-activity/*"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/in/:slug/recent-activity"
          }
        },
        {
          "pattern": {
            "hostname": "*.linkedin.com",
            "pathname": "/in/:slug/recent-activity/*"
          }
        }
      ],
      "runsOn": [
        "desktop"
      ],
      "login": "linkedin",
      "ready": {
        "selector": ".feed-shared-update-v2",
        "timeoutMs": 20000,
        "count": 1
      },
      "scroll": {
        "kind": "finite",
        "maxAttempts": 5
      },
      "binding": "classic"
    }
  ],
  "logins": [
    {
      "id": "linkedin",
      "name": "LinkedIn",
      "reason": "LinkedIn captures need a logged-in browser session.",
      "checkUrl": "https://www.linkedin.com/in/me/",
      "signInUrl": "https://www.linkedin.com/login",
      "signedIn": {
        "selectors": [
          "[data-testid=\"mainFeed\"]",
          "[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
          "section[data-view-name=\"profile-card\"]"
        ]
      },
      "signedOut": {
        "reason": "LinkedIn redirected to a sign-in or checkpoint page.",
        "urls": [
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/uas/login*"
            }
          },
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/checkpoint/*"
            }
          },
          {
            "pattern": {
              "hostname": "linkedin.com",
              "pathname": "/login*"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/uas/login*"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/checkpoint/*"
            }
          },
          {
            "pattern": {
              "hostname": "*.linkedin.com",
              "pathname": "/login*"
            }
          }
        ],
        "html": [
          "id=\"rememberme-div\"",
          "action=\"/checkpoint/rm/login-submit\""
        ]
      }
    }
  ],
  "hooks": {
    "file": "linkedin-capture-hooks.js",
    "exports": [
      {
        "name": "prepareCompanyPosts",
        "capabilities": [
          "browser.dom.read",
          "browser.dom.click"
        ]
      }
    ]
  },
  "bindings": [
    {
      "id": "classic",
      "package": "com.linkedin.posts.binding.classic@^2.1.0"
    },
    {
      "id": "newsfeed",
      "package": "com.linkedin.posts.binding.newsfeed@^2.1.0"
    }
  ]
}

feed

How a route is offered to people. A collection with a feed is followed on its schedule; an item with a feed is saved once. Routes without a feed never appear in Explore or Activity.

FieldTypeRequiredDescription
namestringyesDisplay name shown in intake, Registry, and Explore.
scheduleschedulenoCapture cadence and placement applied when a person follows. Only meaningful on collection routes.
exploreexplorenoHow the feed appears in Explore. Omit to keep the feed out of the catalog.

Example:

{
  "name": "LinkedIn Newsfeed",
  "schedule": {
    "cron": "0 0 */4 * * *",
    "label": "Every 4 hours",
    "runOn": "desktop",
    "captureOnFollow": true
  },
  "explore": {
    "group": "feeds",
    "platform": "LinkedIn",
    "targets": [
      {
        "id": "linkedin-newsfeed",
        "title": "LinkedIn Newsfeed",
        "url": "https://www.linkedin.com/feed/"
      }
    ]
  }
}

schedule

Defaults applied to the page created when a user follows a feed.

FieldTypeRequiredDescription
cronstringyesSix-field cron expression for recurring captures.
labelstringnoHuman label for the cadence shown in Explore and intake, for example Every 4 hours.
runOn"desktop" | "cloud"noWhere captures run. desktop uses the user's signed-in browser via the Desktop app; cloud uses the screenshot service.
captureOnFollowbooleannoWhether to capture immediately when the feed is followed.

Example:

{
  "cron": "0 0 */4 * * *",
  "label": "Every 4 hours",
  "runOn": "desktop",
  "captureOnFollow": true
}

explore

Explore catalog placement for a feed.

FieldTypeRequiredDescription
group"feeds" | "people-companies" | "communities" | "media"yesExplore section the targets are listed under.
platformstringyesPlatform label on the Explore card, for example LinkedIn.
targetsexploreTarget[]yesConcrete URLs a user can pick, optionally parameterised.

Example:

{
  "group": "feeds",
  "platform": "LinkedIn",
  "targets": [
    {
      "id": "linkedin-newsfeed",
      "title": "LinkedIn Newsfeed",
      "url": "https://www.linkedin.com/feed/"
    }
  ]
}

exploreTarget

One Explore card.

FieldTypeRequiredDescription
idstringyesStable target id, kebab-case.
titlestringyesCard title.
urlstring (uri)yesExample or default URL. Must match the feed's route patterns.
inputexploreInputnoPresent when the user supplies a value (a handle, a slug) that is substituted into a URL template.

Example:

{
  "id": "linkedin-newsfeed",
  "title": "LinkedIn Newsfeed",
  "url": "https://www.linkedin.com/feed/"
}

exploreInput

A single user-supplied value that builds the target URL.

FieldTypeRequiredDescription
labelstringyesField label, for example Company slug.
placeholderstringyesExample value shown in the field.
urlTemplatestringyesURL with a {value} placeholder.
valuePatternstringnoRegular expression the raw value must match before substitution.
prefixToStripstringnoPrefix removed from the raw value before substitution, for example @.

Example:

{
  "label": "Company slug",
  "placeholder": "elevenlabs",
  "urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
  "valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}

login

A signed-in browser session and how to recognise it.

FieldTypeRequiredDescription
idstringyesLogin id referenced by routes.
namestringyesDisplay name, for example LinkedIn.
reasonstringnoShown to the user when explaining why sign-in is needed.
checkUrlstring (uri)yesURL loaded to test the session.
signInUrlstring (uri)yesURL opened for the user to sign in.
signedInsessionMatchernoPositive match: the session is ready when this matches the check page.
signedOutsessionMatchernoNegative match: the session is missing when this matches the final URL or HTML.

Example:

{
  "id": "linkedin",
  "name": "LinkedIn",
  "reason": "LinkedIn captures need a logged-in browser session.",
  "checkUrl": "https://www.linkedin.com/in/me/",
  "signInUrl": "https://www.linkedin.com/login",
  "signedIn": {
    "selectors": [
      "[data-testid=\"mainFeed\"]",
      "[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
      "section[data-view-name=\"profile-card\"]"
    ]
  },
  "signedOut": {
    "reason": "LinkedIn redirected to a sign-in or checkpoint page.",
    "urls": [
      {
        "pattern": {
          "hostname": "linkedin.com",
          "pathname": "/uas/login*"
        }
      },
      {
        "pattern": {
          "hostname": "linkedin.com",
          "pathname": "/checkpoint/*"
        }
      },
      {
        "pattern": {
          "hostname": "linkedin.com",
          "pathname": "/login*"
        }
      },
      {
        "pattern": {
          "hostname": "*.linkedin.com",
          "pathname": "/uas/login*"
        }
      },
      {
        "pattern": {
          "hostname": "*.linkedin.com",
          "pathname": "/checkpoint/*"
        }
      },
      {
        "pattern": {
          "hostname": "*.linkedin.com",
          "pathname": "/login*"
        }
      }
    ],
    "html": [
      "id=\"rememberme-div\"",
      "action=\"/checkpoint/rm/login-submit\""
    ]
  }
}

sessionMatcher

Any of the listed conditions matching counts as a match.

FieldTypeRequiredDescription
reasonstringnoMessage shown when this matcher fires.
urlsurlPattern[]noURL patterns tested against the final URL.
selectorsstring[]noCSS selectors tested against the page.
htmlstring[]noLiteral substrings tested against the page HTML.

Example:

{
  "selectors": [
    "[data-testid=\"mainFeed\"]",
    "[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
    "section[data-view-name=\"profile-card\"]"
  ]
}

captureHooks

The capture-hook module. Every export is a function the platform calls at a named lifecycle point with exactly the capabilities declared here; the declaration becomes the sandbox import table.

FieldTypeRequiredDescription
filestringyesPackage-relative path to the self-contained hook bundle. Must appear in files.
stdlibstring (known: url, intl, base64)[]noHost-provided pure modules linked into the sandbox. Declared, then linked; never inferred from the bundle.
exportshookExport[]noCapability grants per export. Every hook referenced by a route must be listed; an unlisted export gets an empty import table. Publish validation checks each name exists in the module.

Example:

{
  "file": "linkedin-capture-hooks.js",
  "exports": [
    {
      "name": "prepareCompanyPosts",
      "capabilities": [
        "browser.dom.read",
        "browser.dom.click"
      ]
    }
  ]
}

hookExport

One exported hook and the authority it is granted.

FieldTypeRequiredDescription
namestringyesExport name in the hook module.
capabilities("browser.dom.read" | "browser.dom.click" | "browser.dom.type")[]yesCapability ids; each maps to a fixed set of page operations in the SafePage import table.

Example:

{
  "name": "prepareCompanyPosts",
  "capabilities": [
    "browser.dom.read",
    "browser.dom.click"
  ]
}

route

One page the package can capture. kind says what the route yields: a collection (the master: many entries that get ids and become Activity, re-observed on a schedule) or an item (the detail: one thing that stands alone and enriches an entry). binding says how the page becomes a record: which pinned binding, and through it which platform extractor engine, runs over it.

FieldTypeRequiredDescription
idstringyesStable route identity, kebab-case. Pinned into installs and provenance when the route carries a feed; never renamed.
kind"collection" | "item"yescollection: the page lists many entries; each gets a stable id and new ones become Activity. item: the page is one thing (an article, a post, a video) that stands alone and enriches an entry.
feedfeednoOffer this route to people. Present on routes that appear in Explore and can be followed or saved.
detailOfrecordSourcenoThe master this item is the detail of: each entry of the named record type has a URL at the given pointer, and that URL is captured through this route, one hop, bounded by the platform.
urlsurlPattern[]noURL patterns this route handles. Required unless the route is the detail of a record type, where it may constrain which entry URLs are accepted.
runsOn("desktop" | "cloud")[]yesWhere this route may execute. Authoring tooling can run any route and is not listed.
loginstringnoId of a login that must be ready before capture.
loadloadOptionsnoNavigation wait and timeout. Defaults: domcontentloaded, 45000 ms.
readyreadinessnoSelector the page must satisfy before hooks and scrolling run.
onlifecyclenoCapture hooks attached to lifecycle points. Omission is the canonical empty.
scrollscrollnoScroll strategy applied before the snapshot.
bindingstringyesAlias from capture.bindings. The binding's extractor engine decides what the page becomes: a dom binding emits the transform's input, a reader binding emits a webDocument, a media binding emits a media record. Collections need a binding that emits transform.input; reader and media bindings are for items.

Example:

{
  "id": "newsfeed",
  "kind": "collection",
  "feed": {
    "name": "LinkedIn Newsfeed",
    "schedule": {
      "cron": "0 0 */4 * * *",
      "label": "Every 4 hours",
      "runOn": "desktop",
      "captureOnFollow": true
    },
    "explore": {
      "group": "feeds",
      "platform": "LinkedIn",
      "targets": [
        {
          "id": "linkedin-newsfeed",
          "title": "LinkedIn Newsfeed",
          "url": "https://www.linkedin.com/feed/"
        }
      ]
    }
  },
  "urls": [
    {
      "pattern": {
        "hostname": "linkedin.com",
        "pathname": "/feed"
      }
    },
    {
      "pattern": {
        "hostname": "linkedin.com",
        "pathname": "/feed/*"
      }
    },
    {
      "pattern": {
        "hostname": "*.linkedin.com",
        "pathname": "/feed"
      }
    },
    {
      "pattern": {
        "hostname": "*.linkedin.com",
        "pathname": "/feed/*"
      }
    }
  ],
  "runsOn": [
    "desktop"
  ],
  "login": "linkedin",
  "ready": {
    "selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
    "timeoutMs": 20000,
    "count": 1
  },
  "scroll": {
    "kind": "infinite",
    "container": "[data-testid=\"mainFeed\"]",
    "item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
    "minItems": 60,
    "maxAttempts": 25
  },
  "binding": "newsfeed"
}

loadOptions

Navigation settings.

FieldTypeRequiredDescription
waitUntil"domcontentloaded" | "load"noNavigation event to wait for.
timeoutMsintegernoNavigation timeout in milliseconds.

readiness

The condition that marks the page as loaded enough to capture.

FieldTypeRequiredDescription
selectorstringyesCSS selector to wait for.
countintegernoMinimum number of matches.
timeoutMsintegernoHow long to wait before the route fails.

Example:

{
  "selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
  "timeoutMs": 20000,
  "count": 1
}

lifecycle

Hook attachments by capture lifecycle point. The point vocabulary is the platform's; unknown points are rejected.

FieldTypeRequiredDescription
afterLoadhookRefnoAfter navigation completes, before the readiness wait.
afterReadyhookRefnoAfter the readiness selector is satisfied, before scrolling.
beforeSnapshothookRefnoAfter scrolling settles, immediately before the snapshot.

Example:

{
  "afterReady": {
    "hook": "prepareCompanyPosts",
    "onError": "continue"
  }
}

hookRef

A reference to a capture hook export plus per-usage failure policy.

FieldTypeRequiredDescription
hookstringyesExport name declared in capture.hooks.exports.
onError"fail" | "continue"nofail aborts the capture; continue proceeds to the snapshot. Per usage because the same hook can be load-bearing on one route and best-effort on another.

Example:

{
  "hook": "prepareCompanyPosts",
  "onError": "continue"
}

scroll

Scroll strategy. kind selects the algorithm; container and item are required for infinite, stitching and paginate, next is required for paginate, and all are ignored for finite (enforced by the registry).

FieldTypeRequiredDescription
kind"finite" | "infinite" | "stitching" | "paginate"yesfinite scrolls to the bottom a bounded number of times; infinite scrolls a container until item growth stops; stitching also concatenates virtualised items that leave the DOM; paginate follows the next link and stitches each page's items.
containerstringnoCSS selector of the scrolling container.
itemstringnoCSS selector of one feed item, used to measure growth.
minItemsintegernoStop once this many items are present.
maxAttemptsintegernoUpper bound on scroll iterations.
delayMsintegernoPause between iterations.
itemGrowthTimeoutMsintegernoGive up when no new items appear within this window.
dedupeAttributestringnostitching only: attribute that identifies an item across virtualisation.
nextstringnopaginate only: CSS selector of the next-page link. Its href is followed until it is absent, minItems is reached, or maxPages is hit.
maxPagesintegernopaginate only: upper bound on pages visited, the first page included. Defaults to 5.

Example:

{
  "kind": "infinite",
  "container": "[data-testid=\"mainFeed\"]",
  "item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
  "minItems": 60,
  "maxAttempts": 25
}

urlPattern

A URLPattern plus sibling constraints. The pattern wrapper is deliberate: exclude, groups, and params are siblings, not children, of the pattern.

FieldTypeRequiredDescription
patternurlPatternInityesURLPattern init; omitted components match anything.
excludeurlPatternInit[]noPatterns that reject a URL even when pattern matches.
groupspathnameGroup[]noConstraints on named pathname groups such as :handle.
paramssearchParam[]noConstraints on query parameters.

Example:

{
  "pattern": {
    "hostname": "linkedin.com",
    "pathname": "/feed"
  }
}

urlPatternInit

URLPattern components, each in URLPattern syntax.

FieldTypeRequiredDescription
protocolstringno
usernamestringno
passwordstringno
hostnamestringno
portstringno
pathnamestringno
searchstringno
hashstringno
baseURLstring (uri)no

Example:

{
  "hostname": "linkedin.com",
  "pathname": "/feed"
}

pathnameGroup

A constraint on one named pathname group.

FieldTypeRequiredDescription
namestringyesGroup name without the leading colon.
regexstringnoRegular expression the group value must match.
oneOfstring[]noAllowed values.
caseInsensitivebooleannoCompare ignoring case.

Example:

{
  "name": "handle",
  "regex": "^[a-zA-Z_][a-zA-Z0-9_]{0,20}$"
}

searchParam

A constraint on one query parameter.

FieldTypeRequiredDescription
namestringyesParameter name.
requiredbooleannoWhether the parameter must be present.
regexstringnoRegular expression the value must match.
oneOfstring[]noAllowed values.
caseInsensitivebooleannoCompare ignoring case.

transform

The transform block: the input def, the module that turns an extract into records, and the record Lexicons it may emit.

FieldTypeRequiredDescription
inputstringnoThe Lexicon def the transform consumes, as nsid@range#def. Optional when every dom, rss or http binding the package pins emits the same def, in which case it is that def; required when they differ, and then the transform must export normalize for every binding whose emits is not this def.
hookstransformHooksyesThe transform module. The second of the two places code may appear.
recordsstring[]yesRecord Lexicon pins as nsid@range. Identity is recordRef.uri and links are ref-typed properties, both read from the Lexicon; nothing is restated here.

Example:

{
  "hooks": {
    "file": "linkedin-page-plugin.js"
  },
  "records": [
    "app.fruitful.identity.linkedinAccount@^1.0.0",
    "app.fruitful.content.linkedinPost@^1.0.0",
    "app.fruitful.activity.repost@^1.0.0"
  ]
}

transformHooks

The transform module. Entry points are fixed by runtime: normalize (only when a binding emits a def other than transform.input), materializeRecords, buildView.

FieldTypeRequiredDescription
filestringyesPackage-relative path to the self-contained transform bundle. Must appear in files and differ from capture.hooks.file.
stdlibstring (known: url, intl, base64)[]noHost-provided pure modules linked into the sandbox.

Example:

{
  "file": "linkedin-page-plugin.js"
}

render

The render block. Declares which record is the Activity root and how the trusted catalog presents it. Never contains code.

FieldTypeRequiredDescription
rootstring (nsid)yesNSID of the record Lexicon that becomes an Activity item. Must appear in transform.records.
viewstringyesView Lexicon def buildView must produce, as nsid@range#def.
occurredAtstringnoJSON Pointer into the root record for the item's timestamp. Omit to use the capture time; when present and the path is absent from a record, capture time is the fallback.
headlineheadlineyesWhere the Activity shell headline comes from.
surfacessurfacesyesA2UI surface templates. Each carries its own catalog id; they must agree.
actionsaction[]noAlternate ways to open the item, resolved against the validated View.

Example:

{
  "root": "app.fruitful.content.linkedinPost",
  "view": "app.fruitful.feed.linkedin.defs@^1.0.0#postView",
  "occurredAt": "/publishedAt",
  "headline": {
    "path": "/text",
    "default": "LinkedIn post"
  },
  "surfaces": {
    "compact": "presentation/compact.surface.json",
    "expanded": "presentation/reader.surface.json"
  },
  "actions": [
    {
      "key": "linkedin-browser",
      "label": "LinkedIn",
      "view": "fruitful.browser.v1",
      "url": "/record/url",
      "rel": [
        "canonical"
      ]
    }
  ]
}

headline

Headline source for the Activity shell. With path, the record value is used and default is the fallback; without path, default is the static headline.

FieldTypeRequiredDescription
pathstringnoJSON Pointer into the root record. Omit for a static headline.
defaultstringyesFallback when path is absent or empty, or the static headline when there is no path.

Example:

{
  "path": "/text",
  "default": "LinkedIn post"
}

surfaces

Package-authored A2UI v0.9.1 surface templates.

FieldTypeRequiredDescription
compactstringyesPackage-relative path to the compact surface JSON.
expandedstringyesPackage-relative path to the expanded (reader) surface JSON.

Example:

{
  "compact": "presentation/compact.surface.json",
  "expanded": "presentation/reader.surface.json"
}

action

A resource the item can be opened as, for example the original website.

FieldTypeRequiredDescription
keystringyesStable key, kebab-case.
labelstringyesButton label.
viewstringyesTrusted view type, for example fruitful.browser.v1 or fruitful.webReader.v1.
urlstringyesJSON Pointer into the View selecting the resource URI.
mediaTypestringnoMIME type of the resource when known.
relstring (known: canonical, replies)[]noRelationships from the item to the resource; known values or absolute extension URIs.

Example:

{
  "key": "linkedin-browser",
  "label": "LinkedIn",
  "view": "fruitful.browser.v1",
  "url": "/record/url",
  "rel": [
    "canonical"
  ]
}

focusPolicy

A browser-extension Focus policy. Active only while a listed feed is followed by an installed package.

FieldTypeRequiredDescription
idstringyesPolicy id, kebab-case.
feedsstring[]yesIds of routes whose feed being followed activates this policy.
urlsurlPattern[]yesBrowsing URLs the policy applies to. Distinct from capture routes.
hidestring[]noAdblock-syntax cosmetic rules applied by the extension's cosmetic engine.
replacereplacementnoReplace a distraction surface with a Fruitful panel or interstitial.

Example:

{
  "id": "newsfeed-focus",
  "feeds": [
    "newsfeed"
  ],
  "urls": [
    {
      "pattern": {
        "hostname": "linkedin.com",
        "pathname": "/feed"
      }
    },
    {
      "pattern": {
        "hostname": "linkedin.com",
        "pathname": "/feed/*"
      }
    },
    {
      "pattern": {
        "hostname": "*.linkedin.com",
        "pathname": "/feed"
      }
    },
    {
      "pattern": {
        "hostname": "*.linkedin.com",
        "pathname": "/feed/*"
      }
    }
  ],
  "hide": [
    "www.linkedin.com##aside.scaffold-layout__aside",
    "linkedin.com##aside.scaffold-layout__aside",
    "www.linkedin.com##.feed-right-rail",
    "linkedin.com##.feed-right-rail",
    "www.linkedin.com##[data-test-id=\"feed-right-rail\"]",
    "linkedin.com##[data-test-id=\"feed-right-rail\"]",
    "www.linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))",
    "linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))"
  ],
  "replace": {
    "selector": "[data-testid=\"mainFeed\"]",
    "with": "panel",
    "title": "Read your LinkedIn updates in Fruitful"
  }
}

replacement

Replaces the matched element in the live page.

FieldTypeRequiredDescription
selectorstringyesCSS selector of the element to replace.
with"panel" | "interstitial"yespanel replaces a feed with a Fruitful panel; interstitial replaces a whole surface and pauses media inside it.
titlestringnoPanel title.
descriptionstringnoPanel body text.

Example:

{
  "selector": "[data-testid=\"mainFeed\"]",
  "with": "panel",
  "title": "Read your LinkedIn updates in Fruitful"
}

recordSource

Which master an item route is the detail of: the record type whose entries carry the URL, read off each root record after the transform runs.

FieldTypeRequiredDescription
recordstring (nsid)yesNSID of the record Lexicon whose instances carry the URL. Must be the render root, since the frontier is read off Activity items.
urlstringyesJSON Pointer into the record selecting a URI string, for example /destinationUrl.

Example:

{
  "record": "app.fruitful.feed.hackerNews.submission",
  "url": "/destinationUrl"
}

bindingDependency

A pinned binding package, addressed from routes by alias.

FieldTypeRequiredDescription
idstringyesAlias used in route.binding.
packagestringyesBinding package pin as name@range, for example com.linkedin.posts.binding.newsfeed@^2.1.0 or app.fruitful.binding.reader@^1.0.0.

Example:

{
  "id": "classic",
  "package": "com.linkedin.posts.binding.classic@^2.1.0"
}

On this page