Server API

HTTP endpoints for building integrations against this CookCLI server.

Before you start

Base URL: http://cook.jefski14.ch/api
Authentication: Off unless the server has a users file (cook server user add <name>). Without one, anyone who can reach the server can read and modify your recipes — think twice before using --host on an untrusted network. With one, reads stay open, and every request that changes something needs the session cookie that signing in sets: curl -c jar -d username=<name> --data-urlencode password=<password> <server>/login, then pass -b jar to later requests. GET /api/ws/lsp and /api/sync/* need it too.
CORS: Off unless the server was started with --cors-origin: no other origin can read its answers. --cors-origin '*' lets any origin GET; a cross-origin request that would modify recipes is still refused with 403 unless it comes from an origin named explicitly. Requests with no Origin header — curl and other non-browser clients — are unaffected. content-type is always an allowed request header. Every request, from a browser or not, has to be sent to localhost, an IP address, or the host of a --cors-origin; any other Host is refused with 403.
Request size limit: 1 MB, except a title or step picture upload (PUT /api/recipe_image/{*path}), which takes up to 10 MB.
Content type: JSON in and out, except where noted — raw recipe text is text/plain.

Errors

Every failure returns the same shape, with the status code carrying the meaning:

{ "error": "Recipe not found: Nope.cook" }
  • 400 — malformed input: an invalid path, a bad query parameter, or a recipe that failed to parse.
  • 401 — the server requires signing in to make changes, and the request carried no valid session cookie.
  • 403 — the signed-in user's role does not allow this request (Your role does not allow this change); signing in again as the same user will not help. Also: the request was sent to a host name other than localhost or an IP address, or a browser request tried to modify recipes from an origin the server does not trust (another site). Start the server with --cors-origin <ORIGIN> to allow that origin and its host.
  • 404 — the recipe, menu, or pantry section does not exist, or no pantry file is configured.
  • 413 — the request body is over the size limit, or a picture has more pixels than the server will decode.
  • 415 — a picture in a format the server cannot read. The body adds a code; see PUT /api/recipe_image/{*path}.
  • 500 — the server could not read or write a file.
  • 503 — every language server session is in use, or the bridge is switched off. Only GET /api/ws/lsp returns this; see --max-lsp-sessions.

Recipes

Browse, read, write and delete .cook files under the server's recipe directory. Paths are relative to that directory and may include subdirectories.

GET /api/recipes

List every recipe as a directory tree

Returns the recipe tree rooted at the server's base path. Every node — root, directory, and file alike — carries the same four keys: children, name, path, recipe. recipe is null for a directory and non-null for a file; that is the discriminator, not the key's presence. Every path, and a recipe's source.path, is relative to the recipe directory, the root's being ""; it is what GET /api/recipes/{*path} takes. Where the directory is on the server is never sent. Menus (.menu) appear in the same tree as recipes. A recipe's metadata keeps the types its frontmatter used, except tags, which is always an array of strings: the comma-separated spelling tags: breakfast, quick is split into the same array the list spelling tags: [breakfast, quick] gives.

Response

{
  "children": {
    "Breakfast": {
      "children": {
        "Easy Pancakes": {
          "children": {},
          "name": "Easy Pancakes",
          "path": "Breakfast/Easy Pancakes.cook",
          "recipe": {
            "metadata": {
              "author": "CookCLI Team",
              "servings": 2,
              "tags": ["breakfast", "quick"],
              "description": "Simple crepes that are perfect for a lazy weekend breakfast."
            },
            "source": {
              "path": "Breakfast/Easy Pancakes.cook",
              "source_type": "Path"
            }
          }
        }
      },
      "name": "Breakfast",
      "path": "Breakfast",
      "recipe": null
    }
  },
  "name": "seed",
  "path": "",
  "recipe": null
}
GET /api/recipes/{*path}

Read one parsed recipe

Parses the recipe and returns its ingredients, cookware, timers and steps. grouped_ingredients aggregates repeated ingredients and indexes back into ingredients. inline_quantities is also present alongside them at the top level of recipe. The image field is a URL under /api/static/ (after any --url-prefix, each path segment percent-encoded) when the recipe has a title image, otherwise null. Frontmatter lands under metadata.map with the types it was written in, except tags, which is always an array of strings — tags: breakfast, quick is split on commas into the same array as tags: [breakfast, quick].

Name In Type Description
path* path string Recipe path relative to the recipe directory, e.g. Breakfast/Easy Pancakes.cook. The .cook extension is optional — the server tries the bare path first, then .cook, then .menu.
scale query number Scaling factor applied during parsing. Defaults to 1. A non-numeric value returns a plain-text 400 ("Failed to deserialize query string: ...") from axum's query deserializer, not the page's usual JSON error envelope.

* required

Response

{
  "image": "/api/static/Breakfast/Easy%20Pancakes.jpg",
  "scale": 2.0,
  "recipe": {
    "metadata": {
      "map": {
        "author": "CookCLI Team",
        "servings": 4,
        "tags": ["breakfast", "quick"],
        "description": "Simple crepes that are perfect for a lazy weekend breakfast."
      }
    },
    "ingredients": [
      {
        "name": "eggs",
        "alias": null,
        "note": null,
        "modifiers": "",
        "quantity": {
          "scalable": true,
          "unit": null,
          "value": { "type": "number", "value": { "type": "regular", "value": 6.0 } }
        },
        "reference": null,
        "relation": {
          "reference_target": null,
          "relation": {
            "defined_in_step": true,
            "referenced_from": [],
            "type": "definition"
          }
        }
      }
    ],
    "grouped_ingredients": [
      {
        "index": 0,
        "quantities": [
          {
            "scalable": true,
            "unit": null,
            "value": { "type": "number", "value": { "type": "regular", "value": 6.0 } }
          }
        ]
      }
    ],
    "cookware": [],
    "timers": [],
    "sections": [
      {
        "content": [
          {
            "type": "step",
            "value": {
              "items": [
                { "type": "text", "value": "Crack the " },
                { "type": "ingredient", "index": 0 },
                { "type": "text", "value": " into a blender." }
              ]
            }
          }
        ]
      }
    ]
  }
}
GET /api/recipes/raw/{*path}

Read the unparsed Cooklang source

Returns the file's text verbatim with content type text/plain, including YAML frontmatter. Only .cook and .menu files are served. The extension is optional in the path — without one the server tries .cook, then .menu. A path with a component starting with . is refused with a 400, and any other file answers 404.

Name In Type Description
path* path string Recipe path relative to the recipe directory.

* required

Response

---
servings: 2
tags: breakfast, quick
author: CookCLI Team
---

Crack the @eggs{3} into a blender, then add the @flour{125%g},
@milk{250%ml} and @sea salt{pinch}, and blitz until smooth.
PUT /api/recipes/{*path}

Create or overwrite a recipe

The request body is the raw Cooklang source as text/plain — not JSON. Writes are atomic (temp file plus rename). Only .cook and .menu files are written, with the same resolution as the raw endpoint: a path naming any other file, such as config/aisle.conf, writes config/aisle.conf.cook and leaves that file alone. If the file does not exist yet, it is created with the extension the path gives, or .cook — but the response's path echoes the request path verbatim and does not report that resolved filename. The parent directory must already exist: writing into a directory that is not there returns a 500 whose message talks about permissions even when the real cause is the missing directory.

Name In Type Description
path* path string Recipe path relative to the recipe directory.

* required

Request body

---
title: New Recipe
---

Mix the @flour{200%g} and @water{120%ml}.

Response

{
  "path": "Breakfast/New Recipe",
  "status": "success"
}
DELETE /api/recipes/{*path}

Delete a recipe file

Permanently removes the file from disk. There is no undo and no trash.

Name In Type Description
path* path string Recipe path relative to the recipe directory. The extension is optional, and only a .cook or .menu file is ever removed — the same resolution as the raw endpoint applies here too.

* required

Response

{
  "status": "success",
  "path": "Breakfast/Old Recipe.cook"
}
POST /api/recipe_rename/{*path}

Rename a recipe or menu file

Renames the file within its own folder; moving it to another folder is not possible. Its title and step pictures (Recipe.jpg, Recipe.2.jpg, Recipe.1.2.jpg) are renamed with it, except one another recipe or menu in the folder shows too. Every reference to it in the collection's .cook and .menu files (@./Breakfast/Old Recipe{}, @../Old Recipe{}) is rewritten to the new name — only the name, the rest of each file is kept byte for byte. A reference that cannot be rewritten exactly (an escape or a comment inside the name, another letter case, a file reached through a symbolic link, a file saved meanwhile) is left alone and listed in references_skipped, with its line when known; a file that could not be written is listed in references_failed, and the rename stands. The saved shopping list is not changed: shopping_list_stale says whether it still names the old path. Nothing is ever overwritten: 409 when a file already has the new name, when a picture would land on an existing file or on another recipe's picture name, or when renaming a recipe to the name of a menu beside it (references to the menu would reach the recipe). 400 for a name that is not a plain file name (a folder, a leading dot, characters Windows or a Cooklang reference cannot hold, a Windows device name), for a file or folder that is a symbolic link, and for the same name; 404 when the file does not exist.

Name In Type Description
path* path string Recipe or menu path relative to the recipe directory; the extension is optional, resolved as for the raw endpoint.
name* body string The new name, without folder. The file's own extension may be typed and is dropped; a recipe cannot be renamed to .menu or the other way round.

* required

Request body

{ "name": "New Recipe" }

Response

{
  "status": "success",
  "path": "Breakfast/New Recipe.cook",
  "references_updated": ["Plans/Week 1.menu"],
  "references_skipped": [{ "file": "Lunch/Bowl.cook", "line": 4 }],
  "references_failed": [],
  "shopping_list_stale": false
}
GET /api/recipe_image/{*path}

Read a recipe's title and step pictures

Reports the picture the recipe page shows. image is a URL — under /api/static/, with the server's --url-prefix, or the value itself when the metadata names an http(s) address — and null when there is none. source says where it comes from: metadata when the recipe's image (or images, picture, pictures) metadata names it, which takes precedence over any file; file for a Recipe.jpg, .jpeg, .png or .webp beside the recipe; null for none. sections lists every section of the saved recipe that has steps — section is its position counting every section, name null for an unnamed one — with each step's number within the section, its text as plain words, and its picture's URL or null. The upload and removal endpoints answer with this same shape. With step, the answer is that step's { path, section, step, image, source } instead, source being file or null; the upload and removal of a step picture answer with it too. A step's picture is Recipe.S.N.ext (section S, step N within it), else Recipe.G.ext with the step counted across every section — the order the recipe page looks in.

Name In Type Description
path* path string Recipe path relative to the recipe directory; the .cook extension is optional.
section query integer With step: the step's section, from 1, counting every section of the recipe. Defaults to 1.
step query integer A step's picture instead of the title picture: the step's number within its section, from 1. A step or section the saved recipe does not have, a value that is not a whole number from 1, or section without step returns 400.

* required

Response

{
  "path": "Breakfast/Easy Pancakes.cook",
  "image": "/api/static/Breakfast/Easy%20Pancakes.jpg",
  "source": "file",
  "sections": [
    {
      "section": 1,
      "name": null,
      "steps": [
        { "step": 1, "text": "Whisk eggs, milk and flour.", "image": null },
        { "step": 2, "text": "Cook in a pan for 1 minute.", "image": "/api/static/Breakfast/Easy%20Pancakes.1.2.jpg" }
      ]
    }
  ]
}
PUT /api/recipe_image/{*path}

Upload a recipe's title or step picture

The request body is the picture's bytes — not a multipart form. JPEG, PNG and WebP are accepted, up to 10 MB; the format is read from the bytes, not the Content-Type. The picture is turned upright from its Exif orientation, scaled down to at most 2048 px on its longer edge, laid over white if it has transparency, and saved as JPEG at Recipe.jpg beside the recipe — or, with step, at Recipe.S.N.jpg. It is always re-encoded, never stored as sent, so the file holds no Exif (GPS position included) and nothing that rode along after the image. The web editor scales a photo down in the browser before sending it, which is why 10 MB is plenty; another client may send the original. For the title, any Recipe.jpeg, .png or .webp from before is removed; step pictures (Recipe.1.jpg) are not touched. A picture the metadata names still wins over the uploaded file — see source in the response. For a step, that step's other files in both conventions and every extension are removed (Recipe.S.N.png, Recipe.G.jpg, …); the title picture and other steps' pictures are not touched. A step picture belongs to a position, not to the step's text: adding or removing an earlier step moves it onto another step. Errors carry a code next to error: 415 with heif for a HEIC or AVIF photo, which the server cannot read (an iPhone's own browser converts them to JPEG when uploading), 415 with unsupported for any other format, 400 with invalid for a file that does not decode, and 413 with too_large for one with more pixels than the decoder takes on. A body over 10 MB is refused with a plain-text 413 once the server has read past the limit.

Name In Type Description
path* path string Recipe path relative to the recipe directory; the .cook extension is optional. The recipe must exist.
section query integer With step: the step's section, from 1, counting every section of the recipe. Defaults to 1.
step query integer A step's picture instead of the title picture: the step's number within its section, from 1. A step or section the saved recipe does not have, a value that is not a whole number from 1, or section without step returns 400.

* required

Request body

<the picture's bytes>

Response

{
  "path": "Breakfast/Easy Pancakes.cook",
  "section": 1,
  "step": 2,
  "image": "/api/static/Breakfast/Easy%20Pancakes.1.2.jpg",
  "source": "file"
}
DELETE /api/recipe_image/{*path}

Remove a recipe's title or step picture

Deletes every Recipe.jpg, .jpeg, .png and .webp beside the recipe, for good — there is no undo and no trash. With step, deletes that step's Recipe.S.N and Recipe.G files in those extensions instead. Returns 404 when there was none. A picture named by the recipe's metadata is left alone; remove it by editing the recipe.

Name In Type Description
path* path string Recipe path relative to the recipe directory; the .cook extension is optional.
section query integer With step: the step's section, from 1, counting every section of the recipe. Defaults to 1.
step query integer A step's picture instead of the title picture: the step's number within its section, from 1. A step or section the saved recipe does not have, a value that is not a whole number from 1, or section without step returns 400.

* required

Response

{
  "path": "Breakfast/Easy Pancakes.cook",
  "image": null,
  "source": null
}
GET /api/static/{*path}

Fetch a recipe asset

Serves files straight from the recipe directory — this is where recipe images live. The image field returned by GET /api/recipes/{*path} is already a URL into this route. Responses carry Cache-Control: no-cache, so a browser checks back each time — a cheap 304 through the ETag when nothing changed — and a picture replaced under the same name shows up at once.

Name In Type Description
path* path string Asset path relative to the recipe directory, e.g. Breakfast/Easy Pancakes.jpg.

* required

Shopping List

Two distinct things live here. POST /api/shopping_list is stateless: send recipes, get an aggregated ingredient list back. Everything else operates on the server's persistent list, stored as .shopping-list and .shopping-checked in the recipe directory. Most of the endpoints that mutate the stored list respond 200 OK with an empty body — only GET /api/shopping_list/items, GET /api/shopping_list/checked, and the stateless POST /api/shopping_list return JSON. (A third GET lives under this path in the router, /api/shopping_list/events, but it's a Server-Sent Events stream, not JSON — see the Realtime section.)

POST /api/shopping_list

Aggregate ingredients across recipes

Stateless — nothing is stored. Ingredients with the same name are combined and unit-converted, then grouped into aisle categories from aisle.conf; a category with no matching entries is omitted from categories entirely, and ingredients that match no aisle category land in other, sorted alphabetically. Quantities are reduced by anything in pantry.conf; pantry_items lists the ingredient names that were found there (with a nonzero or unlim quantity) and subtracted. checked echoes the server's current persistent checked state, unrelated to the recipes in this request.

Name In Type Description
recipe* body string Recipe path. The array may hold several.
scale body number Scaling factor for this recipe. Defaults to 1.
included_references body string[] Which sub-recipe references to expand. Omit to include all of them.

* required

Request body

[
  { "recipe": "Neapolitan Pizza", "scale": 2 },
  { "recipe": "Salads/Caprese", "included_references": ["Shared/Vinaigrette"] }
]

Response

{
  "categories": [
    {
      "category": "fruit and veg",
      "items": [
        {
          "name": "ripe tomatoes",
          "quantities": [
            {
              "scalable": true,
              "unit": "large",
              "value": { "type": "number", "value": { "type": "regular", "value": 3.0 } }
            }
          ]
        }
      ]
    },
    {
      "category": "milk and dairy",
      "items": [
        {
          "name": "fresh mozzarella",
          "quantities": [
            {
              "scalable": false,
              "unit": "g",
              "value": { "type": "number", "value": { "type": "regular", "value": 200.0 } }
            }
          ]
        },
        {
          "name": "mozzarella cheese",
          "quantities": [
            {
              "scalable": false,
              "unit": "g",
              "value": { "type": "number", "value": { "type": "regular", "value": 200.0 } }
            }
          ]
        }
      ]
    },
    {
      "category": "tinned goods and baking",
      "items": [
        {
          "name": "tipo zero flour",
          "quantities": [
            {
              "scalable": false,
              "unit": "g",
              "value": { "type": "number", "value": { "type": "regular", "value": 1680.0 } }
            }
          ]
        },
        {
          "name": "fresh yeast",
          "quantities": [
            {
              "scalable": false,
              "unit": "g",
              "value": { "type": "number", "value": { "type": "regular", "value": 3.2 } }
            }
          ]
        }
      ]
    },
    {
      "category": "dried herbs and spices",
      "items": [
        {
          "name": "salt",
          "quantities": [
            {
              "scalable": false,
              "unit": "tsp",
              "value": {
                "type": "number",
                "value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 8, "err": 0.0 } }
              }
            },
            {
              "scalable": false,
              "unit": "g",
              "value": { "type": "number", "value": { "type": "regular", "value": 49.2 } }
            }
          ]
        },
        {
          "name": "black pepper",
          "quantities": [
            {
              "scalable": false,
              "unit": "tsp",
              "value": { "type": "number", "value": { "type": "regular", "value": 0.0625 } }
            }
          ]
        }
      ]
    },
    {
      "category": "oils and dressings",
      "items": [
        {
          "name": "Dijon mustard",
          "quantities": [
            {
              "scalable": false,
              "unit": "tsp",
              "value": {
                "type": "number",
                "value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 4, "err": 0.0 } }
              }
            }
          ]
        },
        {
          "name": "honey",
          "quantities": [
            {
              "scalable": false,
              "unit": "tsp",
              "value": {
                "type": "number",
                "value": { "type": "fraction", "value": { "whole": 0, "num": 1, "den": 4, "err": 0.0 } }
              }
            }
          ]
        },
        {
          "name": "red wine vinegar",
          "quantities": [
            {
              "scalable": false,
              "unit": "ml",
              "value": { "type": "number", "value": { "type": "regular", "value": 10.0 } }
            }
          ]
        }
      ]
    },
    {
      "category": "other",
      "items": [
        { "name": "basil leaves", "quantities": [] },
        {
          "name": "San Marzano tomato sauce",
          "quantities": [
            {
              "scalable": false,
              "unit": "tbsp",
              "value": { "type": "number", "value": { "type": "regular", "value": 10.0 } }
            }
          ]
        },
        { "name": "semolina", "quantities": [] }
      ]
    }
  ],
  "pantry_items": ["flour", "water", "salt", "tipo zero flour", "olive oil", "fresh basil", "black pepper"],
  "checked": []
}
GET /api/shopping_list/items

Read the stored recipe list

Returns the recipes currently on the shopping list, not their ingredients. An entry with a recipes array is a menu added via add_menu; its nested entries carry their own resolved scale and included_references, independent of whatever the same recipe's standalone entry (if any) was given. name is the title the recipe or menu declares, or its file name when it declares none or cannot be read; included_reference_names names each of included_references the same way, in the same order.

Response

[
  {
    "path": "Salads/Caprese.cook",
    "name": "Caprese",
    "scale": 2.0,
    "included_references": [],
    "included_reference_names": []
  },
  {
    "path": "2 Day Plan.menu",
    "name": "2 Day Plan",
    "scale": 1.0,
    "recipes": [
      {
        "path": "Breakfast/Easy Pancakes",
        "name": "Easy Pancakes",
        "scale": 5.0,
        "included_references": [],
        "included_reference_names": []
      },
      {
        "path": "lamb-chops",
        "name": "Sicilian-style Scottadito Lamb Chops",
        "scale": 1.0,
        "included_references": [],
        "included_reference_names": []
      },
      {
        "path": "Neapolitan Pizza",
        "name": "Neapolitan Pizza",
        "scale": 1.0,
        "included_references": ["Shared/Pizza Dough"],
        "included_reference_names": ["Pizza Dough"]
      },
      {
        "path": "Salads/Caprese",
        "name": "Caprese",
        "scale": 1.0,
        "included_references": ["Shared/Vinaigrette"],
        "included_reference_names": ["Vinaigrette"]
      },
      {
        "path": "Risotto",
        "name": "Classic Risotto alla Milanese",
        "scale": 1.0,
        "included_references": [],
        "included_reference_names": []
      }
    ]
  }
]
POST /api/shopping_list/add

Add one recipe to the stored list

Responds 200 OK with an empty body. The display name is derived from the path server-side; a client-supplied name would be discarded, so it is not accepted. The path is not checked for existence or validity: adding a recipe that doesn't exist still returns 200 and the entry lands on the list — it then makes every subsequent POST /api/shopping_list/compact fail with 500 until it's removed, because compact re-aggregates the whole stored list and refuses to proceed if any entry fails to parse.

Name In Type Description
path* body string Recipe path relative to the recipe directory.
scale* body number Scaling factor to store with the entry.
included_references body string[] Which sub-recipe references to expand. Omit and no sub-recipes are expanded — unlike the stateless POST /api/shopping_list, where omitting this field means "expand all", omitting it here is not preserved through storage and reads back as an explicit empty array. Pass the reference paths explicitly if you want them expanded.

* required

Request body

{
  "path": "Salads/Caprese.cook",
  "scale": 2.0,
  "included_references": ["Shared/Vinaigrette"]
}
POST /api/shopping_list/add_menu

Add every recipe in a menu

Stored as a single entry with the menu's recipes nested inside; each nested recipe's own sub-recipe references are resolved automatically (there is no included_references field to set here). Each nested recipe's scale is resolved from the menu reference: a bare {2} is a raw multiplier, {3%servings} targets 3 servings against the recipe's own servings metadata, and any other unit targets its yield metadata. Responds 200 OK with an empty body; returns 404 if path does not exist, but does not check that it's actually a .menu file — pointing this at a plain recipe is accepted and stores it as if it were POST /api/shopping_list/add with that recipe's own references as included_references, which can leave the list in a state that later makes compact fail.

Name In Type Description
path* body string Menu path relative to the recipe directory.
scale* body number Scaling factor applied to the whole menu.

* required

Request body

{
  "path": "2 Day Plan.menu",
  "scale": 1.0
}
POST /api/shopping_list/remove

Remove one recipe from the stored list

Also compacts the checked log, dropping checks for ingredients no longer referenced by any remaining recipe — best-effort; a compaction failure does not fail the remove itself. Responds 200 OK with an empty body, or 409 Conflict when the entry at index is no longer path — the list changed since it was read, and nothing was removed.

Name In Type Description
path* body string Recipe path exactly as stored.
index body integer Position of the entry in GET /api/shopping_list/items, counting from 0. The same recipe can be on the list more than once; without index the first entry with path is removed.

* required

Request body

{ "path": "Salads/Caprese.cook", "index": 1 }
POST /api/shopping_list/clear

Empty the stored list

Removes every recipe and all checked state. Responds 200 OK with an empty body.

POST /api/shopping_list/check

Mark an ingredient as bought

Appends the name to the checked log verbatim — the server does not validate it against the current aggregated list, so any string is accepted. Use a name as returned by POST /api/shopping_list for it to correspond to a real ingredient. Note that GET /api/shopping_list/checked lowercases every name it reads back, while the aggregated list from POST /api/shopping_list keeps original case — a client that diffs the two sets directly will fail to match any ingredient whose name isn't already all-lowercase, e.g. checking "Dijon mustard" here shows up as "dijon mustard" from checked. Responds 200 OK with an empty body.

Name In Type Description
name* body string Aggregated ingredient name.

* required

Request body

{ "name": "mozzarella cheese" }
POST /api/shopping_list/uncheck

Clear an ingredient's bought mark

Responds 200 OK with an empty body.

Name In Type Description
name* body string Aggregated ingredient name.

* required

Request body

{ "name": "mozzarella cheese" }
GET /api/shopping_list/checked

List checked ingredient names

Returns [] against a fresh list — nothing is checked until check is called. Every name comes back lowercased, regardless of the case it was checked with or the case POST /api/shopping_list uses for the same ingredient in its categories — compare case-insensitively, or lowercase the aggregated names yourself before diffing the two. Order is not stable: the underlying set is unordered, and consecutive calls can return the same names in a different order.

Response

["tipo zero flour", "mozzarella cheese"]
POST /api/shopping_list/compact

Drop stale checked entries

Re-aggregates the current list and removes checks for ingredients that are no longer in it. Refuses to compact (500) if any recipe fails to parse, rather than wiping checks based on a partial ingredient set. Responds 200 OK with an empty body.

Pantry

Reads and writes pantry.conf, a TOML file of what you already have at home. Quantities are typically VALUE%UNIT, e.g. 250%g, but the field is just a string — unlim and plain counts like 12 appear untouched in the seed data. Every endpoint here returns 404 when no pantry file is configured.

GET /api/pantry

Read the whole pantry

Top-level keys are section names — you choose them; fridge, garden, pantry and spice rack are just conventions used by the seed data. A bare key = "value" line at the top of the TOML file, outside any [section] header, is not an error: it is folded into a section literally named general, which is why it appears below even though pantry.conf never writes [general] itself. Item fields other than name are all optional and present only when set. The response below is trimmed to two sections of the seed pantry's five (pantry and spice rack also exist, with the same shape) to keep this example short.

Response

{
  "fridge": [
    { "name": "butter", "expire": "2026-04-15", "quantity": "250%g" },
    { "name": "eggs", "bought": "2026-03-07", "quantity": "12" },
    { "name": "milk", "quantity": "2%l" },
    { "name": "parmesan cheese", "quantity": "200%g" },
    { "name": "sour cream", "quantity": "200%ml" }
  ],
  "garden": [
    { "name": "fresh basil", "quantity": "unlim" },
    { "name": "fresh oregano", "quantity": "unlim" },
    { "name": "thyme", "quantity": "unlim" }
  ],
  "general": [
    { "name": "water", "quantity": "unlim" }
  ]
}
POST /api/pantry/add

Add an item

Creates the section if it does not exist. The response claims success and reports a distinct item was appended even when the section already has an item of that name — but every write round-trips through a TOML serializer that keys each section's items by name, so a second item sharing a name with an existing one in the same section silently replaces it on disk rather than coexisting. Verified: adding dup twice with different quantities to a fresh section leaves exactly one dup item, with the second call's quantity.

Name In Type Description
section* body string Section to add the item to.
name* body string Item name.
quantity body string Amount as VALUE%UNIT, or any other string such as unlim.
bought body string Purchase date. Accepts YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, MM/DD/YYYY, YYYY.MM.DD or DD-MM-YYYY.
expire body string Expiry date, same accepted formats as bought.
low body string Threshold below which the item counts as running low. Compared against quantity only when both share the same unit; see GET /api/pantry/depleted.

* required

Request body

{
  "section": "fridge",
  "name": "yogurt",
  "quantity": "1%l",
  "low": "2%l"
}

Response

{
  "success": true,
  "message": "Added yogurt to fridge"
}
PUT /api/pantry/{section}/{name}

Update an item

Only the fields present in the body are changed; omitted fields keep their current values. Returns 404 if the section does not exist. A name that matches nothing inside a valid section does not 404 — that case still rewrites the file (a no-op) and responds 200 with a success message naming the item that was never found. If more than one item in the section shares the target name, only the first one is updated.

Name In Type Description
section* path string Section containing the item.
name* path string Item name.
quantity body string New amount.
bought body string New purchase date.
expire body string New expiry date.
low body string New low threshold.

* required

Request body

{ "quantity": "500%g" }

Response

{
  "success": true,
  "message": "Updated butter in fridge"
}
DELETE /api/pantry/{section}/{name}

Remove an item

The section is deleted too if it becomes empty. Returns 404 if the section does not exist, but — like PUT — responds 200 with a success message even when no item in the section actually has that name; nothing is removed and the file is rewritten unchanged.

Name In Type Description
section* path string Section containing the item.
name* path string Item name.

* required

Response

{
  "success": true,
  "message": "Removed butter from fridge"
}
GET /api/pantry/expiring

List items expiring soon

Sorted most urgent first. days_remaining goes negative for items that have already expired, and those are always included regardless of the window — the seed pantry's butter (expire = "2026-04-15") is already expired relative to today, so it shows up even with the default 7-day window.

Name In Type Description
days query number Look-ahead window in days. Defaults to 7. Negative values return 400.

* required

Response

[
  {
    "section": "fridge",
    "name": "butter",
    "expire": "2026-04-15",
    "days_remaining": -116
  }
]
GET /api/pantry/depleted

List items running low

An item counts as low when its quantity has fallen to or below its low threshold and the two share the same unit. Returns [] against the unmodified seed pantry, which has no low field set on anything.

Response

[
  { "section": "fridge", "name": "yogurt", "low": "2%l" }
]

Aisles

Reads and changes aisle.conf, which groups the shopping list by store aisle: an [aisle] line, then one ingredient per line with its other names after |. Changes edit only the lines concerned, so comments and blank lines elsewhere in the file are kept. Each response carries the file's revision; send it back with a change and the change is refused with 409 if the file has been changed since, along with the current revision. Leave it out to apply the change regardless. Changes need the shopper role when sign-in is on.

GET /api/aisles

Read the aisles

Aisles in file order, which is the order the shopping list uses. Each ingredient's first name is the one the shopping list shows; the others are merged into it. warnings lists the lines the parser skipped, such as a name listed twice. Without an aisle file, configured is false, path and revision are null and aisles is empty. The response below is trimmed to two of the seed's eight aisles and a few ingredients each.

Response

{
  "configured": true,
  "path": "/path/to/recipes/config/aisle.conf",
  "revision": "edbf1fd2579f964e",
  "aisles": [
    {
      "name": "fruit and veg",
      "ingredients": [
        { "names": ["apples"] },
        { "names": ["avocado", "avocados"] }
      ]
    },
    {
      "name": "milk and dairy",
      "ingredients": [
        { "names": ["milk"] },
        { "names": ["butter"] }
      ]
    }
  ],
  "warnings": []
}
POST /api/aisles

Create an aisle file

Creates an empty config/aisle.conf in the recipe directory and answers 201 with the same shape as GET /api/aisles. 409 if an aisle file already exists, wherever it was found.

POST /api/aisles/changes

Change the aisles

A change is named by action; the other fields depend on it. add_aisle (name, optional position, counted from 0; last when omitted), rename_aisle (name, new_name), remove_aisle (name; its ingredients go with it), move_aisle (name, position), add_ingredient (aisle, names), update_ingredient (name, names, optional aisle to move it), remove_ingredient (name), add_names (name, names: more names for that ingredient, ones it has skipped) and merge_ingredients (names, one per ingredient, and main: the one name the shopping list will show; the result keeps the place, aisle and comment of the ingredient main came from). An ingredient is found by any of its names, ignoring case. Send one change with its fields beside revision, or several as changes, a list: they are applied in order and written together, or not at all, and an error names the change that failed (Change 4: …). Answers with the aisles as GET /api/aisles does. 400 for a name the file cannot hold (empty, or containing | or //) or one already used — ingredient names ignoring case, as the shopping list matches them; 404 for an aisle or ingredient that does not exist, or when there is no aisle file; 409 for a stale revision.

Name In Type Description
action* body string add_aisle, rename_aisle, remove_aisle, move_aisle, add_ingredient, update_ingredient, remove_ingredient, add_names or merge_ingredients. Not given when sending changes.
changes body object[] Several changes, each with its own action, made together.
revision body string The revision the change was made against.
name body string The aisle, or any one of the ingredient's names.
names body string[] An ingredient's names, the one to show first; for merge_ingredients, one name of each ingredient to group.
main body string For merge_ingredients: the name the grouped ingredient goes by.
aisle body string The aisle to put it in.
new_name body string For rename_aisle.
position body integer Where the aisle goes among the aisles, from 0.

* required

Request body

{
  "revision": "edbf1fd2579f964e",
  "changes": [
    { "action": "add_aisle", "name": "snacks" },
    { "action": "add_ingredient", "aisle": "snacks", "names": ["almonds"] },
    { "action": "add_names", "name": "butter", "names": ["Butter"] },
    { "action": "add_ingredient", "aisle": "snacks", "names": ["Butter"] }
  ]
}

Response

{
  "error": "Change 4: \"Butter\" is already in the \"milk and dairy\" aisle"
}
GET /api/aisles/raw

Read the aisle file as text

The file exactly as written, with its revision. 404 when there is no aisle file. The content below is cut short.

Response

{
  "path": "/path/to/recipes/config/aisle.conf",
  "content": "[fruit and veg]\napples\nbananas\ngrapes\n",
  "revision": "edbf1fd2579f964e"
}
PUT /api/aisles/raw

Replace the aisle file

Writes content as the whole file once the aisle parser accepts it, and answers as GET /api/aisles does. Otherwise 400 naming the line, and the file is left alone.

Name In Type Description
content* body string The new file.
revision body string The revision the text was edited from.

* required

Request body

{
  "content": "[a]\nx\n[b]\nx\n"
}

Response

{
  "error": "Line 4: Duplicate ingredient: 'x'"
}
GET /api/aisles/uncategorized

Ingredients without an aisle

The ingredients the recipes and menus use that no aisle names — what cook doctor aisle reports — with the files using each. link is what follows /recipe/ in that file's page address. Scans the whole collection, so it takes longer than the other reads. The response below is trimmed to two of the seed's eleven.

Response

{
  "total_recipes": 14,
  "ingredients": [
    {
      "name": "San Marzano tomato sauce",
      "recipes": [
        { "path": "Neapolitan Pizza.cook", "link": "Neapolitan Pizza" }
      ]
    },
    {
      "name": "almonds",
      "recipes": [
        { "path": "Weekly Plan.menu", "link": "Weekly Plan.menu" }
      ]
    }
  ]
}

Search & Stats

Collection-wide queries.

GET /api/search

Full-text recipe search

Matches against recipe names and content. Menus are searched alongside recipes. q is required: omitting it entirely returns a plain-text 400 from axum's query deserializer (Failed to deserialize query string: missing field q), not the page's usual JSON error envelope — the same shape as the scale parameter's failure mode on GET /api/recipes/{*path}. A present but empty q= is not rejected, though: it matches everything and returns the whole collection.

Name In Type Description
q* query string Search term. Omitting the parameter returns 400; an empty value matches everything.

* required

Response

[
  { "name": "Neapolitan Pizza", "path": "Neapolitan Pizza.cook" },
  { "name": "Pizza Dough", "path": "Shared/Pizza Dough.cook" },
  { "name": "2 Day Plan", "path": "2 Day Plan.menu" },
  { "name": "Weekly Plan", "path": "Weekly Plan.menu" }
]
GET /api/stats

Collection counts

Pantry counts are all zero when no pantry file is configured, or when it fails to read or parse. pantry_expiring_count uses a fixed 7-day window regardless of what GET /api/pantry/expiring?days= would be called with.

Response

{
  "recipe_count": 12,
  "menu_count": 2,
  "pantry_item_count": 29,
  "pantry_expiring_count": 1,
  "pantry_depleted_count": 0
}
GET /api/reload

Reload recipes (no-op)

Kept for client compatibility. The server reads from disk on every request, so there is no cache to clear and this endpoint does nothing besides log and return this fixed response.

Response

{
  "status": "success",
  "message": "Recipes will be refreshed from disk on next request"
}
POST /api/reload

Reload recipes (no-op)

Identical to the GET form; both verbs are registered on the same route and accepted.

Response

{
  "status": "success",
  "message": "Recipes will be refreshed from disk on next request"
}

Realtime

Long-lived connections. Neither of these returns a normal JSON response.

GET /api/shopping_list/events

Server-sent events for shopping list changes

Emits a change event whenever .shopping-list or .shopping-checked is written, created, replaced or removed on disk — by any client of this server (a client's own changes are announced back to it too), by CookCloud sync, or by hand. Reading the files, through this API or otherwise, never emits one. The event's file field is "list" or "checked", naming which file changed (captured live below by editing the shopping list in a second shell while connected). It is not a snapshot of what changed, so the intended pattern is to re-fetch on each event rather than to apply a diff: after "checked" only the ticks changed, and GET /api/shopping_list/checked has them; after "list", regenerate the list. A subscriber that falls too far behind to be told every change is sent a "list" event instead. A ping keep-alive comment is sent every 30 seconds. If the filesystem watcher failed to start, the stream still connects and returns 200 but never emits an event.

Response

event: change
data: {"file":"list"}

event: change
data: {"file":"checked"}
GET /api/ws/lsp

Language server bridge (websocket)

Upgrades to a websocket (verified: a plain WebSocket handshake against this path returns 101 Switching Protocols) that bridges to a cook lsp subprocess, providing diagnostics and completions to the built-in editor. Each websocket text message is one bare JSON-RPC message; the bridge adds and strips the Content-Length framing LSP uses over stdio. The language server's workspace root is always the server's recipe directory: initialize has its rootUri, rootPath and workspaceFolders replaced, and workspace/didChangeWorkspaceFolders is dropped, as is any message that is not a single JSON object. Browsers apply no CORS to websockets, so a handshake is refused with 403 when its Origin is neither a --cors-origin nor the server's own page reached by IP address or localhost. Clients that send no Origin are unaffected, and --no-csrf-check turns the check off. Each open socket holds a subprocess, so no more than --max-lsp-sessions of them (8 by default) run at once; a handshake beyond that is refused with 503 and the usual JSON error body instead of being upgraded, and succeeds once an earlier socket closes. --max-lsp-sessions 0 turns the endpoint off, which is worth doing when serving --host on a network you do not control. Not a REST endpoint and not usable with a plain HTTP client.

Sync

Sign in to Cook Cloud and sync recipes across devices (Cook Basic). These four endpoints exist only when CookCLI is built with the sync feature, which is on by default — the badge on each entry marks that. Because the router is read as source text (include_str!), they are documented unconditionally rather than silently disappearing from this page in a build that lacks the feature. Authentication uses an OAuth device-code flow: start a login, show the user the code, then poll status until it completes.

GET /api/sync/status requires sync build

Current sync and login state

pending_login is non-null while a device-code login is in progress; poll this endpoint to detect completion. expires_in_secs counts down to when the pending login expires. Captured live against a fresh server with no session.

Response

{
  "logged_in": false,
  "email": null,
  "syncing": false,
  "pending_login": null
}
POST /api/sync/login requires sync build

Start a device-code login

Illustrative response — derived field-by-field from LoginResponse in src/server/handlers/sync.rs, not captured from a completed cook.md exchange (that needs real network and credentials, which this reference was not authenticated against). Show user_code to the user and send them to verification_uri, or open verification_uri_complete, which pre-fills the code. Takes no request body. Returns 400 if already logged in, 409 if a login is already in progress, and 502 if cook.md is unreachable — the 502 path was verified live, by calling this endpoint with no network reachable: it returned 502 with {"error": "cook.md unreachable: ..."}.

Response

{
  "user_code": "ABCD-EFGH",
  "verification_uri": "https://cook.md/device",
  "verification_uri_complete": "https://cook.md/device?code=ABCD-EFGH",
  "expires_in_secs": 900
}
POST /api/sync/cancel_login requires sync build

Abandon a pending login

cancelled is false when there was no login in progress — captured live against a fresh server with nothing pending. Takes no request body.

Response

{ "cancelled": false }
POST /api/sync/logout requires sync build

Sign out and stop syncing

Clears the stored session and halts the background sync task. Always responds 200 with {"ok": true}, even when no session exists — captured live against a fresh server with no session file. Takes no request body.

Response

{ "ok": true }