Paprika Recipe Manager 3 (macOS)
Read the local library directly from its database. Create recipes through the
app's own import format. Both paths are verified against a live install of
Paprika 3.8.4; see references/recipe-format.md for the evidence.
The app has no AppleScript dictionary and no URL scheme. Its only scripted surfaces are the SQLite database (read) and the file import (write). That is enough — there is no need for the Paprika cloud API or an account password.
Prerequisites
- Paprika Recipe Manager 3 installed (Mac App Store build, sandboxed)
sqlite3,zip,unzip— all present on macOS- Node 18+ for the helper scripts (zero npm dependencies)
- For
--confirm: Automation permission for the controlling terminal. macOS asks once; the user approves it. Until thenosascriptfails with-1743and the import must be confirmed by hand.
Reading the library
Safe while Paprika is open. Every query goes through sqlite3 -readonly;
nothing here writes to the live database.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs count # totals + sync revisions
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs list # all active recipes
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs list --search miso
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs get "Miso Soup" # one recipe, full JSON
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs categories # categories + counts
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs photo "Miso Soup"
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs snapshot /tmp/paprika.sqlite
get emits the same JSON shape the import format uses, so its output can
be edited and fed straight back to paprika-recipe.mjs.
Three details the script handles so callers don't have to: ZCREATED is a Core
Data timestamp (2001 epoch, not Unix), trashed recipes must be filtered on
ZINTRASH, and categories live behind a generated join table.
Never cp the database and read the copy with -readonly — a copy keeps
journal_mode=wal and SQLite then refuses to open it (unable to open database file (14)) because it cannot create the -shm sidecar. Use snapshot, which
uses VACUUM INTO.
Don't write to the database directly
ZSTATUS, ZISSYNCED and ZSYNCHASH are bookkeeping the app maintains for
cloud sync. A hand-written row risks corrupting the library and confusing sync
across devices. Import is the supported write path, and it is not slower.
Creating a recipe from any text source
This is the main capability. The agent does the understanding; the script does the mechanical part.
1 — Read the source. A web page, a note, a PDF (pdftotext file.pdf -, or
the pandoc skill), a photo of a cookbook page, a pasted message, a chat
transcript. Anything you can read.
When the user names a source, find that source. If a search comes back empty, that is evidence about the search, not about the user's memory. Never substitute a similar-looking document you found elsewhere — a plausible substitute produces a recipe that is wrong in ways nobody can see afterwards, because it is internally consistent and correctly attributed to the wrong thing.
The specific trap: a container's text is not its contents. Attachments, embeds and linked files usually live outside the body you just searched, and they are often unidentifiable by name — Apple Notes titles every scanned PDF literally
2 — Structure it as JSON. The only required field is name.
{
"name": "Rhabarberkompott",
"ingredients": "200 g Rhabarber\n1 Vanilleschote\n2 EL Zucker",
"directions": "Rhabarber rüsten und in 1 cm breite Stücke schneiden.\n\nVanilleschote längs halbieren, Mark herauskratzen.\n\nAlles ca. 5 Minuten köcheln lassen.",
"notes": "war nicht süß genug",
"servings": "2 servings",
"source": "migusto.migros.ch",
"source_url": "https://migusto.migros.ch/de/rezepte/rhabarberkompott",
"categories": ["Dessert"]
}
Rules that actually bite:
ingredientsanddirectionsare newline-delimited plain text, not arrays. One ingredient per line.End a section header with a colon. A line whose last character is
:renders as a bold, highlighted subsection header — a real Paprika formatting feature. So writeFor the dressing:, neverFor the dressingorFor the dressing (makes 200 ml). This is an authoring rule, not a curiosity: a header without the trailing colon silently renders as an ordinary ingredient line, and nothing about the stored data reveals the difference.For the dressing: ← bold header 2 tbsp rice vinegar ← blank line separates the blocks To serve: ← bold header Sliced tomatoesThe colon must be last, not merely present:
Salt: about 2 gstays a plain line. And it is a rendering rule — the colon is ordinary stored text, so never strip it when reading, editing or round-tripping.servings,prep_time,cook_timeare free text, not numbers —"2 servings","15 mins".categoriesare plain names, and an unknown name creates a category. This is a side effect with no undo path in this skill, and it outlives the recipe: a category is its own row inZRECIPECATEGORY, so deleting the recipe that introduced it leaves the category behind, to be removed by hand in the app. Verified. Always runpaprika-db.mjs categoriesfirst and reuse existing names; a typo or a near-duplicate ("Desserts" vs "Dessert") permanently pollutes the sidebar on every synced device.Omit
uid,hash,photo_hash. The app assigns them.createdis ignored — the app stamps its own import time.Keep the source language. Libraries are frequently mixed-language; don't translate a recipe on the way in.
Put substitutions and results in
notes— that is what the field is for in practice ("Replace the butter with 60 g vegan block.").Don't invent what the source doesn't say. If a source gives ingredients but no method, leave
directionsempty and record that innotes. A plausible-sounding invented step is indistinguishable from a real one once it's in the library.Link to another recipe with
[recipe:Name]— see below. Whenever a source says "serve with X" or calls for a component the library already has, that is a link, not a plain string.
Choosing an import format — native by default
When this skill writes a file, write the native format. .paprikarecipe for
one recipe, .paprikarecipes for several.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs build recipe.json --out ~/Downloads/r.paprikarecipe
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs bundle a.json b.json --out ~/Downloads/two.paprikarecipes
It carries 24 fields to .yml's 15, is the only format that can update an
existing recipe (via uid) or carry a photo, and is the only one that can be
imported automatically — open routes it by file type, then --confirm
clicks a button on a sheet that identifies itself.
A second format exists: vendor-documented plain-text .yml, produced by the
yaml subcommand. It is kept and documented, and it is the right choice in two
cases:
- a human hand-authoring a recipe in a text editor and importing it themselves
- conversion scripts from other structured data — when something already holds recipe-shaped data, emitting YAML (which is just JSON) is often easier than producing the gzipped native format
Both cases accept the same two costs: no update or round-trip (there is no
uid, so re-running a conversion duplicates rather than corrects) and a
manual import — .yml is not associated with the app, so it goes through
File → Import on macOS or Settings → Import on iOS.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs yaml recipe.json --out ~/Downloads/r.yml
📖 Full comparison, evidence and the failure modes:
references/import-formats.md.
Linking to another recipe — [recipe:Name]
Paprika resolves a [recipe:Exact Recipe Name] token in a text field into a
tappable link to that recipe. It is plain text in the stored field, not a
separate column — which is why it survives the interchange format untouched.
1 tbsp [recipe:Spice Rub] inline, after a quantity
Vegan Ricotta ([recipe:Cashew Ricotta]) in parentheses, beside a label
Serve with:
[recipe:Chili / Garlic Oil] standalone on its own line
What is verified:
- It resolves by name, not by uid. The token carries a name; the name may
contain spaces, slashes and punctuation. It must match the target's
nameexactly — so rename a linked recipe and the link goes stale. - It round-trips. The token survives
get→build→importand appears verbatim in Paprika's own.paprikarecipesexport. Nothing escapes or rewrites it. - An unknown target is harmless to write. Importing a recipe whose token names a recipe that does not exist succeeds normally — no error, no crash.
- Rendering is confirmed for
ingredients. A token in that field displays as a tappable link — observed in the Paprika iOS app. The stored format is identical on both platforms, but rendering was not separately checked on macOS.
What is not verified: how a token renders in directions or notes (it is
stored there fine, and real libraries do use it in notes), and what an
unknown target looks like on screen — plain literal text or a dead link. If
that matters, put links in ingredients, which is the field that is proven.
Link both ways
A dish links down to its component in ingredients. Also link the component
back up to the dishes that use it, in its notes:
Used with:
[recipe:Roast Carrots with Spice Rub]
[recipe:Grilled Aubergine with Spice Rub]
Without it the relationship is one-way. Opening the component — which is exactly what happens when someone is deciding whether to make a batch — gives no way to reach the dishes it was made for. That is the moment the answer is most useful.
Keep it to the list. The dish already describes the dish; a paragraph here just goes stale when a dish changes.
Two caveats specific to this direction:
- It lives in
notes, where rendering is unverified (see above). Real libraries do use links there, but if you need a guaranteed-rendering link,ingredientsis the proven field. - Every back-link is another name to keep in sync. Links resolve by name, so renaming a dish silently breaks its component's back-link as well as any forward link. Prefer back-links on components that are genuinely reused; a one-off does not need one.
Because a link is just text, check the target exists before emitting one:
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs list --search "Spice Rub" \
| jq -r '.[].name' # must print the name exactly as you'll write it
3 — Build and import.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs build recipe.json --out ~/Downloads/recipe.paprikarecipe
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs import ~/Downloads/recipe.paprikarecipe --confirm
Attach a photo with --photo image.jpg (embedded as base64 in the file; must
be a JPEG). Import several at once by building a bundle:
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs bundle a.json b.json --out ~/Downloads/batch.paprikarecipes
Photos replace, they don't merge
The single most destructive behaviour in this format, verified by test:
- An import overwrites the recipe's photo state. A file with no embedded image sets the photo to null and deletes the JPEG from disk. There is no "leave the photo alone" — silence means delete.
photo_datawithout aphotofilename terminates the app mid-import. No recipe written, no error dialog — the app is simply gone. macOS writes a crash report (~/Library/Logs/DiagnosticReports/):SIGABRTfrom an uncaught exception on anNSManagedObjectContextqueue. Check there before concluding an import "did nothing". The filename is where the decoded JPEG gets written; it is not optional.
paprika-recipe.mjs build handles both: it re-embeds the current photo
whenever the input carries a photo_path that exists on disk (paprika-db.mjs get fills that in), and it always emits a filename alongside photo_data.
When a recipe names a photo that isn't on disk, build refuses outright
rather than writing a file whose import would delete it:
error: "Miso Soup" refers to photo AE175116-….jpg, but no local JPEG was found
That happens when the photo hasn't been downloaded from Paprika Cloud yet —
most likely soon after a fresh install, while the library is still syncing.
Open the recipe in the app so the photo downloads, then retry. Pass
--no-photo only when removing the photo is what you actually want.
photo_hash is computed by the app — an uppercase SHA-256 of the JPEG bytes.
Never supply it. Re-importing byte-identical image data is a no-op.
4 — Verify, always.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs list --search "Rhabarberkompott"
The import is not headless
Paprika raises a modal sheet on its main window:
Import Recipes — Are you sure you want to import the recipes contained in this file? Format: Paprika (paprikarecipe) —
Cancel/Import
then a second sheet: Import Complete — Successfully imported 1 recipes. —
OK.
--confirm clicks through both via System Events, but only after checking the
sheet's own text; anything unexpected is left alone for a human. Without
--confirm, tell the user to click Import — don't report success until the
database confirms it.
Editing an existing recipe
Importing a file whose uid matches an existing recipe updates that recipe in
place — the app reports "1 recipes updated" and the row count doesn't change.
node ${CLAUDE_SKILL_DIR}/scripts/paprika-db.mjs get "Some Recipe" > edit.json
# edit the JSON, keeping its uid
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs build edit.json --keep-ids --out ~/Downloads/edit.paprikarecipe
node ${CLAUDE_SKILL_DIR}/scripts/paprika-recipe.mjs import ~/Downloads/edit.paprikarecipe --confirm
--keep-ids is the only destructive flag here. Three caveats:
- Categories merge rather than replace — an import can add a category to a recipe but cannot remove one.
- The original
createdis preserved. - The photo is replaced, not merged. Piping
getoutput intobuildas shown above carries it over; a hand-written JSON with nophoto_pathdeletes it.buildrefuses when it can see a photo it cannot embed, but it cannot detect a photo you never told it about. See "Photos replace, they don't merge".
One source, several recipes
A source that gives a component plus dishes made from it — a sauce and two salads that use it — can go in as one recipe or several. Both shapes appear in real libraries:
| Shape | How | Use when |
|---|---|---|
| Sectioned | One recipe; ingredients carries labelled blocks (For the Sauce:, To Serve:) |
The component only exists to serve this dish |
| Extracted | The component becomes its own recipe; each dish names it as an ingredient line (2 tbsp Garlic Confit) |
The component is reusable across dishes |
The deciding question is "would the user make this component for something else?" — if the source itself uses it in more than one dish, that's a yes.
Extracting is cheap because of [recipe:Name] (above): each dish links to
the component and the link is tappable, so the two aren't merely related by a
matching string. Write the ingredient line as 80 g [recipe:Garlic Confit],
not a bare name — and link the component back to its dishes in notes, so the
set is navigable from either end.
The one real cost that remains: the shopping list adds the component as a single line rather than expanding it into its own ingredients, so shopping for a dish you haven't already got the component for takes two steps.
Ask rather than guess. Check the library's existing habit first
(paprika-db.mjs categories — a large "Condiments"-style bucket is a strong
signal the user extracts components), then confirm before importing several
recipes from one source.
Other import paths
Web clipper — works almost always. The share extension
(Paprika Mac Share.appex) takes a page from the browser share sheet and
parses the recipe out of it. This is the normal route for a URL, and it is
better than anything this skill does: it gets the photo, the source, and the
structure in one step. If the user gives you a recipe URL, suggest the
clipper first.
Manual import — when the clipper can't parse the site. Paprika says so
plainly: "We couldn't process the recipe on this site. But don't worry, you
can still save the recipe using the clipboard tools below." The fallback is
the app's own Browser tab plus its clipboard tools (Smart Copy/Paste,
Detect Clipboard URLs): open the page in Paprika's browser, select the
ingredients, paste into the ingredients field, repeat for directions.
This skill is the third option — and the only one that works for sources that aren't a web page at all, or when the work should happen without a human driving the UI field by field. For a page the clipper failed on, fetching the page and structuring it here is usually faster than manual copy-paste.
Sync — an import is not local
A local import publishes to every device on the account. Verified on a separate device (Paprika iOS over cellular, away from the Mac) minutes after a Mac-side import: title, star rating, categories, source, prep time, servings, ingredients and directions as separate lines, and notes all survived.
Consequences worth taking seriously:
- There is no local-only mode and no dry run. An imported recipe lands with
ZISSYNCED=1and is already on its way out. You cannot "test quietly." - A mistake is not confined to this machine. A bad bulk import shows up on the user's phone within a minute. This is why the caution against bulk-writing is not bureaucratic — undoing it means deleting recipes on a device you don't control.
- Test with one obviously-named throwaway, then tell the user it exists.
Don't act on recipe counts while a sync is landing. A fresh install pulls
the library in over minutes (observed: 122 → 259 rows over ~15 minutes). Before
anything that depends on a total, check that paprika-db.mjs count returns the
same totals and the same sync_revisions twice, at least 60 s apart.