Skip to content
Notion Arena

Building

Authoring a recipe

A recipe is a declarative description of a Notion workspace fragment: databases, their properties, the views over them, seed rows, and documentation pages written as real block trees. The installer turns it into an ordered list of API calls, including the two passes that relations force. You never write a request yourself.

The shape

lib/recipes/types.tsts
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 is the whole trick
Recipe databases do not know their own ids: they do not exist yet. So they address each other by 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:

lib/recipes/business.ts, abbreviatedts
{
  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 the single_property shape.
  • { twoWay: true, syncedName } produces the dual_property shape, so Notion creates the reverse property on People named Deals.
  • Both relations, both the rollup and the Weighted formula 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}, with companies resolved 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.

1POST
/v1/pages

Create the “crm-pipeline” container page under the page you chose

2POST
/v1/databases

Create the “Companies” database with 10 base properties

3GET
/v1/databases/{companies_database_id}

Read back Companies to get its default data source id

4PATCH
/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

5POST
/v1/databases

Create the “People” database with 9 base properties

6GET
/v1/databases/{people_database_id}

Read back People to get its default data source id

7PATCH
/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

8POST
/v1/databases

Create the “Deals” database with 13 base properties

9GET
/v1/databases/{deals_database_id}

Read back Deals to get its default data source id

10PATCH
/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

11POST
/v1/views

Create the “Pipeline board” board view on deals

12POST
/v1/views

Create the “Weighted pipeline” chart view on deals

13POST
/v1/views

Create the “Closing this month” list view on deals

14POST
/v1/views

Create the “Slipped” table view on deals

15POST
/v1/views

Create the “Accounts gallery” gallery view on companies

16POST
/v1/views

Create the “Needs a touch” table view on people

17POST
/v1/pages

Seed 3 rows into Companies so the views are not empty on first open (one request per row; Notion has no bulk insert)

×3
18POST
/v1/pages

Seed 2 rows into People so the views are not empty on first open (one request per row; Notion has no bulk insert)

×2
19POST
/v1/pages

Seed 3 rows into Deals so the views are not empty on first open (one request per row; Notion has no bulk insert)

×3
20POST
/v1/pages

Create the “CRM: how this system works” page

21PATCH
/v1/blocks/{crm_how_this_system_works_page_id}/children

Append 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.

Order is load-bearing
Views come after properties, because a board view groups by a select property that must exist. Seed rows come after views, because rows created before a form view exists cannot be validated against it. Page bodies come last, in chunks of 100, because that is the append limit, and any chunk that needs deeper than two levels is appended against the block id the previous chunk returned.

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" }
Dashboard layouts are read-only
You can create a dashboard view and you can create the chart views it displays, but there is no API for positioning them. Notion composes the layout itself from the widget views attached to the same data source. Recipes say so in their manual steps rather than pretending otherwise.

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.ts rather 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 how client-portal puts 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:

ConstantThe limitationAppears in
BUTTON_LIMITATIONButton 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_LIMITATIONDatabase automations have no public API surface. They are configured per-database in the Notion app under ••• → Automations.9 recipes
RECURRING_LIMITATIONRecurring 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_LIMITATIONThe API can append an original synced_block, but the duplicate instances that reference it must be created in the app.1 recipes
The rule
If you find yourself writing a manual step whose reason is "it is easier by hand", you are writing a shortcut, not a limitation. Either script it or do not include the recipe.

Adding a recipe

  • Write it in lib/recipes/business.ts or lib/recipes/systems.ts, whichever half of the catalog it belongs to.
  • Register it in the RECIPES map in lib/recipes/index.ts. Nothing else needs to change.
  • Add the catalog entry with kind: "native" and a recipe field 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