Building
Authoring a recipe
The shape
type Recipe = {
id: string; // matches the catalog slug
summary: string; // one paragraph, shown on the component page
databases: RecipeDb[]; // created first, base properties only
views: ViewSpec[]; // created after the databases exist
pages: RecipePage[]; // documentation, appended as block trees
manual: RecipeManual[]; // the gaps, with reasons
};
type RecipeDb = {
ref: string; // local name other databases point at
title: string;
icon: string; // single emoji
isInline?: boolean; // renders inline in its parent page
properties: Record<string, PropertySchema>;
seed?: Record<string, unknown>[]; // rows created after the schema settles
};
type RecipeManual = {
title: string;
reason: string; // always a real API limitation, never a shortcut
steps: string[]; // exact click path
};ref, a local string. The installer resolves refs to real data_source_ids in a second pass. That indirection is what lets a recipe declare a bidirectional relation between two databases that have not been created.A worked example
CRM Pipeline declares 3 databases and produces 26 API requests. Here is the declaration of the relation that ties two of them together:
{
ref: "deals",
title: "Deals",
icon: "🤝",
properties: {
"Deal": title(),
"Amount": num("dollar"),
"Stage": status([
{ name: "Qualified", group: "In progress" },
{ name: "Proposal out", group: "In progress" },
{ name: "Negotiation", group: "In progress" },
{ name: "Won", group: "Complete" },
{ name: "Lost", group: "Complete" },
]),
"Company": relation("companies"), // ← by ref
"Owner": relation("people", {
twoWay: true,
syncedName: "Deals", // ← name on the far side
}),
"Close date": date(),
"Weighted": formula(
'prop("Amount") * if(prop("Stage") == "Qualified", 0.2, ' +
'if(prop("Stage") == "Proposal out", 0.5, ' +
'if(prop("Stage") == "Negotiation", 0.8, ' +
'if(prop("Stage") == "Won", 1, 0))))'
),
"Days open": rollup("Company", "Created", "earliest_date"),
},
seed: [
{ "Deal": "Northwind · Platform", "Amount": 48000, "Stage": "Proposal out" },
{ "Deal": "Aurelia Health", "Amount": 180000, "Stage": "Negotiation" },
],
}What the installer does with it
relation("companies")attaches an internal__relationRef: "companies"marker and produces thesingle_propertyshape.{ twoWay: true, syncedName }produces thedual_propertyshape, so Notion creates the reverse property on People namedDeals.- Both relations, both the rollup and the
Weightedformula are stripped from pass 1: the databases are created with Companies (16 props), People (12 props), Deals (19 props) minus anything that references another database. - They come back in pass 3 and 4 as
PATCH /v1/data_sources/{id}, withcompaniesresolved to the id Notion assigned.
How planInstall orders the work
This is the exact sequence for crm-pipeline. Every recipe page renders it from the same function, and POST /api/install with mode: "plan" returns it as JSON.
/v1/pagesCreate the “crm-pipeline” container page under the page you chose
/v1/databasesCreate the “Companies” database with 10 base properties
/v1/databases/{companies_database_id}Read back Companies to get its default data source id
/v1/data_sources/{companies_data_source_id}Add 6 deferred properties to Companies: relations (with resolved data_source_ids), then the rollups and formulas that read them
/v1/databasesCreate the “People” database with 9 base properties
/v1/databases/{people_database_id}Read back People to get its default data source id
/v1/data_sources/{people_data_source_id}Add 3 deferred properties to People: relations (with resolved data_source_ids), then the rollups and formulas that read them
/v1/databasesCreate the “Deals” database with 13 base properties
/v1/databases/{deals_database_id}Read back Deals to get its default data source id
/v1/data_sources/{deals_data_source_id}Add 6 deferred properties to Deals: relations (with resolved data_source_ids), then the rollups and formulas that read them
/v1/viewsCreate the “Pipeline board” board view on deals
/v1/viewsCreate the “Weighted pipeline” chart view on deals
/v1/viewsCreate the “Closing this month” list view on deals
/v1/viewsCreate the “Slipped” table view on deals
/v1/viewsCreate the “Accounts gallery” gallery view on companies
/v1/viewsCreate the “Needs a touch” table view on people
/v1/pagesSeed 3 rows into Companies so the views are not empty on first open (one request per row; Notion has no bulk insert)
/v1/pagesSeed 2 rows into People so the views are not empty on first open (one request per row; Notion has no bulk insert)
/v1/pagesSeed 3 rows into Deals so the views are not empty on first open (one request per row; Notion has no bulk insert)
/v1/pagesCreate the “CRM: how this system works” page
/v1/blocks/{crm_how_this_system_works_page_id}/childrenAppend 13 blocks of page body
26 requests, 13 manual steps. Paced at Notion's roughly three requests per second, the whole install runs in well under a minute.
Views
Views are declared separately from databases, because one database usually gets several. Each spec names the database ref, the view type, and whatever that type needs.
// board grouped by a status property
{ on: "deals", name: "Pipeline", type: "board",
board: { group_by: "Stage" },
sorts: [{ property: "Amount", direction: "descending" }] }
// calendar
{ on: "deals", name: "Close calendar", type: "calendar",
calendar: { start_date_property: "Close date" } }
// form: Notion renders it as a public intake page
{ on: "intake", name: "New client intake", type: "form",
form: { questions: [
{ property: "Company", required: true },
{ property: "What do you need?", required: true },
] } }
// chart
{ on: "logs", name: "Consistency", type: "chart",
chart: { type: "bar", x_property: "Week", y_aggregate: "count" } }
// dashboard: layout is Notion's to compose
{ on: "habits", name: "Habit dashboard", type: "dashboard" }Documentation pages
A workspace that arrives empty is a workspace nobody uses. Recipes append real block trees (not markdown, not screenshots) so headings are headings and toggles are toggles.
{
title: "How to run a sprint",
icon: "🔁",
blocks: [
heading(2, "Monday: planning, 45 minutes"),
numbered([t("Read the sprint goal out loud. If nobody can, rewrite it.")]),
numbered([t("Pull from Backlog, not from Triage.")]),
callout("Stop at capacity. Carry-over counts against capacity first.", "⚠️"),
columns(
[heading(3, "Daily"), bullet([t("15 minutes, standing up.")])],
[heading(3, "Friday"), bullet([t("Demo, then retro.")])]
),
simpleTable([
["Ritual", "When", "Owner"],
["Planning", "Monday 09:30", "PM"],
["Retro", "Friday 16:00", "Anyone"],
]),
],
}- Use the builders in
lib/notion/schema.tsrather than raw objects. They keep the nesting depth legal and the rich-text shape correct, which is where hand-written payloads usually break. syncedBlock()creates the original. Its duplicates cannot be created by API: that is always a manual step.embedUrl()can point a recipe page at one of our widgets, which is howclient-portalputs a live countdown on the client-facing page.
Writing manual steps
A manual step is a promise to you that we tried. The reason field must name a real limitation. There are four shared constants, and every one of the 11 recipes uses them rather than inventing an excuse:
| Constant | The limitation | Appears in |
|---|---|---|
BUTTON_LIMITATION | Button properties are response-only in the Notion API: the schema is returned on read, but the API ignores button settings on write and cannot create a button or configure its actions. | 6 recipes |
AUTOMATION_LIMITATION | Database automations have no public API surface. They are configured per-database in the Notion app under ••• → Automations. | 9 recipes |
RECURRING_LIMITATION | Recurring database templates (daily/weekly/monthly) are configured in the app on the template's ••• menu; the API can create pages from a template but cannot schedule recurrence. | 6 recipes |
SYNCED_BLOCK_LIMITATION | The API can append an original synced_block, but the duplicate instances that reference it must be created in the app. | 1 recipes |
Adding a recipe
- Write it in
lib/recipes/business.tsorlib/recipes/systems.ts, whichever half of the catalog it belongs to. - Register it in the
RECIPESmap inlib/recipes/index.ts. Nothing else needs to change. - Add the catalog entry with
kind: "native"and arecipefield matching the id. - Open the component page. The install plan, the property tables, the view list, the page-body previews and the manual steps all render from the declaration. Check that the plan reads the way you intended before you call it done.
Then run the plan, not the install
curl -s -X POST localhost:3000/api/install \
-H 'content-type: application/json' \
-d '{"component":"crm-pipeline","mode":"plan"}' \
| jq '.plan.calls[] | {method, path, note}'
# count the requests, check the order, read the warnings
curl -s -X POST localhost:3000/api/install \
-H 'content-type: application/json' \
-d '{"component":"crm-pipeline","mode":"plan"}' \
| jq '{total: .plan.totalRequests, manual: .plan.manualSteps, warnings: .plan.warnings}'Plan mode needs no credentials and touches nothing. It is the review step, and it is the reason an agent can be given a recipe without being given a token first.
Everything published
- 3 db6 views1 pages3 manual
- 3 db6 views1 pages3 manual
- 3 db5 views1 pages2 manual
- 3 db6 views1 pages2 manual
- 2 db5 views1 pages2 manual
- 3 db6 views1 pages3 manual
- 2 db7 views1 pages2 manual
- 5 db7 views1 pages3 manual
- 2 db6 views1 pages3 manual
- 3 db6 views1 pages3 manual
- 2 db6 views1 pages3 manual