refactor(cron): rebrand Cron Recipes -> Automation Blueprints

Product rename across every surface: module/file names (blueprint_catalog,
tools/blueprints, blueprint_cmd), slash command /cron-recipe -> /blueprint
(alias /bp), dashboard API /api/cron/blueprints, desktop deep-link
hermes://blueprint/<key>, docs catalog page + extract script, and the
skill frontmatter block metadata.hermes.blueprint. No behavior change.
fix/verification-admin-route-recovery
Teknium 2026-06-11 10:23:27 -07:00
parent 3c489fda81
commit cb29e8a82e
29 changed files with 627 additions and 627 deletions

6
.gitignore vendored
View File

@ -89,9 +89,9 @@ website/static/api/skills-index.json
# every build). # every build).
website/static/api/skills.json website/static/api/skills.json
website/static/api/skills-meta.json website/static/api/skills-meta.json
# cron-recipes-index.json is a build artifact emitted by # automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-cron-recipes.py during prebuild. # website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/cron-recipes-index.json website/static/api/automation-blueprints-index.json
models-dev-upstream/ models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo) # Local editor / agent tooling (machine-specific; keep in global config, not the repo)

View File

@ -6112,7 +6112,7 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20))
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// hermes:// deep links (e.g. hermes://cron-recipe/morning-brief?time=08:00). // hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00).
// A docs/dashboard "Send to App" button opens this URL; we route it into the // A docs/dashboard "Send to App" button opens this URL; we route it into the
// running app's chat composer. Three delivery paths: macOS 'open-url', // running app's chat composer. Three delivery paths: macOS 'open-url',
// Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. // Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv.
@ -6135,7 +6135,7 @@ function handleDeepLink(url) {
rememberLog(`[deeplink] ignoring malformed url: ${url}`) rememberLog(`[deeplink] ignoring malformed url: ${url}`)
return return
} }
// hermes://cron-recipe/<key>?slot=val -> host="cron-recipe", path="/<key>" // hermes://blueprint/<key>?slot=val -> host="blueprint", path="/<key>"
const kind = parsed.hostname || '' const kind = parsed.hostname || ''
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, '')) const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
const params = {} const params = {}

View File

@ -267,14 +267,14 @@ export function DesktopController() {
} }
}, []) }, [])
// hermes:// deep links (e.g. a docs "Send to App" button for a cron recipe). // hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /cron-recipe slash command from the payload and drop // Build the equivalent /blueprint slash command from the payload and drop
// it into the composer — the user reviews/edits, then sends; the agent (or // it into the composer — the user reviews/edits, then sends; the agent (or
// the shared command handler) creates the job. Signal readiness so a link // the shared command handler) creates the job. Signal readiness so a link
// that arrived during boot is flushed exactly once. // that arrived during boot is flushed exactly once.
useEffect(() => { useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => { const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
if (!payload || payload.kind !== 'cron-recipe' || !payload.name) { if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return return
} }
const slots = Object.entries(payload.params || {}) const slots = Object.entries(payload.params || {})
@ -283,7 +283,7 @@ export function DesktopController() {
return `${k}=${sval}` return `${k}=${sval}`
}) })
.join(' ') .join(' ')
const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}` const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' }) requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main') requestComposerFocus('main')
}) })

8
cli.py
View File

@ -3504,7 +3504,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# the next submitted input, whether it's the selection or anything # the next submitted input, whether it's the selection or anything
# else). See #34584. # else). See #34584.
self._pending_resume_sessions = None self._pending_resume_sessions = None
# One-shot agent seed set by a slash handler (e.g. /cron-recipe <name>) # One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
# that wants its output run as the next agent turn. Consumed and cleared # that wants its output run as the next agent turn. Consumed and cleared
# by the interactive loop immediately after process_command() returns. # by the interactive loop immediately after process_command() returns.
self._pending_agent_seed = None self._pending_agent_seed = None
@ -7415,8 +7415,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_cron_command(cmd_original) self._handle_cron_command(cmd_original)
elif canonical == "suggestions": elif canonical == "suggestions":
self._handle_suggestions_command(cmd_original) self._handle_suggestions_command(cmd_original)
elif canonical == "cron-recipe": elif canonical == "blueprint":
self._handle_cron_recipe_command(cmd_original) self._handle_blueprint_command(cmd_original)
elif canonical == "curator": elif canonical == "curator":
self._handle_curator_command(cmd_original) self._handle_curator_command(cmd_original)
elif canonical == "kanban": elif canonical == "kanban":
@ -12837,7 +12837,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint("\n[dim]Command interrupted.[/dim]") _cprint("\n[dim]Command interrupted.[/dim]")
continue continue
# A slash handler may set a one-shot pending seed (e.g. # A slash handler may set a one-shot pending seed (e.g.
# /cron-recipe <name>) to be run as the next agent turn. # /blueprint <name>) to be run as the next agent turn.
# If present, fall through to the chat path with the seed # If present, fall through to the chat path with the seed
# as the user message instead of looping back to idle. # as the user message instead of looping back to idle.
_seed = getattr(self, "_pending_agent_seed", None) _seed = getattr(self, "_pending_agent_seed", None)

View File

@ -1,23 +1,23 @@
"""Cron Recipes — parameterized automation templates with typed slots. """Automation Blueprints — parameterized automation templates with typed slots.
A *recipe* is a one-place definition of an automation that every surface A *blueprint* is a one-place definition of an automation that every surface
renders natively: renders natively:
* Dashboard / GUI app -> a form (one field per slot) * Dashboard / GUI app -> a form (one field per slot)
* CLI / TUI / messenger -> a pre-filled ``/cron-recipe`` slash command * CLI / TUI / messenger -> a pre-filled ``/blueprint`` slash command
* Agent -> a seed prompt; it asks for any blank/ambiguous slot * Agent -> a seed prompt; it asks for any blank/ambiguous slot
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link * Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
The single source of truth is the slot schema below. ``recipe_form_schema`` The single source of truth is the slot schema below. ``blueprint_form_schema``
emits what a form renderer needs; ``recipe_slash_command`` emits the flattened emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
one-line command; ``fill_recipe`` validates user-supplied values and turns a one-line command; ``fill_blueprint`` validates user-supplied values and turns a
recipe into a ``cron.jobs.create_job`` kwargs dict (so there is no second job blueprint into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat engine). The form-where-there's-a-screen / agent-fills-where-there's-a-chat
split both consume this same module. split both consume this same module.
Design choice: users never type raw cron. A recipe carries a fixed recurrence Design choice: users never type raw cron. A blueprint carries a fixed recurrence
in ``schedule_template`` and parameterizes only the human-friendly parts in ``schedule_template`` and parameterizes only the human-friendly parts
(time-of-day, weekday set). Recipes needing full flexibility expose a ``text`` (time-of-day, weekday set). Blueprints needing full flexibility expose a ``text``
slot named ``schedule`` that passes through verbatim. slot named ``schedule`` that passes through verbatim.
""" """
@ -28,21 +28,21 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
__all__ = [ __all__ = [
"RecipeSlot", "BlueprintSlot",
"CronRecipe", "AutomationBlueprint",
"CATALOG", "CATALOG",
"get_recipe", "get_blueprint",
"recipe_form_schema", "blueprint_form_schema",
"recipe_slash_command", "blueprint_slash_command",
"recipe_deeplink", "blueprint_deeplink",
"recipe_catalog_entry", "blueprint_catalog_entry",
"fill_recipe", "fill_blueprint",
"RecipeFillError", "BlueprintFillError",
"WEEKDAY_PRESETS", "WEEKDAY_PRESETS",
] ]
class RecipeFillError(ValueError): class BlueprintFillError(ValueError):
"""Raised when supplied slot values fail validation.""" """Raised when supplied slot values fail validation."""
@ -58,8 +58,8 @@ WEEKDAY_PRESETS: Dict[str, str] = {
@dataclass(frozen=True) @dataclass(frozen=True)
class RecipeSlot: class BlueprintSlot:
"""A single fillable field on a recipe.""" """A single fillable field on a blueprint."""
name: str name: str
type: str type: str
@ -80,7 +80,7 @@ class RecipeSlot:
@dataclass(frozen=True) @dataclass(frozen=True)
class CronRecipe: class AutomationBlueprint:
"""A parameterized automation template.""" """A parameterized automation template."""
key: str key: str
@ -93,7 +93,7 @@ class CronRecipe:
schedule_template: str schedule_template: str
# Seed instruction for the agent / the cron job prompt; may contain {slot}s. # Seed instruction for the agent / the cron job prompt; may contain {slot}s.
prompt_template: str prompt_template: str
slots: List[RecipeSlot] = field(default_factory=list) slots: List[BlueprintSlot] = field(default_factory=list)
deliver_default: str = "origin" deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running skills: tuple = () # skills the job loads before running
tags: tuple = () tags: tuple = ()
@ -103,11 +103,11 @@ class CronRecipe:
# Curated in-repo catalog # Curated in-repo catalog
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TIME = lambda default="08:00": RecipeSlot( # noqa: E731 - concise factory _TIME = lambda default="08:00": BlueprintSlot( # noqa: E731 - concise factory
name="time", type="time", label="What time?", default=default, name="time", type="time", label="What time?", default=default,
help="24h local time, e.g. 08:00", help="24h local time, e.g. 08:00",
) )
_DELIVER = RecipeSlot( _DELIVER = BlueprintSlot(
name="deliver", type="enum", label="Where to deliver?", name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"), default="origin", options=("origin", "local", "telegram", "discord", "email"),
optional=False, strict=False, optional=False, strict=False,
@ -117,8 +117,8 @@ _DELIVER = RecipeSlot(
) )
CATALOG: List[CronRecipe] = [ CATALOG: List[AutomationBlueprint] = [
CronRecipe( AutomationBlueprint(
key="morning-brief", key="morning-brief",
title="Morning briefing", title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and " description="A short daily briefing: today's calendar, weather, and "
@ -134,7 +134,7 @@ CATALOG: List[CronRecipe] = [
slots=[_TIME("08:00"), _DELIVER], slots=[_TIME("08:00"), _DELIVER],
tags=("daily", "briefing"), tags=("daily", "briefing"),
), ),
CronRecipe( AutomationBlueprint(
key="important-mail", key="important-mail",
title="Important-mail monitor", title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail " description="Check your inbox periodically and ping you ONLY about mail "
@ -149,12 +149,12 @@ CATALOG: List[CronRecipe] = [
"configured, explain how to connect one and stop." "configured, explain how to connect one and stop."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="interval_min", type="enum", label="How often?", name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"), default="30", options=("15", "30", "60"),
help="minutes between checks", help="minutes between checks",
), ),
RecipeSlot( BlueprintSlot(
name="criteria", type="text", name="criteria", type="text",
label="Only notify me if the mail…", label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, " default="needs a reply today, is from my manager or family, "
@ -164,7 +164,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("email", "monitor"), tags=("email", "monitor"),
), ),
CronRecipe( AutomationBlueprint(
key="weekly-review", key="weekly-review",
title="Weekly review", title="Weekly review",
description="A weekly recap: what got done, what's still open, and " description="A weekly recap: what got done, what's still open, and "
@ -178,7 +178,7 @@ CATALOG: List[CronRecipe] = [
), ),
slots=[ slots=[
_TIME("18:00"), _TIME("18:00"),
RecipeSlot( BlueprintSlot(
name="day", type="enum", label="Which day?", name="day", type="enum", label="Which day?",
default="sunday", default="sunday",
options=("sunday", "monday", "friday", "saturday"), options=("sunday", "monday", "friday", "saturday"),
@ -187,7 +187,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("weekly", "review"), tags=("weekly", "review"),
), ),
CronRecipe( AutomationBlueprint(
key="workday-start", key="workday-start",
title="Workday start reminder", title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.", description="A weekday nudge with your agenda and top priorities.",
@ -201,7 +201,7 @@ CATALOG: List[CronRecipe] = [
slots=[_TIME("09:00"), _DELIVER], slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"), tags=("daily", "focus"),
), ),
CronRecipe( AutomationBlueprint(
key="custom-reminder", key="custom-reminder",
title="Custom reminder", title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.", description="A recurring reminder in your own words, on your schedule.",
@ -209,10 +209,10 @@ CATALOG: List[CronRecipe] = [
schedule_template="{minute} {hour} * * {dow}", schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}", prompt_template="Remind the user: {what}",
slots=[ slots=[
RecipeSlot(name="what", type="text", label="Remind me to…", BlueprintSlot(name="what", type="text", label="Remind me to…",
default="take a break and stretch"), default="take a break and stretch"),
_TIME("14:00"), _TIME("14:00"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="everyday", default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
@ -221,7 +221,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("reminder",), tags=("reminder",),
), ),
CronRecipe( AutomationBlueprint(
key="evening-winddown", key="evening-winddown",
title="Evening wind-down", title="Evening wind-down",
description="An end-of-day check-in: tomorrow's calendar at a glance " description="An end-of-day check-in: tomorrow's calendar at a glance "
@ -238,7 +238,7 @@ CATALOG: List[CronRecipe] = [
slots=[_TIME("21:00"), _DELIVER], slots=[_TIME("21:00"), _DELIVER],
tags=("daily", "evening"), tags=("daily", "evening"),
), ),
CronRecipe( AutomationBlueprint(
key="news-digest", key="news-digest",
title="Topic news digest", title="Topic news digest",
description="A recurring digest on a topic you care about — deduped " description="A recurring digest on a topic you care about — deduped "
@ -253,18 +253,18 @@ CATALOG: List[CronRecipe] = [
"last run, respond with [SILENT]." "last run, respond with [SILENT]."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="topic", type="text", label="What topic?", name="topic", type="text", label="What topic?",
default="AI and technology", default="AI and technology",
help="a subject, product, person, or search phrase", help="a subject, product, person, or search phrase",
), ),
_TIME("18:00"), _TIME("18:00"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="weekdays", default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
), ),
RecipeSlot( BlueprintSlot(
name="count", type="enum", label="How many bullets?", name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"), default="5", options=("3", "5", "8"),
), ),
@ -272,7 +272,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("digest", "research"), tags=("digest", "research"),
), ),
CronRecipe( AutomationBlueprint(
key="bill-renewal-watch", key="bill-renewal-watch",
title="Bills & renewals reminder", title="Bills & renewals reminder",
description="A heads-up before a recurring payment, subscription " description="A heads-up before a recurring payment, subscription "
@ -285,12 +285,12 @@ CATALOG: List[CronRecipe] = [
"it renews'), not just a notification. One short message." "it renews'), not just a notification. One short message."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="what", type="text", label="What's due?", name="what", type="text", label="What's due?",
default="my streaming subscription renews soon", default="my streaming subscription renews soon",
), ),
_TIME("10:00"), _TIME("10:00"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="everyday", default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
@ -299,7 +299,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("reminder", "finance"), tags=("reminder", "finance"),
), ),
CronRecipe( AutomationBlueprint(
key="habit-checkin", key="habit-checkin",
title="Habit check-in", title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect " description="A recurring nudge to keep a habit on track and reflect "
@ -312,12 +312,12 @@ CATALOG: List[CronRecipe] = [
"of encouragement. One short message." "of encouragement. One short message."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="habit", type="text", label="Which habit?", name="habit", type="text", label="Which habit?",
default="20 minutes of reading", default="20 minutes of reading",
), ),
_TIME("20:00"), _TIME("20:00"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="everyday", default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
@ -326,7 +326,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("habit", "wellbeing"), tags=("habit", "wellbeing"),
), ),
CronRecipe( AutomationBlueprint(
key="hydration-move", key="hydration-move",
title="Hydration & movement nudge", title="Hydration & movement nudge",
description="A periodic nudge during the day to drink water, stand up, " description="A periodic nudge during the day to drink water, stand up, "
@ -342,17 +342,17 @@ CATALOG: List[CronRecipe] = [
"doesn't feel robotic. One short line." "doesn't feel robotic. One short line."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="interval_hours", type="enum", label="How often?", name="interval_hours", type="enum", label="How often?",
default="1", options=("1", "2", "3"), default="1", options=("1", "2", "3"),
help="hours between nudges", help="hours between nudges",
), ),
RecipeSlot( BlueprintSlot(
name="start_hour", type="enum", label="Start hour", name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"), default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)", help="first hour of the active window (24h)",
), ),
RecipeSlot( BlueprintSlot(
name="end_hour", type="enum", label="End hour", name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"), default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)", help="last hour of the active window (24h)",
@ -361,7 +361,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("wellbeing", "focus"), tags=("wellbeing", "focus"),
), ),
CronRecipe( AutomationBlueprint(
key="meal-plan", key="meal-plan",
title="Weekly meal plan", title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, " description="A weekly meal plan plus a consolidated grocery list, "
@ -371,27 +371,27 @@ CATALOG: List[CronRecipe] = [
prompt_template=( prompt_template=(
"Build the user a meal plan for the coming week: {meals} per day, " "Build the user a meal plan for the coming week: {meals} per day, "
"suited to a {diet} diet and roughly {effort} cooking effort. " "suited to a {diet} diet and roughly {effort} cooking effort. "
"Include a consolidated grocery list grouped by aisle. Keep recipes " "Include a consolidated grocery list grouped by aisle. Keep blueprints "
"simple and skimmable." "simple and skimmable."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="diet", type="enum", label="Diet?", name="diet", type="enum", label="Diet?",
default="no restrictions", default="no restrictions",
options=("no restrictions", "vegetarian", "vegan", options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"), "high-protein", "low-carb"),
), ),
RecipeSlot( BlueprintSlot(
name="meals", type="enum", label="Meals per day?", name="meals", type="enum", label="Meals per day?",
default="dinner only", default="dinner only",
options=("dinner only", "lunch and dinner", "all three"), options=("dinner only", "lunch and dinner", "all three"),
), ),
RecipeSlot( BlueprintSlot(
name="effort", type="enum", label="Cooking effort?", name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"), default="quick", options=("quick", "medium", "ambitious"),
), ),
_TIME("17:00"), _TIME("17:00"),
RecipeSlot( BlueprintSlot(
name="day", type="enum", label="Which day?", name="day", type="enum", label="Which day?",
default="sunday", default="sunday",
options=("sunday", "monday", "friday", "saturday"), options=("sunday", "monday", "friday", "saturday"),
@ -400,7 +400,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("weekly", "food"), tags=("weekly", "food"),
), ),
CronRecipe( AutomationBlueprint(
key="learn-daily", key="learn-daily",
title="Daily learning drip", title="Daily learning drip",
description="One bite-sized lesson a day on a topic you want to learn, " description="One bite-sized lesson a day on a topic you want to learn, "
@ -414,12 +414,12 @@ CATALOG: List[CronRecipe] = [
"with a single question to check understanding." "with a single question to check understanding."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="topic", type="text", label="Learn about…", name="topic", type="text", label="Learn about…",
default="Spanish vocabulary", default="Spanish vocabulary",
), ),
_TIME("08:30"), _TIME("08:30"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="weekdays", default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
@ -428,7 +428,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("learning", "daily"), tags=("learning", "daily"),
), ),
CronRecipe( AutomationBlueprint(
key="gratitude-journal", key="gratitude-journal",
title="Gratitude & reflection prompt", title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note " description="A gentle evening prompt to reflect on the day and note "
@ -443,7 +443,7 @@ CATALOG: List[CronRecipe] = [
), ),
slots=[ slots=[
_TIME("21:30"), _TIME("21:30"),
RecipeSlot( BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on", name="recurrence", type="weekdays", label="Repeat on",
default="everyday", default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()), options=tuple(WEEKDAY_PRESETS.keys()),
@ -452,7 +452,7 @@ CATALOG: List[CronRecipe] = [
], ],
tags=("wellbeing", "reflection"), tags=("wellbeing", "reflection"),
), ),
CronRecipe( AutomationBlueprint(
key="on-this-day", key="on-this-day",
title="On-this-day discovery", title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, " description="A daily dose of curiosity: a notable historical event, "
@ -465,7 +465,7 @@ CATALOG: List[CronRecipe] = [
"no filler." "no filler."
), ),
slots=[ slots=[
RecipeSlot( BlueprintSlot(
name="flavor", type="enum", label="What kind?", name="flavor", type="enum", label="What kind?",
default="on this day in history", default="on this day in history",
options=("on this day in history", "word of the day", options=("on this day in history", "word of the day",
@ -481,7 +481,7 @@ CATALOG: List[CronRecipe] = [
_CATALOG_BY_KEY = {r.key: r for r in CATALOG} _CATALOG_BY_KEY = {r.key: r for r in CATALOG}
def get_recipe(key: str) -> Optional[CronRecipe]: def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
return _CATALOG_BY_KEY.get(key) return _CATALOG_BY_KEY.get(key)
@ -489,14 +489,14 @@ def get_recipe(key: str) -> Optional[CronRecipe]:
# Renderers # Renderers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]: def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this recipe.""" """Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
return { return {
"key": recipe.key, "key": blueprint.key,
"title": recipe.title, "title": blueprint.title,
"description": recipe.description, "description": blueprint.description,
"category": recipe.category, "category": blueprint.category,
"tags": list(recipe.tags), "tags": list(blueprint.tags),
"fields": [ "fields": [
{ {
"name": s.name, "name": s.name,
@ -508,20 +508,20 @@ def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]:
"strict": s.strict, "strict": s.strict,
"help": s.help, "help": s.help,
} }
for s in recipe.slots for s in blueprint.slots
], ],
} }
def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/cron-recipe <key> slot=val …`` command string. """Build the flattened ``/blueprint <key> slot=val …`` command string.
Uses each slot's default when ``values`` is omitted, so the docs/dashboard Uses each slot's default when ``values`` is omitted, so the docs/dashboard
can show a ready-to-paste command. Free-text slots are quoted. can show a ready-to-paste command. Free-text slots are quoted.
""" """
values = values or {} values = values or {}
parts = [f"/cron-recipe {recipe.key}"] parts = [f"/blueprint {blueprint.key}"]
for s in recipe.slots: for s in blueprint.slots:
val = values.get(s.name, s.default) val = values.get(s.name, s.default)
if val is None or val == "": if val is None or val == "":
if s.optional: if s.optional:
@ -534,38 +534,38 @@ def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] =
return " ".join(parts) return " ".join(parts)
def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str: def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://cron-recipe/<key>?slot=val`` deep-link URL.""" """Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
from urllib.parse import quote, urlencode from urllib.parse import quote, urlencode
values = values or {} values = values or {}
query = {} query = {}
for s in recipe.slots: for s in blueprint.slots:
val = values.get(s.name, s.default) val = values.get(s.name, s.default)
if val not in (None, ""): if val not in (None, ""):
query[s.name] = str(val) query[s.name] = str(val)
qs = ("?" + urlencode(query)) if query else "" qs = ("?" + urlencode(query)) if query else ""
return f"hermes://cron-recipe/{quote(recipe.key)}{qs}" return f"hermes://blueprint/{quote(blueprint.key)}{qs}"
def _humanize_schedule(recipe: CronRecipe) -> str: def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
"""A short human-readable description of when a recipe runs (defaults).""" """A short human-readable description of when a blueprint runs (defaults)."""
sched = recipe.schedule_template sched = blueprint.schedule_template
if sched.startswith("*/"): if sched.startswith("*/"):
iv = next((s for s in recipe.slots if s.name == "interval_min"), None) iv = next((s for s in blueprint.slots if s.name == "interval_min"), None)
every = (iv.default if iv else None) or sched.split("/")[1].split()[0] every = (iv.default if iv else None) or sched.split("/")[1].split()[0]
return f"every {every} minutes" return f"every {every} minutes"
if "{interval_hours}" in sched: if "{interval_hours}" in sched:
iv = next((s for s in recipe.slots if s.name == "interval_hours"), None) iv = next((s for s in blueprint.slots if s.name == "interval_hours"), None)
every = str((iv.default if iv else None) or "1") every = str((iv.default if iv else None) or "1")
scope = "weekdays, " if "* * 1-5" in sched else "" scope = "weekdays, " if "* * 1-5" in sched else ""
return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours" return f"{scope}every hour" if every == "1" else f"{scope}every {every} hours"
time_slot = next((s for s in recipe.slots if s.type == "time"), None) time_slot = next((s for s in blueprint.slots if s.type == "time"), None)
when = time_slot.default if time_slot else None when = time_slot.default if time_slot else None
if "* * 1-5" in sched: if "* * 1-5" in sched:
return f"weekdays at {when}" if when else "every weekday" return f"weekdays at {when}" if when else "every weekday"
if "{dow}" in sched: if "{dow}" in sched:
day_slot = next((s for s in recipe.slots if s.name in ("day", "recurrence")), None) day_slot = next((s for s in blueprint.slots if s.name in ("day", "recurrence")), None)
scope = (day_slot.default if day_slot else "") or "" scope = (day_slot.default if day_slot else "") or ""
if scope and when: if scope and when:
return f"{scope} at {when}" return f"{scope} at {when}"
@ -575,17 +575,17 @@ def _humanize_schedule(recipe: CronRecipe) -> str:
return "on a schedule" return "on a schedule"
def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]: def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Unified serializable shape for a recipe — used by the docs generator """Unified serializable shape for a blueprint — used by the docs generator
and the dashboard API. Combines the form schema, the ready-to-paste slash and the dashboard API. Combines the form schema, the ready-to-paste slash
command, the deep-link URL, and a human-readable schedule. command, the deep-link URL, and a human-readable schedule.
""" """
return { return {
**recipe_form_schema(recipe), **blueprint_form_schema(blueprint),
"schedule": recipe.schedule_template, "schedule": blueprint.schedule_template,
"scheduleHuman": _humanize_schedule(recipe), "scheduleHuman": _humanize_schedule(blueprint),
"command": recipe_slash_command(recipe), "command": blueprint_slash_command(blueprint),
"appUrl": recipe_deeplink(recipe), "appUrl": blueprint_deeplink(blueprint),
} }
@ -600,9 +600,9 @@ _DAY_TO_DOW = {
} }
def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str: def _resolve_schedule(blueprint: AutomationBlueprint, values: Dict[str, Any]) -> str:
"""Fill the schedule_template placeholders from resolved slot values.""" """Fill the schedule_template placeholders from resolved slot values."""
sched = recipe.schedule_template sched = blueprint.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility). # A free-text `schedule` slot passes through verbatim (full flexibility).
if "schedule" in values and values["schedule"]: if "schedule" in values and values["schedule"]:
@ -614,10 +614,10 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
time_val = values.get("time") time_val = values.get("time")
if "{minute}" in sched or "{hour}" in sched: if "{minute}" in sched or "{hour}" in sched:
if not time_val: if not time_val:
raise RecipeFillError("a time is required") raise BlueprintFillError("a time is required")
m = _TIME_RE.match(str(time_val).strip()) m = _TIME_RE.match(str(time_val).strip())
if not m: if not m:
raise RecipeFillError(f"invalid time {time_val!r} — use HH:MM (24h)") raise BlueprintFillError(f"invalid time {time_val!r} — use HH:MM (24h)")
repl["hour"] = str(int(m.group(1))) repl["hour"] = str(int(m.group(1)))
repl["minute"] = str(int(m.group(2))) repl["minute"] = str(int(m.group(2)))
@ -626,14 +626,14 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
if "recurrence" in values: if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower() preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS: if preset not in WEEKDAY_PRESETS:
raise RecipeFillError( raise BlueprintFillError(
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}" f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
) )
repl["dow"] = WEEKDAY_PRESETS[preset] repl["dow"] = WEEKDAY_PRESETS[preset]
elif "day" in values: elif "day" in values:
day = str(values.get("day", "")).lower() day = str(values.get("day", "")).lower()
if day not in _DAY_TO_DOW: if day not in _DAY_TO_DOW:
raise RecipeFillError(f"unknown day {day!r}") raise BlueprintFillError(f"unknown day {day!r}")
repl["dow"] = _DAY_TO_DOW[day] repl["dow"] = _DAY_TO_DOW[day]
else: else:
repl["dow"] = "*" repl["dow"] = "*"
@ -642,12 +642,12 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
if "{interval_min}" in sched: if "{interval_min}" in sched:
iv = str(values.get("interval_min", "")).strip() iv = str(values.get("interval_min", "")).strip()
if not iv.isdigit() or int(iv) <= 0: if not iv.isdigit() or int(iv) <= 0:
raise RecipeFillError(f"invalid interval {iv!r} — minutes as a positive integer") raise BlueprintFillError(f"invalid interval {iv!r} — minutes as a positive integer")
repl["interval_min"] = iv repl["interval_min"] = iv
# Any remaining {slot} placeholders are filled verbatim from validated # Any remaining {slot} placeholders are filled verbatim from validated
# enum/text slot values (e.g. an hour-range window). Enum options have # enum/text slot values (e.g. an hour-range window). Enum options have
# already been checked in fill_recipe, so these are safe to interpolate. # already been checked in fill_blueprint, so these are safe to interpolate.
for name in re.findall(r"\{(\w+)\}", sched): for name in re.findall(r"\{(\w+)\}", sched):
if name not in repl and name in values: if name not in repl and name in values:
repl[name] = str(values[name]) repl[name] = str(values[name])
@ -655,59 +655,59 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
try: try:
return sched.format(**repl) return sched.format(**repl)
except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error except KeyError as e: # pragma: no cover - template/slot mismatch is a dev error
raise RecipeFillError(f"schedule template missing value for {e}") from e raise BlueprintFillError(f"schedule template missing value for {e}") from e
def fill_recipe( def fill_blueprint(
recipe: CronRecipe, blueprint: AutomationBlueprint,
values: Dict[str, Any], values: Dict[str, Any],
*, *,
origin: Optional[Dict[str, Any]] = None, origin: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Validate ``values`` and return ``cron.jobs.create_job`` kwargs. """Validate ``values`` and return ``cron.jobs.create_job`` kwargs.
Missing required (non-optional) slots raise RecipeFillError naming the Missing required (non-optional) slots raise BlueprintFillError naming the
slot, so a form can show field errors and the agent knows what to ask. slot, so a form can show field errors and the agent knows what to ask.
Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently Unknown slot names are rejected (a typo'd ``tiem=07:15`` must not silently
create a job with the default time). Enum values are checked against their create a job with the default time). Enum values are checked against their
options. The result is passed straight to ``create_job`` no second schema. options. The result is passed straight to ``create_job`` no second schema.
""" """
known = {s.name for s in recipe.slots} known = {s.name for s in blueprint.slots}
unknown = sorted(set(values) - known) unknown = sorted(set(values) - known)
if unknown: if unknown:
raise RecipeFillError( raise BlueprintFillError(
f"unknown slot{'s' if len(unknown) > 1 else ''}: " f"unknown slot{'s' if len(unknown) > 1 else ''}: "
f"{', '.join(unknown)} — valid: {', '.join(s.name for s in recipe.slots)}" f"{', '.join(unknown)} — valid: {', '.join(s.name for s in blueprint.slots)}"
) )
resolved: Dict[str, Any] = {} resolved: Dict[str, Any] = {}
for s in recipe.slots: for s in blueprint.slots:
raw = values.get(s.name, s.default) raw = values.get(s.name, s.default)
if raw in (None, ""): if raw in (None, ""):
if s.optional: if s.optional:
continue continue
raise RecipeFillError(f"missing required value: {s.name} ({s.label})") raise BlueprintFillError(f"missing required value: {s.name} ({s.label})")
if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}: if s.type == "enum" and s.strict and s.options and str(raw) not in {str(o) for o in s.options}:
raise RecipeFillError( raise BlueprintFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}" f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
) )
resolved[s.name] = raw resolved[s.name] = raw
schedule = _resolve_schedule(recipe, resolved) schedule = _resolve_schedule(blueprint, resolved)
# Render the prompt with whatever slots it references. # Render the prompt with whatever slots it references.
try: try:
prompt = recipe.prompt_template.format(**resolved) prompt = blueprint.prompt_template.format(**resolved)
except KeyError as e: except KeyError as e:
raise RecipeFillError(f"recipe prompt missing value for {e}") from e raise BlueprintFillError(f"blueprint prompt missing value for {e}") from e
spec: Dict[str, Any] = { spec: Dict[str, Any] = {
"prompt": prompt, "prompt": prompt,
"schedule": schedule, "schedule": schedule,
"name": recipe.title, "name": blueprint.title,
"deliver": resolved.get("deliver", recipe.deliver_default), "deliver": resolved.get("deliver", blueprint.deliver_default),
} }
if recipe.skills: if blueprint.skills:
spec["skills"] = list(recipe.skills) spec["skills"] = list(blueprint.skills)
if origin is not None: if origin is not None:
spec["origin"] = origin spec["origin"] = origin
return spec return spec

View File

@ -7,8 +7,8 @@ flows through, regardless of where it came from:
* ``catalog`` a curated starter automation (daily briefing, important-mail * ``catalog`` a curated starter automation (daily briefing, important-mail
monitor, weekly digest, ...). monitor, weekly digest, ...).
* ``recipe`` the user installed a skill that carries a ``recipe:`` block * ``blueprint`` the user installed a skill that carries a ``blueprint:`` block
(see ``tools/recipes.py``); installing it registers a (see ``tools/blueprints.py``); installing it registers a
suggestion instead of auto-scheduling. suggestion instead of auto-scheduling.
* ``usage`` the background self-improvement review noticed a recurring * ``usage`` the background self-improvement review noticed a recurring
ask that a scheduled job would serve. ask that a scheduled job would serve.
@ -53,7 +53,7 @@ _suggestions_lock = threading.Lock()
# new suggestions are dropped (the user should clear the backlog first). # new suggestions are dropped (the user should clear the backlog first).
MAX_PENDING = 5 MAX_PENDING = 5
VALID_SOURCES = frozenset({"catalog", "recipe", "usage", "integration"}) VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
_STATUS_PENDING = "pending" _STATUS_PENDING = "pending"
_STATUS_ACCEPTED = "accepted" _STATUS_ACCEPTED = "accepted"
_STATUS_DISMISSED = "dismissed" _STATUS_DISMISSED = "dismissed"

View File

@ -7174,11 +7174,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "suggestions": if canonical == "suggestions":
return await self._handle_suggestions_command(event) return await self._handle_suggestions_command(event)
if canonical == "cron-recipe": if canonical == "blueprint":
_recipe_result = await self._handle_cron_recipe_command(event) _blueprint_result = await self._handle_blueprint_command(event)
_recipe_seed = getattr(_recipe_result, "agent_seed", None) _blueprint_seed = getattr(_blueprint_result, "agent_seed", None)
if _recipe_seed: if _blueprint_seed:
# Recipe matched — rewrite the turn to the seed and fall # Blueprint matched — rewrite the turn to the seed and fall
# through to _handle_message_with_agent so the agent asks the # through to _handle_message_with_agent so the agent asks the
# user for each slot value conversationally and then calls the # user for each slot value conversationally and then calls the
# cronjob tool (the /steer fall-through pattern). The seed # cronjob tool (the /steer fall-through pattern). The seed
@ -7186,7 +7186,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Send the "Setting up X…" ack first so the user gets the same # Send the "Setting up X…" ack first so the user gets the same
# immediate feedback CLI users see, instead of silence until # immediate feedback CLI users see, instead of silence until
# the agent's first question. # the agent's first question.
_ack = getattr(_recipe_result, "text", "") or "" _ack = getattr(_blueprint_result, "text", "") or ""
if _ack: if _ack:
try: try:
adapter = self.adapters.get(source.platform) adapter = self.adapters.get(source.platform)
@ -7194,13 +7194,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_ack_meta = self._thread_metadata_for_source(source) _ack_meta = self._thread_metadata_for_source(source)
await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta) await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)
except Exception: except Exception:
logger.debug("cron-recipe ack send failed", exc_info=True) logger.debug("blueprint ack send failed", exc_info=True)
try: try:
event.text = _recipe_seed event.text = _blueprint_seed
except Exception: except Exception:
return getattr(_recipe_result, "text", "") or None return getattr(_blueprint_result, "text", "") or None
else: else:
return getattr(_recipe_result, "text", "") or None return getattr(_blueprint_result, "text", "") or None
if canonical == "retry": if canonical == "retry":
return await self._handle_retry_command(event) return await self._handle_retry_command(event)
@ -9298,15 +9298,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("suggestions command failed: %s", e) logger.debug("suggestions command failed: %s", e)
return f"Suggestions command failed: {e}" return f"Suggestions command failed: {e}"
async def _handle_cron_recipe_command(self, event: MessageEvent): async def _handle_blueprint_command(self, event: MessageEvent):
"""Handle /cron-recipe in the gateway. """Handle /blueprint in the gateway.
Delegates to the shared handler so CLI, TUI, and gateway never drift. Delegates to the shared handler so CLI, TUI, and gateway never drift.
Returns a RecipeCommandResult: ``text`` is shown to the user, and if Returns a BlueprintCommandResult: ``text`` is shown to the user, and if
``agent_seed`` is set the dispatch site rewrites ``event.text`` to the ``agent_seed`` is set the dispatch site rewrites ``event.text`` to the
seed and falls through to the agent (the ``/steer`` pattern) so the seed and falls through to the agent (the ``/steer`` pattern) so the
agent gathers the slot values conversationally. Origin is built from the agent gathers the slot values conversationally. Origin is built from the
event source so a directly created recipe job delivers back to this chat. event source so a directly created blueprint job delivers back to this chat.
""" """
args = (event.get_command_args() or "").strip() args = (event.get_command_args() or "").strip()
source = event.source source = event.source
@ -9324,14 +9324,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception: except Exception:
origin = None origin = None
try: try:
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
return handle_cron_recipe_command(args, origin=origin, surface="gateway") return handle_blueprint_command(args, origin=origin, surface="gateway")
except Exception as e: except Exception as e:
logger.debug("cron-recipe command failed: %s", e) logger.debug("blueprint command failed: %s", e)
from hermes_cli.cron_recipe_cmd import RecipeCommandResult from hermes_cli.blueprint_cmd import BlueprintCommandResult
return RecipeCommandResult(f"Cron recipe command failed: {e}") return BlueprintCommandResult(f"Cron blueprint command failed: {e}")
# ──────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────
# /goal — persistent cross-turn goals (Ralph-style loop) # /goal — persistent cross-turn goals (Ralph-style loop)

View File

@ -1,25 +1,25 @@
"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway. """Shared ``/blueprint`` command logic for CLI, TUI, and gateway.
The conversational counterpart to the dashboard's Cron Recipes form. Where a The conversational counterpart to the dashboard's Automation Blueprints form. Where a
surface has a screen, the user fills a form (dashboard / GUI app) and the API surface has a screen, the user fills a form (dashboard / GUI app) and the API
calls ``fill_recipe`` -> ``create_job`` directly. Where a surface is just a calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
chat line, the user picks a recipe by name and the agent asks for what it chat line, the user picks a blueprint by name and the agent asks for what it
needs pick a recipe by name and the agent asks you for what it needs, one needs pick a blueprint by name and the agent asks you for what it needs, one
question at a time (the messaging-assistant model: pick a recipe it asks you question at a time (the messaging-assistant model: pick a blueprint it asks you
a couple things done). a couple things done).
Subcommand shapes: Subcommand shapes:
/cron-recipe list the catalog /blueprint list the catalog
/cron-recipe <name> name-match a recipe, then SEED THE AGENT to /blueprint <name> name-match a blueprint, then SEED THE AGENT to
ask the user for each value conversationally ask the user for each value conversationally
/cron-recipe <name> slot=val fill + create the cron job directly /blueprint <name> slot=val fill + create the cron job directly
(the deterministic dashboard / docs / power- (the deterministic dashboard / docs / power-
user shortcut no agent turn) user shortcut no agent turn)
The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all The ``<name>`` form is forgiving: exact key, unique prefix, or fuzzy match all
resolve; an ambiguous query lists the candidates; an unknown one suggests the resolve; an ambiguous query lists the candidates; an unknown one suggests the
closest. When it resolves, the handler returns an ``agent_seed`` a natural- closest. When it resolves, the handler returns an ``agent_seed`` a natural-
language instruction built from the recipe's typed slots + schedule/prompt language instruction built from the blueprint's typed slots + schedule/prompt
templates that the calling surface feeds to the agent as a normal user turn templates that the calling surface feeds to the agent as a normal user turn
(gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI: (gateway: rewrite ``event.text`` and fall through, the ``/steer`` pattern; CLI:
a one-shot pending seed the main loop runs). The agent then asks for each slot a one-shot pending seed the main loop runs). The agent then asks for each slot
@ -41,12 +41,12 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class RecipeCommandResult: class BlueprintCommandResult:
"""Outcome of a ``/cron-recipe`` invocation. """Outcome of a ``/blueprint`` invocation.
``text`` is always shown to the user. When ``agent_seed`` is set, the ``text`` is always shown to the user. When ``agent_seed`` is set, the
calling surface should ALSO hand that seed to the agent as the user's next calling surface should ALSO hand that seed to the agent as the user's next
turn (the recipe was matched and now the agent gathers the slot values turn (the blueprint was matched and now the agent gathers the slot values
conversationally). When ``agent_seed`` is None the command is fully handled conversationally). When ``agent_seed`` is None the command is fully handled
(catalog listing, direct create, or an error) and nothing is sent to the (catalog listing, direct create, or an error) and nothing is sent to the
agent. agent.
@ -91,11 +91,11 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
return values, leftovers return values, leftovers
def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]: def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed recipe name to a recipe. """Resolve a free-typed blueprint name to a blueprint.
Returns ``(recipe, candidates)``: Returns ``(blueprint, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(recipe, [])`` * exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
* ambiguous (2+ plausible) -> ``(None, [candidates])`` * ambiguous (2+ plausible) -> ``(None, [candidates])``
* no plausible match -> ``(None, [])`` * no plausible match -> ``(None, [])``
@ -103,13 +103,13 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
dashboard/Discord where it's picked): exact key first, then case-insensitive dashboard/Discord where it's picked): exact key first, then case-insensitive
prefix on key or title, then a difflib fuzzy pass. prefix on key or title, then a difflib fuzzy pass.
""" """
from cron.recipe_catalog import CATALOG, get_recipe from cron.blueprint_catalog import CATALOG, get_blueprint
q = (query or "").strip().lower() q = (query or "").strip().lower()
if not q: if not q:
return None, [] return None, []
exact = get_recipe(q) exact = get_blueprint(q)
if exact is not None: if exact is not None:
return exact, [] return exact, []
@ -138,43 +138,43 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
keys = [r.key for r in CATALOG] keys = [r.key for r in CATALOG]
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6) close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
if len(close) == 1: if len(close) == 1:
return get_recipe(close[0]), [] return get_blueprint(close[0]), []
if len(close) > 1: if len(close) > 1:
return None, [get_recipe(k) for k in close] return None, [get_blueprint(k) for k in close]
return None, [] return None, []
def _humanize_schedule(recipe) -> str: def _humanize_schedule(blueprint) -> str:
from cron.recipe_catalog import _humanize_schedule as _h from cron.blueprint_catalog import _humanize_schedule as _h
try: try:
return _h(recipe) return _h(blueprint)
except Exception: except Exception:
return "on a schedule" return "on a schedule"
def build_recipe_seed(recipe) -> str: def build_blueprint_seed(blueprint) -> str:
"""Build the natural-language fill-request the agent will act on. """Build the natural-language fill-request the agent will act on.
The agent reads this as a normal user turn, asks the user for each unfilled The agent reads this as a normal user turn, asks the user for each unfilled
slot one at a time, then calls the ``cronjob`` tool with the slot one at a time, then calls the ``cronjob`` tool with the
cron expression it builds from the recipe's ``schedule_template`` and the cron expression it builds from the blueprint's ``schedule_template`` and the
rendered prompt. Defaults are stated so the agent can offer them. rendered prompt. Defaults are stated so the agent can offer them.
""" """
from cron.recipe_catalog import WEEKDAY_PRESETS from cron.blueprint_catalog import WEEKDAY_PRESETS
lines: List[str] = [] lines: List[str] = []
lines.append( lines.append(
f"Set up the '{recipe.title}' automation for me (cron recipe " f"Set up the '{blueprint.title}' automation for me (automation blueprint "
f"'{recipe.key}'). {recipe.description}" f"'{blueprint.key}'). {blueprint.description}"
) )
lines.append("") lines.append("")
lines.append( lines.append(
"Ask me for each of these, one at a time, offering the default in " "Ask me for each of these, one at a time, offering the default in "
"brackets if I don't have a preference:" "brackets if I don't have a preference:"
) )
for s in recipe.slots: for s in blueprint.slots:
bits = [f"- {s.label} ({s.name})"] bits = [f"- {s.label} ({s.name})"]
if s.options: if s.options:
bits.append(f" — one of: {', '.join(map(str, s.options))}") bits.append(f" — one of: {', '.join(map(str, s.options))}")
@ -190,47 +190,47 @@ def build_recipe_seed(recipe) -> str:
lines.append( lines.append(
"Once you have my answers, create the job by calling the cronjob tool " "Once you have my answers, create the job by calling the cronjob tool "
"with action='create'. Build the schedule as a cron expression from " "with action='create'. Build the schedule as a cron expression from "
f"this template: `{recipe.schedule_template}` " f"this template: `{blueprint.schedule_template}` "
"(fill {minute}/{hour} from the chosen time, {dow} from the weekday " "(fill {minute}/{hour} from the chosen time, {dow} from the weekday "
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any " f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
"interval). Use this exact prompt for the job (substituting my " "interval). Use this exact prompt for the job (substituting my "
f"answers into any {{slot}} placeholders): \"{recipe.prompt_template}\". " f"answers into any {{slot}} placeholders): \"{blueprint.prompt_template}\". "
"Confirm the schedule and what it will do before you create it." "Confirm the schedule and what it will do before you create it."
) )
return "\n".join(lines) return "\n".join(lines)
def _fmt_catalog() -> str: def _fmt_catalog() -> str:
from cron.recipe_catalog import CATALOG from cron.blueprint_catalog import CATALOG
lines = ["Cron Recipes — `/cron-recipe <name>` and I'll ask you what I need:\n"] lines = ["Automation Blueprints — `/blueprint <name>` and I'll ask you what I need:\n"]
for r in CATALOG: for r in CATALOG:
lines.append(f"{r.key}{r.title}") lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}") lines.append(f" {r.description}")
lines.append( lines.append(
"\nTip: `/cron-recipe <name>` walks you through it. Power users can " "\nTip: `/blueprint <name>` walks you through it. Power users can "
"pass values inline, e.g. `/cron-recipe morning-brief time=08:00`." "pass values inline, e.g. `/blueprint morning-brief time=08:00`."
) )
return "\n".join(lines) return "\n".join(lines)
def _fmt_candidates(query: str, candidates: List[Any]) -> str: def _fmt_candidates(query: str, candidates: List[Any]) -> str:
lines = [f"'{query}' matches several recipes — which one?\n"] lines = [f"'{query}' matches several blueprints — which one?\n"]
for r in candidates: for r in candidates:
lines.append(f"{r.key}{r.title}") lines.append(f"{r.key}{r.title}")
lines.append("\nRun `/cron-recipe <name>` with one of the names above.") lines.append("\nRun `/blueprint <name>` with one of the names above.")
return "\n".join(lines) return "\n".join(lines)
def _fmt_no_match(query: str) -> str: def _fmt_no_match(query: str) -> str:
from cron.recipe_catalog import CATALOG from cron.blueprint_catalog import CATALOG
keys = [r.key for r in CATALOG] keys = [r.key for r in CATALOG]
close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4) close = difflib.get_close_matches((query or "").lower(), keys, n=3, cutoff=0.4)
msg = f"No cron recipe matches '{query}'." msg = f"No automation blueprint matches '{query}'."
if close: if close:
msg += " Did you mean: " + ", ".join(close) + "?" msg += " Did you mean: " + ", ".join(close) + "?"
msg += " Run /cron-recipe to see the catalog." msg += " Run /blueprint to see the catalog."
return msg return msg
@ -243,28 +243,28 @@ def _manage_hint(surface: str) -> str:
return "Ask me to list, pause, or remove it any time." return "Ask me to list, pause, or remove it any time."
def handle_cron_recipe_command( def handle_blueprint_command(
args: str, args: str,
*, *,
origin: Optional[Dict[str, Any]] = None, origin: Optional[Dict[str, Any]] = None,
surface: str = "cli", surface: str = "cli",
) -> RecipeCommandResult: ) -> BlueprintCommandResult:
"""Dispatch a ``/cron-recipe`` invocation. """Dispatch a ``/blueprint`` invocation.
Returns a :class:`RecipeCommandResult`. When ``agent_seed`` is set the Returns a :class:`BlueprintCommandResult`. When ``agent_seed`` is set the
caller must feed it to the agent as the next user turn; otherwise the caller must feed it to the agent as the next user turn; otherwise the
command is fully handled and only ``text`` is shown. command is fully handled and only ``text`` is shown.
``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly ``args`` is everything after ``/blueprint``. ``origin`` lets a directly
created job deliver back to the chat it was set up from. ``surface`` created job deliver back to the chat it was set up from. ``surface``
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints (``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints
``/cron`` only exists on the CLI. ``/cron`` only exists on the CLI.
""" """
try: try:
from cron.recipe_catalog import fill_recipe, RecipeFillError from cron.blueprint_catalog import fill_blueprint, BlueprintFillError
except Exception as e: # pragma: no cover - import guard except Exception as e: # pragma: no cover - import guard
logger.debug("recipe catalog import failed: %s", e) logger.debug("blueprint catalog import failed: %s", e)
return RecipeCommandResult("Cron Recipes are unavailable in this build.") return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
try: try:
tokens = shlex.split(args or "") tokens = shlex.split(args or "")
@ -273,33 +273,33 @@ def handle_cron_recipe_command(
# Bare -> list catalog. # Bare -> list catalog.
if not tokens: if not tokens:
return RecipeCommandResult(_fmt_catalog()) return BlueprintCommandResult(_fmt_catalog())
query = tokens[0] query = tokens[0]
values, _leftover = _parse_kv(tokens[1:]) values, _leftover = _parse_kv(tokens[1:])
recipe, candidates = match_recipe(query) blueprint, candidates = match_blueprint(query)
if recipe is None: if blueprint is None:
if candidates: if candidates:
return RecipeCommandResult(_fmt_candidates(query, candidates)) return BlueprintCommandResult(_fmt_candidates(query, candidates))
return RecipeCommandResult(_fmt_no_match(query)) return BlueprintCommandResult(_fmt_no_match(query))
# `<name>` with no inline slot values -> seed the agent to ask for them. # `<name>` with no inline slot values -> seed the agent to ask for them.
if not values: if not values:
seed = build_recipe_seed(recipe) seed = build_blueprint_seed(blueprint)
text = ( text = (
f"Setting up '{recipe.title}' ({_humanize_schedule(recipe)}). " f"Setting up '{blueprint.title}' ({_humanize_schedule(blueprint)}). "
"I'll ask you a couple of things…" "I'll ask you a couple of things…"
) )
return RecipeCommandResult(text, agent_seed=seed) return BlueprintCommandResult(text, agent_seed=seed)
# `<name> slot=val …` -> fill + create directly (deterministic shortcut). # `<name> slot=val …` -> fill + create directly (deterministic shortcut).
try: try:
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin)) spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
except RecipeFillError as e: except BlueprintFillError as e:
return RecipeCommandResult( return BlueprintCommandResult(
f"Can't set up '{recipe.title}': {e}\n" f"Can't set up '{blueprint.title}': {e}\n"
f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values." f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
) )
try: try:
@ -307,12 +307,12 @@ def handle_cron_recipe_command(
job = create_job(**spec) job = create_job(**spec)
except Exception as e: except Exception as e:
logger.debug("cron-recipe create_job failed: %s", e) logger.debug("blueprint create_job failed: %s", e)
return RecipeCommandResult(f"Failed to create the job: {e}") return BlueprintCommandResult(f"Failed to create the job: {e}")
sched = job.get("schedule_display") or spec.get("schedule", "") sched = job.get("schedule_display") or spec.get("schedule", "")
return RecipeCommandResult( return BlueprintCommandResult(
f"Scheduled '{recipe.title}'" f"Scheduled '{blueprint.title}'"
+ (f" ({sched})" if sched else "") + (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}" + f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
) )

View File

@ -1276,13 +1276,13 @@ class CLICommandsMixin:
output = f"Suggestions command failed: {e}" output = f"Suggestions command failed: {e}"
self._console_print(output) self._console_print(output)
def _handle_cron_recipe_command(self, cmd: str): def _handle_blueprint_command(self, cmd: str):
"""Handle /cron-recipe — set up an automation from a recipe template. """Handle /blueprint — set up an automation from a blueprint template.
Delegates to the shared handler. A bare ``/cron-recipe`` lists the Delegates to the shared handler. A bare ``/blueprint`` lists the
catalog; ``/cron-recipe <name>`` name-matches a recipe and seeds the catalog; ``/blueprint <name>`` name-matches a blueprint and seeds the
agent to ask the user for each value conversationally (the result's agent to ask the user for each value conversationally (the result's
``agent_seed``); ``/cron-recipe <name> slot=val `` creates the job ``agent_seed``); ``/blueprint <name> slot=val `` creates the job
directly. When a seed is returned it is stashed as a one-shot pending directly. When a seed is returned it is stashed as a one-shot pending
message the interactive loop runs as the next agent turn. message the interactive loop runs as the next agent turn.
""" """
@ -1294,10 +1294,10 @@ class CLICommandsMixin:
tokens = (cmd or "").split()[1:] tokens = (cmd or "").split()[1:]
args = " ".join(shlex.quote(t) for t in tokens) args = " ".join(shlex.quote(t) for t in tokens)
try: try:
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
result = handle_cron_recipe_command(args) result = handle_blueprint_command(args)
except Exception as e: except Exception as e:
self._console_print(f"Cron recipe command failed: {e}") self._console_print(f"Cron blueprint command failed: {e}")
return return
self._console_print(result.text) self._console_print(result.text)
seed = getattr(result, "agent_seed", None) seed = getattr(result, "agent_seed", None)

View File

@ -182,8 +182,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("suggestions", "Review suggested automations (accept/dismiss)", CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]", "Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")), subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("cron-recipe", "Set up an automation from a recipe template", CommandDef("blueprint", "Set up an automation from a blueprint template",
"Tools & Skills", aliases=("recipe",), args_hint="[name] [slot=value ...]"), "Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)", CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
"Tools & Skills", args_hint="[subcommand]", "Tools & Skills", args_hint="[subcommand]",
subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")), subcommands=("status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived")),

View File

@ -691,24 +691,24 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}") c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n") c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
# Recipe detection: if the installed skill declares a # Blueprint detection: if the installed skill declares a
# metadata.hermes.recipe block, it is a runnable automation. Register it as # metadata.hermes.blueprint block, it is a runnable automation. Register it as
# a Suggested Cron Job rather than auto-scheduling — installing never # a Suggested Cron Job rather than auto-scheduling — installing never
# silently creates a recurring job; the user accepts it via /suggestions. # silently creates a recurring job; the user accepts it via /suggestions.
# This is the single surface every automation proposal flows through. # This is the single surface every automation proposal flows through.
try: try:
from tools.recipes import RecipeError, recipe_spec_for_installed, register_recipe_suggestion from tools.blueprints import BlueprintError, blueprint_spec_for_installed, register_blueprint_suggestion
try: try:
spec = recipe_spec_for_installed(bundle.name) spec = blueprint_spec_for_installed(bundle.name)
except RecipeError as _rec_err: except BlueprintError as _rec_err:
c.print(f"[yellow]Recipe block present but invalid:[/] {_rec_err}\n") c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n")
spec = None spec = None
if spec is not None: if spec is not None:
registered = register_recipe_suggestion(spec) registered = register_blueprint_suggestion(spec)
if registered is not None: if registered is not None:
c.print( c.print(
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation " f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
f"(schedule [bold]{spec.schedule}[/])." f"(schedule [bold]{spec.schedule}[/])."
) )
c.print( c.print(
@ -720,7 +720,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
# list is at its cap. Say so instead of silently doing nothing — # list is at its cap. Say so instead of silently doing nothing —
# the user can still schedule it by hand. # the user can still schedule it by hand.
c.print( c.print(
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation " f"[bold cyan]Blueprint:[/] '{bundle.name}' is an automation "
f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to " f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to "
"your suggestions (already offered/dismissed, or the pending " "your suggestions (already offered/dismissed, or the pending "
"list is full — run [bold]/suggestions[/] to review)." "list is full — run [bold]/suggestions[/] to review)."
@ -729,7 +729,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
"[dim]You can still schedule it any time by asking the agent " "[dim]You can still schedule it any time by asking the agent "
"or via[/] [bold]hermes cron add[/][dim].[/]\n" "or via[/] [bold]hermes cron add[/][dim].[/]\n"
) )
except Exception: # pragma: no cover - recipe detection is best-effort except Exception: # pragma: no cover - blueprint detection is best-effort
pass pass
if invalidate_cache: if invalidate_cache:

View File

@ -25,7 +25,7 @@ def _fmt_pending(pending: list) -> str:
return ( return (
"No suggested automations right now.\n" "No suggested automations right now.\n"
"Try `/suggestions catalog` to see the curated starter set, or " "Try `/suggestions catalog` to see the curated starter set, or "
"install a recipe skill to get one." "install a blueprint skill to get one."
) )
lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"] lines = ["Suggested automations — `/suggestions accept N` or `dismiss N`:\n"]
for i, s in enumerate(pending, 1): for i, s in enumerate(pending, 1):

View File

@ -6779,25 +6779,25 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cron Recipes — parameterized automation templates. The dashboard renders the # Automation Blueprints — parameterized automation templates. The dashboard renders the
# slot schema as a form; submitting instantiates a real cron job via the same # slot schema as a form; submitting instantiates a real cron job via the same
# create_job path. See cron/recipe_catalog.py for the single source of truth. # create_job path. See cron/blueprint_catalog.py for the single source of truth.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class CronRecipeInstantiate(BaseModel): class AutomationBlueprintInstantiate(BaseModel):
recipe: str # recipe key, e.g. "morning-brief" blueprint: str # blueprint key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form values: Dict[str, Any] = {} # filled slot values from the form
@app.get("/api/cron/recipes") @app.get("/api/cron/blueprints")
async def list_cron_recipes(): async def list_cron_blueprints():
"""Return the recipe catalog as form schemas for the dashboard gallery. """Return the blueprint catalog as form schemas for the dashboard gallery.
The ``deliver`` slot's options are rewritten from the user's actually The ``deliver`` slot's options are rewritten from the user's actually
configured gateway platforms (plus the universal origin/local/all), so the configured gateway platforms (plus the universal origin/local/all), so the
form never offers a platform that isn't connected. form never offers a platform that isn't connected.
""" """
try: try:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
deliver_options = None deliver_options = None
try: try:
@ -6810,40 +6810,40 @@ async def list_cron_recipes():
entries = [] entries = []
for r in CATALOG: for r in CATALOG:
entry = recipe_catalog_entry(r) entry = blueprint_catalog_entry(r)
if deliver_options: if deliver_options:
for f in entry.get("fields", []): for f in entry.get("fields", []):
if f.get("name") == "deliver": if f.get("name") == "deliver":
f["options"] = deliver_options f["options"] = deliver_options
entries.append(entry) entries.append(entry)
return {"recipes": entries} return {"blueprints": entries}
except Exception as e: except Exception as e:
_log.exception("GET /api/cron/recipes failed") _log.exception("GET /api/cron/blueprints failed")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/cron/recipes/instantiate") @app.post("/api/cron/blueprints/instantiate")
async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"): async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
"""Fill a recipe's slots and create the cron job (form-submit path).""" """Fill a blueprint's slots and create the cron job (form-submit path)."""
try: try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError
recipe = get_recipe(body.recipe) blueprint = get_blueprint(body.blueprint)
if recipe is None: if blueprint is None:
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}") raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
try: try:
spec = fill_recipe(recipe, body.values) spec = fill_blueprint(blueprint, body.values)
except RecipeFillError as exc: except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline. # Field-level validation error — 422 so the form can show it inline.
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
# Recipe-created jobs deliver to the dashboard's configured target by # Blueprint-created jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"]. # default; the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None) spec.pop("origin", None)
return _call_cron_for_profile(profile, "create_job", **spec) return _call_cron_for_profile(profile, "create_job", **spec)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
_log.exception("POST /api/cron/recipes/instantiate failed") _log.exception("POST /api/cron/blueprints/instantiate failed")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))

View File

@ -1,7 +1,7 @@
"""Tests for Cron Recipes — the parameterized automation template system. """Tests for Automation Blueprints — the parameterized automation template system.
Covers the core catalog/slot schema/renderers/fill (cron/recipe_catalog.py), Covers the core catalog/slot schema/renderers/fill (cron/blueprint_catalog.py),
the shared /cron-recipe command handler (hermes_cli/cron_recipe_cmd.py), and the shared /blueprint command handler (hermes_cli/blueprint_cmd.py), and
the docs generator. Uses an isolated HERMES_HOME for anything that touches the the docs generator. Uses an isolated HERMES_HOME for anything that touches the
cron job store. cron job store.
""" """
@ -13,16 +13,16 @@ from unittest.mock import patch
import pytest import pytest
from cron.recipe_catalog import ( from cron.blueprint_catalog import (
CATALOG, CATALOG,
RecipeFillError, BlueprintFillError,
RecipeSlot, BlueprintSlot,
fill_recipe, fill_blueprint,
get_recipe, get_blueprint,
recipe_catalog_entry, blueprint_catalog_entry,
recipe_deeplink, blueprint_deeplink,
recipe_form_schema, blueprint_form_schema,
recipe_slash_command, blueprint_slash_command,
) )
@ -30,7 +30,7 @@ class TestCatalog:
def test_catalog_nonempty_and_keyed(self): def test_catalog_nonempty_and_keyed(self):
assert len(CATALOG) >= 1 assert len(CATALOG) >= 1
for r in CATALOG: for r in CATALOG:
assert get_recipe(r.key) is r assert get_blueprint(r.key) is r
def test_every_slot_has_known_type(self): def test_every_slot_has_known_type(self):
for r in CATALOG: for r in CATALOG:
@ -39,60 +39,60 @@ class TestCatalog:
def test_bad_slot_type_rejected(self): def test_bad_slot_type_rejected(self):
with pytest.raises(ValueError): with pytest.raises(ValueError):
RecipeSlot(name="x", type="bogus", label="X") BlueprintSlot(name="x", type="bogus", label="X")
class TestScheduleResolution: class TestScheduleResolution:
def test_time_to_cron(self): def test_time_to_cron(self):
spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:30"}) spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:30"})
assert spec["schedule"] == "30 8 * * *" assert spec["schedule"] == "30 8 * * *"
def test_interval_schedule(self): def test_interval_schedule(self):
spec = fill_recipe( spec = fill_blueprint(
get_recipe("important-mail"), get_blueprint("important-mail"),
{"interval_min": "15", "criteria": "x", "deliver": "origin"}, {"interval_min": "15", "criteria": "x", "deliver": "origin"},
) )
assert spec["schedule"] == "*/15 * * * *" assert spec["schedule"] == "*/15 * * * *"
def test_day_to_dow(self): def test_day_to_dow(self):
spec = fill_recipe( spec = fill_blueprint(
get_recipe("weekly-review"), get_blueprint("weekly-review"),
{"time": "18:00", "day": "sunday", "deliver": "origin"}, {"time": "18:00", "day": "sunday", "deliver": "origin"},
) )
assert spec["schedule"] == "0 18 * * 0" assert spec["schedule"] == "0 18 * * 0"
def test_weekday_preset_to_dow(self): def test_weekday_preset_to_dow(self):
spec = fill_recipe( spec = fill_blueprint(
get_recipe("custom-reminder"), get_blueprint("custom-reminder"),
{"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"}, {"what": "stretch", "time": "14:00", "recurrence": "weekdays", "deliver": "origin"},
) )
assert spec["schedule"] == "0 14 * * 1-5" assert spec["schedule"] == "0 14 * * 1-5"
def test_defaults_fill_when_omitted(self): def test_defaults_fill_when_omitted(self):
spec = fill_recipe(get_recipe("morning-brief"), {}) spec = fill_blueprint(get_blueprint("morning-brief"), {})
assert spec["schedule"] == "0 8 * * *" assert spec["schedule"] == "0 8 * * *"
class TestValidation: class TestValidation:
def test_invalid_time_rejected(self): def test_invalid_time_rejected(self):
with pytest.raises(RecipeFillError, match="invalid time"): with pytest.raises(BlueprintFillError, match="invalid time"):
fill_recipe(get_recipe("morning-brief"), {"time": "25:99"}) fill_blueprint(get_blueprint("morning-brief"), {"time": "25:99"})
def test_bad_enum_rejected_and_names_slot(self): def test_bad_enum_rejected_and_names_slot(self):
with pytest.raises(RecipeFillError, match="not allowed"): with pytest.raises(BlueprintFillError, match="not allowed"):
fill_recipe(get_recipe("news-digest"), {"count": "42"}) fill_blueprint(get_blueprint("news-digest"), {"count": "42"})
def test_deliver_slot_accepts_any_platform(self): def test_deliver_slot_accepts_any_platform(self):
# deliver is a non-strict enum: its options are suggestions, the real # deliver is a non-strict enum: its options are suggestions, the real
# set of valid platforms depends on the user's configured gateways and # set of valid platforms depends on the user's configured gateways and
# is validated downstream by the cron scheduler. # is validated downstream by the cron scheduler.
spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "slack"}) spec = fill_blueprint(get_blueprint("morning-brief"), {"time": "08:00", "deliver": "slack"})
assert spec["deliver"] == "slack" assert spec["deliver"] == "slack"
def test_unknown_slot_name_rejected(self): def test_unknown_slot_name_rejected(self):
# A typo'd slot must NOT silently create a job with the default value. # A typo'd slot must NOT silently create a job with the default value.
with pytest.raises(RecipeFillError, match="unknown slot"): with pytest.raises(BlueprintFillError, match="unknown slot"):
fill_recipe(get_recipe("morning-brief"), {"tiem": "07:15"}) fill_blueprint(get_blueprint("morning-brief"), {"tiem": "07:15"})
def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self): def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self):
# Regression: a minute-field step (*/90) silently wraps to hourly. # Regression: a minute-field step (*/90) silently wraps to hourly.
@ -100,7 +100,7 @@ class TestValidation:
croniter = pytest.importorskip("croniter").croniter croniter = pytest.importorskip("croniter").croniter
from datetime import datetime from datetime import datetime
spec = fill_recipe(get_recipe("hydration-move"), {"interval_hours": "2"}) spec = fill_blueprint(get_blueprint("hydration-move"), {"interval_hours": "2"})
it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0)) it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0))
first_three = [it.get_next(datetime) for _ in range(3)] first_three = [it.get_next(datetime) for _ in range(3)]
gaps = { gaps = {
@ -110,45 +110,45 @@ class TestValidation:
assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}" assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}"
def test_text_slot_renders_into_prompt(self): def test_text_slot_renders_into_prompt(self):
spec = fill_recipe( spec = fill_blueprint(
get_recipe("important-mail"), get_blueprint("important-mail"),
{"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"}, {"interval_min": "30", "criteria": "from my CEO", "deliver": "origin"},
) )
assert "from my CEO" in spec["prompt"] assert "from my CEO" in spec["prompt"]
def test_origin_threads_through(self): def test_origin_threads_through(self):
spec = fill_recipe( spec = fill_blueprint(
get_recipe("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"} get_blueprint("morning-brief"), {"time": "08:00"}, origin={"platform": "telegram", "chat_id": "9"}
) )
assert spec["origin"] == {"platform": "telegram", "chat_id": "9"} assert spec["origin"] == {"platform": "telegram", "chat_id": "9"}
class TestRenderers: class TestRenderers:
def test_form_schema_fields(self): def test_form_schema_fields(self):
schema = recipe_form_schema(get_recipe("morning-brief")) schema = blueprint_form_schema(get_blueprint("morning-brief"))
names = [f["name"] for f in schema["fields"]] names = [f["name"] for f in schema["fields"]]
assert names == ["time", "deliver"] assert names == ["time", "deliver"]
assert schema["key"] == "morning-brief" assert schema["key"] == "morning-brief"
def test_slash_command_defaults(self): def test_slash_command_defaults(self):
cmd = recipe_slash_command(get_recipe("morning-brief")) cmd = blueprint_slash_command(get_blueprint("morning-brief"))
assert cmd.startswith("/cron-recipe morning-brief") assert cmd.startswith("/blueprint morning-brief")
assert "time=08:00" in cmd assert "time=08:00" in cmd
def test_slash_command_quotes_freetext(self): def test_slash_command_quotes_freetext(self):
cmd = recipe_slash_command( cmd = blueprint_slash_command(
get_recipe("custom-reminder"), {"what": "drink water", "time": "10:00"} get_blueprint("custom-reminder"), {"what": "drink water", "time": "10:00"}
) )
assert '"drink water"' in cmd assert '"drink water"' in cmd
def test_deeplink_shape(self): def test_deeplink_shape(self):
url = recipe_deeplink(get_recipe("morning-brief"), {"time": "07:15"}) url = blueprint_deeplink(get_blueprint("morning-brief"), {"time": "07:15"})
assert url.startswith("hermes://cron-recipe/morning-brief?") assert url.startswith("hermes://blueprint/morning-brief?")
assert "time=07" in url assert "time=07" in url
def test_catalog_entry_has_all_surfaces(self): def test_catalog_entry_has_all_surfaces(self):
entry = recipe_catalog_entry(get_recipe("morning-brief")) entry = blueprint_catalog_entry(get_blueprint("morning-brief"))
assert entry["command"].startswith("/cron-recipe") assert entry["command"].startswith("/blueprint")
assert entry["appUrl"].startswith("hermes://") assert entry["appUrl"].startswith("hermes://")
assert entry["scheduleHuman"] assert entry["scheduleHuman"]
assert "fields" in entry assert "fields" in entry
@ -168,18 +168,18 @@ def isolated_home(tmp_path, monkeypatch):
class TestCommandHandler: class TestCommandHandler:
def test_bare_lists_catalog(self, isolated_home): def test_bare_lists_catalog(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
res = handle_cron_recipe_command("") res = handle_blueprint_command("")
assert "morning-brief" in res.text and "Cron Recipes" in res.text assert "morning-brief" in res.text and "Automation Blueprints" in res.text
assert res.agent_seed is None assert res.agent_seed is None
def test_name_seeds_agent(self, isolated_home): def test_name_seeds_agent(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
# `/cron-recipe <name>` (no inline slots) now seeds the agent to ask # `/blueprint <name>` (no inline slots) now seeds the agent to ask
# the user for each value conversationally instead of dumping fields. # the user for each value conversationally instead of dumping fields.
res = handle_cron_recipe_command("morning-brief") res = handle_blueprint_command("morning-brief")
assert res.agent_seed is not None assert res.agent_seed is not None
assert "morning-brief" in res.agent_seed assert "morning-brief" in res.agent_seed
assert "cronjob tool" in res.agent_seed assert "cronjob tool" in res.agent_seed
@ -187,22 +187,22 @@ class TestCommandHandler:
assert "* * *" in res.agent_seed assert "* * *" in res.agent_seed
def test_name_match_is_forgiving(self, isolated_home): def test_name_match_is_forgiving(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command, match_recipe from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint
# prefix match # prefix match
r, cands = match_recipe("morning") r, cands = match_blueprint("morning")
assert r is not None and r.key == "morning-brief" assert r is not None and r.key == "morning-brief"
# fuzzy / typo # fuzzy / typo
r2, _ = match_recipe("mornning-brief") r2, _ = match_blueprint("mornning-brief")
assert r2 is not None and r2.key == "morning-brief" assert r2 is not None and r2.key == "morning-brief"
# a forgiving name still seeds the agent # a forgiving name still seeds the agent
res = handle_cron_recipe_command("morning") res = handle_blueprint_command("morning")
assert res.agent_seed is not None assert res.agent_seed is not None
def test_fill_creates_job(self, isolated_home): def test_fill_creates_job(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
res = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram") res = handle_blueprint_command("morning-brief time=07:30 deliver=telegram")
assert "Scheduled" in res.text assert "Scheduled" in res.text
assert res.agent_seed is None assert res.agent_seed is None
jobs = isolated_home.load_jobs() jobs = isolated_home.load_jobs()
@ -210,17 +210,17 @@ class TestCommandHandler:
assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *" assert (jobs[0].get("schedule_display") or jobs[0].get("schedule")) == "30 7 * * *"
assert jobs[0].get("deliver") == "telegram" assert jobs[0].get("deliver") == "telegram"
def test_unknown_recipe(self, isolated_home): def test_unknown_blueprint(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
res = handle_cron_recipe_command("zzz-nope-nothing") res = handle_blueprint_command("zzz-nope-nothing")
assert "No cron recipe" in res.text assert "No automation blueprint" in res.text
assert res.agent_seed is None assert res.agent_seed is None
def test_bad_value_names_slot(self, isolated_home): def test_bad_value_names_slot(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command from hermes_cli.blueprint_cmd import handle_blueprint_command
res = handle_cron_recipe_command("morning-brief time=99:99") res = handle_blueprint_command("morning-brief time=99:99")
assert "Can't set up" in res.text and "time" in res.text assert "Can't set up" in res.text and "time" in res.text
assert res.agent_seed is None assert res.agent_seed is None
@ -232,9 +232,9 @@ class TestDocsGenerator:
script = ( script = (
Path(__file__).resolve().parents[2] Path(__file__).resolve().parents[2]
/ "website" / "scripts" / "extract-cron-recipes.py" / "website" / "scripts" / "extract-automation-blueprints.py"
) )
spec = importlib.util.spec_from_file_location("extract_cron_recipes", script) spec = importlib.util.spec_from_file_location("extract_cron_blueprints", script)
assert spec is not None and spec.loader is not None assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) spec.loader.exec_module(mod)

View File

@ -1,7 +1,7 @@
"""Tests for the Suggested Cron Jobs feature. """Tests for the Suggested Cron Jobs feature.
Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the Covers the store (add/dedup/cap/accept/dismiss/latch), catalog seeding, the
recipe->suggestion bridge, and the shared command handler. Uses an isolated blueprint->suggestion bridge, and the shared command handler. Uses an isolated
HERMES_HOME so the real suggestions.json is never touched. HERMES_HOME so the real suggestions.json is never touched.
""" """
@ -136,23 +136,23 @@ class TestCatalog:
assert Path(classify_items_script_path()).name == "classify_items.py" assert Path(classify_items_script_path()).name == "classify_items.py"
class TestRecipeBridge: class TestBlueprintBridge:
def test_recipe_registers_suggestion(self, store): def test_blueprint_registers_suggestion(self, store):
from tools.recipes import RecipeSpec, register_recipe_suggestion from tools.blueprints import BlueprintSpec, register_blueprint_suggestion
spec = RecipeSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram") spec = BlueprintSpec(skill_name="morning-brief", schedule="0 8 * * *", deliver="telegram")
with patch("cron.suggestions.add_suggestion", store.add_suggestion): with patch("cron.suggestions.add_suggestion", store.add_suggestion):
rec = register_recipe_suggestion(spec) rec = register_blueprint_suggestion(spec)
assert rec is not None assert rec is not None
assert rec["source"] == "recipe" assert rec["source"] == "blueprint"
assert rec["job_spec"]["skills"] == ["morning-brief"] assert rec["job_spec"]["skills"] == ["morning-brief"]
assert rec["job_spec"]["schedule"] == "0 8 * * *" assert rec["job_spec"]["schedule"] == "0 8 * * *"
def test_recipe_to_job_spec_matches_create_recipe_job(self): def test_blueprint_to_job_spec_matches_create_blueprint_job(self):
from tools.recipes import RecipeSpec, recipe_to_job_spec from tools.blueprints import BlueprintSpec, blueprint_to_job_spec
spec = RecipeSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p") spec = BlueprintSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p")
js = recipe_to_job_spec(spec) js = blueprint_to_job_spec(spec)
assert js["skills"] == ["x"] assert js["skills"] == ["x"]
assert js["schedule"] == "every 2h" assert js["schedule"] == "every 2h"
assert js["prompt"] == "p" assert js["prompt"] == "p"

View File

@ -2360,39 +2360,39 @@ class TestNewEndpoints:
resp = self.client.get("/api/cron/jobs/nonexistent-id") resp = self.client.get("/api/cron/jobs/nonexistent-id")
assert resp.status_code == 404 assert resp.status_code == 404
# --- Cron Recipes --- # --- Automation Blueprints ---
def test_cron_recipes_list(self): def test_cron_blueprints_list(self):
resp = self.client.get("/api/cron/recipes") resp = self.client.get("/api/cron/blueprints")
assert resp.status_code == 200 assert resp.status_code == 200
recipes = resp.json()["recipes"] blueprints = resp.json()["blueprints"]
assert len(recipes) >= 1 assert len(blueprints) >= 1
first = recipes[0] first = blueprints[0]
assert "fields" in first assert "fields" in first
assert first["command"].startswith("/cron-recipe") assert first["command"].startswith("/blueprint")
assert first["appUrl"].startswith("hermes://") assert first["appUrl"].startswith("hermes://")
def test_cron_recipe_instantiate_creates_job(self): def test_blueprint_instantiate_creates_job(self):
resp = self.client.post( resp = self.client.post(
"/api/cron/recipes/instantiate", "/api/cron/blueprints/instantiate",
json={"recipe": "morning-brief", "values": {"time": "07:30", "deliver": "local"}}, json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}},
) )
assert resp.status_code == 200 assert resp.status_code == 200
job = resp.json() job = resp.json()
assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \ assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \
(job.get("schedule", {}) or {}).get("expr") == "30 7 * * *" (job.get("schedule", {}) or {}).get("expr") == "30 7 * * *"
def test_cron_recipe_instantiate_unknown_404(self): def test_blueprint_instantiate_unknown_404(self):
resp = self.client.post( resp = self.client.post(
"/api/cron/recipes/instantiate", "/api/cron/blueprints/instantiate",
json={"recipe": "does-not-exist", "values": {}}, json={"blueprint": "does-not-exist", "values": {}},
) )
assert resp.status_code == 404 assert resp.status_code == 404
def test_cron_recipe_instantiate_bad_value_422(self): def test_blueprint_instantiate_bad_value_422(self):
resp = self.client.post( resp = self.client.post(
"/api/cron/recipes/instantiate", "/api/cron/blueprints/instantiate",
json={"recipe": "morning-brief", "values": {"time": "99:99"}}, json={"blueprint": "morning-brief", "values": {"time": "99:99"}},
) )
assert resp.status_code == 422 assert resp.status_code == 422

View File

@ -1,6 +1,6 @@
"""Tests for the recipes layer (skill frontmatter <-> cron automation bridge). """Tests for the blueprints layer (skill frontmatter <-> cron automation bridge).
A recipe is a skill with a metadata.hermes.recipe block. These verify parsing, A blueprint is a skill with a metadata.hermes.blueprint block. These verify parsing,
the create-job bridge, and the export round-trip without touching the real the create-job bridge, and the export round-trip without touching the real
cron store. cron store.
""" """
@ -11,24 +11,24 @@ from unittest.mock import patch
import pytest import pytest
from tools.recipes import ( from tools.blueprints import (
RecipeError, BlueprintError,
RecipeSpec, BlueprintSpec,
create_recipe_job, create_blueprint_job,
export_recipe, export_blueprint,
parse_recipe, parse_blueprint,
recipe_spec_for_installed, blueprint_spec_for_installed,
) )
RECIPE_SKILL = """--- BLUEPRINT_SKILL = """---
name: morning-brief name: morning-brief
description: Summarize unread email and calendar every morning. description: Summarize unread email and calendar every morning.
version: 1.0.0 version: 1.0.0
metadata: metadata:
hermes: hermes:
tags: [recipe, email] tags: [blueprint, email]
recipe: blueprint:
schedule: "0 8 * * *" schedule: "0 8 * * *"
deliver: telegram deliver: telegram
prompt: "Summarize my unread email and today's calendar." prompt: "Summarize my unread email and today's calendar."
@ -40,22 +40,22 @@ Every morning, gather unread email and the day's calendar and send a digest.
""" """
PLAIN_SKILL = """--- PLAIN_SKILL = """---
name: not-a-recipe name: not-a-blueprint
description: Just a regular skill. description: Just a regular skill.
metadata: metadata:
hermes: hermes:
tags: [misc] tags: [misc]
--- ---
# Not a recipe # Not a blueprint
""" """
MALFORMED_RECIPE = """--- MALFORMED_BLUEPRINT = """---
name: broken name: broken
description: Recipe with no schedule. description: Blueprint with no schedule.
metadata: metadata:
hermes: hermes:
recipe: blueprint:
deliver: origin deliver: origin
--- ---
@ -63,49 +63,49 @@ metadata:
""" """
class TestParseRecipe: class TestParseBlueprint:
def test_parses_full_recipe(self): def test_parses_full_blueprint(self):
spec = parse_recipe(RECIPE_SKILL) spec = parse_blueprint(BLUEPRINT_SKILL)
assert spec is not None assert spec is not None
assert spec.skill_name == "morning-brief" assert spec.skill_name == "morning-brief"
assert spec.schedule == "0 8 * * *" assert spec.schedule == "0 8 * * *"
assert spec.deliver == "telegram" assert spec.deliver == "telegram"
assert spec.prompt is not None and spec.prompt.startswith("Summarize") assert spec.prompt is not None and spec.prompt.startswith("Summarize")
def test_plain_skill_is_not_a_recipe(self): def test_plain_skill_is_not_a_blueprint(self):
assert parse_recipe(PLAIN_SKILL) is None assert parse_blueprint(PLAIN_SKILL) is None
def test_no_frontmatter_is_not_a_recipe(self): def test_no_frontmatter_is_not_a_blueprint(self):
assert parse_recipe("just some text, no frontmatter") is None assert parse_blueprint("just some text, no frontmatter") is None
def test_missing_schedule_raises(self): def test_missing_schedule_raises(self):
with pytest.raises(RecipeError): with pytest.raises(BlueprintError):
parse_recipe(MALFORMED_RECIPE) parse_blueprint(MALFORMED_BLUEPRINT)
def test_recipe_not_mapping_raises(self): def test_blueprint_not_mapping_raises(self):
bad = "---\nname: x\nmetadata:\n hermes:\n recipe: not-a-dict\n---\n\nbody" bad = "---\nname: x\nmetadata:\n hermes:\n blueprint: not-a-dict\n---\n\nbody"
with pytest.raises(RecipeError): with pytest.raises(BlueprintError):
parse_recipe(bad) parse_blueprint(bad)
def test_deliver_defaults_to_origin(self): def test_deliver_defaults_to_origin(self):
skill = ( skill = (
"---\nname: r\ndescription: d\nmetadata:\n hermes:\n" "---\nname: r\ndescription: d\nmetadata:\n hermes:\n"
' recipe:\n schedule: "every 1h"\n---\n\nbody' ' blueprint:\n schedule: "every 1h"\n---\n\nbody'
) )
spec = parse_recipe(skill) spec = parse_blueprint(skill)
assert spec is not None assert spec is not None
assert spec.deliver == "origin" assert spec.deliver == "origin"
class TestRecipeSpecForInstalled: class TestBlueprintSpecForInstalled:
def test_finds_and_parses_installed_recipe(self, tmp_path): def test_finds_and_parses_installed_blueprint(self, tmp_path):
skills_dir = tmp_path / "skills" skills_dir = tmp_path / "skills"
rec_dir = skills_dir / "productivity" / "morning-brief" rec_dir = skills_dir / "productivity" / "morning-brief"
rec_dir.mkdir(parents=True) rec_dir.mkdir(parents=True)
(rec_dir / "SKILL.md").write_text(RECIPE_SKILL, encoding="utf-8") (rec_dir / "SKILL.md").write_text(BLUEPRINT_SKILL, encoding="utf-8")
with patch("tools.skills_hub.SKILLS_DIR", skills_dir): with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
spec = recipe_spec_for_installed("morning-brief") spec = blueprint_spec_for_installed("morning-brief")
assert spec is not None assert spec is not None
assert spec.schedule == "0 8 * * *" assert spec.schedule == "0 8 * * *"
@ -113,20 +113,20 @@ class TestRecipeSpecForInstalled:
skills_dir = tmp_path / "skills" skills_dir = tmp_path / "skills"
skills_dir.mkdir() skills_dir.mkdir()
with patch("tools.skills_hub.SKILLS_DIR", skills_dir): with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
assert recipe_spec_for_installed("nope") is None assert blueprint_spec_for_installed("nope") is None
def test_plain_skill_returns_none(self, tmp_path): def test_plain_skill_returns_none(self, tmp_path):
skills_dir = tmp_path / "skills" skills_dir = tmp_path / "skills"
d = skills_dir / "misc" / "not-a-recipe" d = skills_dir / "misc" / "not-a-blueprint"
d.mkdir(parents=True) d.mkdir(parents=True)
(d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8") (d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8")
with patch("tools.skills_hub.SKILLS_DIR", skills_dir): with patch("tools.skills_hub.SKILLS_DIR", skills_dir):
assert recipe_spec_for_installed("not-a-recipe") is None assert blueprint_spec_for_installed("not-a-blueprint") is None
class TestCreateRecipeJob: class TestCreateBlueprintJob:
def test_bridges_to_create_job(self): def test_bridges_to_create_job(self):
spec = parse_recipe(RECIPE_SKILL) spec = parse_blueprint(BLUEPRINT_SKILL)
assert spec is not None assert spec is not None
captured = {} captured = {}
@ -135,7 +135,7 @@ class TestCreateRecipeJob:
return {"id": "abc123", **kwargs} return {"id": "abc123", **kwargs}
with patch("cron.jobs.create_job", fake_create_job): with patch("cron.jobs.create_job", fake_create_job):
job = create_recipe_job(spec, origin={"platform": "telegram"}) job = create_blueprint_job(spec, origin={"platform": "telegram"})
assert captured["schedule"] == "0 8 * * *" assert captured["schedule"] == "0 8 * * *"
assert captured["skills"] == ["morning-brief"] assert captured["skills"] == ["morning-brief"]
@ -144,7 +144,7 @@ class TestCreateRecipeJob:
assert job["id"] == "abc123" assert job["id"] == "abc123"
class TestExportRecipe: class TestExportBlueprint:
def test_round_trips_job_to_skill_md(self): def test_round_trips_job_to_skill_md(self):
job = { job = {
"name": "My Morning Brief", "name": "My Morning Brief",
@ -153,19 +153,19 @@ class TestExportRecipe:
"deliver": "telegram", "deliver": "telegram",
"prompt": "Summarize my unread email.", "prompt": "Summarize my unread email.",
} }
md = export_recipe(job, "# Morning Brief\n\nDoes the morning digest.") md = export_blueprint(job, "# Morning Brief\n\nDoes the morning digest.")
# The exported SKILL.md must itself parse back as a recipe. # The exported SKILL.md must itself parse back as a blueprint.
spec = parse_recipe(md) spec = parse_blueprint(md)
assert spec is not None assert spec is not None
assert spec.schedule == "0 8 * * *" assert spec.schedule == "0 8 * * *"
assert spec.deliver == "telegram" assert spec.deliver == "telegram"
# Name is sanitized to a valid skill identifier. # Name is sanitized to a valid skill identifier.
assert spec.skill_name == "my-morning-brief" assert spec.skill_name == "my-morning-brief"
def test_export_has_recipe_tag(self): def test_export_has_blueprint_tag(self):
job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]} job = {"name": "x", "schedule_display": "every 2h", "skills": ["x"]}
md = export_recipe(job, "body") md = export_blueprint(job, "body")
assert "recipe" in md assert "blueprint" in md
assert "automation" in md assert "automation" in md
def test_export_interval_job_without_display(self): def test_export_interval_job_without_display(self):
@ -177,12 +177,12 @@ class TestExportRecipe:
"schedule": {"kind": "interval", "minutes": 30}, "schedule": {"kind": "interval", "minutes": 30},
"skills": ["poller"], "skills": ["poller"],
} }
md = export_recipe(job, "body") md = export_blueprint(job, "body")
spec = parse_recipe(md) spec = parse_blueprint(md)
assert spec is not None assert spec is not None
assert spec.schedule == "every 30m" assert spec.schedule == "every 30m"
job["schedule"] = {"kind": "interval", "minutes": 120} job["schedule"] = {"kind": "interval", "minutes": 120}
spec = parse_recipe(export_recipe(job, "body")) spec = parse_blueprint(export_blueprint(job, "body"))
assert spec is not None assert spec is not None
assert spec.schedule == "every 2h" assert spec.schedule == "every 2h"

View File

@ -1,30 +1,30 @@
"""Recipes: shareable plain-language automations layered on skills + cron. """Blueprints: shareable plain-language automations layered on skills + cron.
A "recipe" is NOT a new object type. It is an ordinary skill (a SKILL.md the A "blueprint" is NOT a new object type. It is an ordinary skill (a SKILL.md the
agent loads) that additionally declares an automation schedule in its agent loads) that additionally declares an automation schedule in its
frontmatter: frontmatter:
metadata: metadata:
hermes: hermes:
recipe: blueprint:
schedule: "0 9 * * *" # presence of `recipe:` marks it runnable schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
deliver: origin # optional (default "origin") deliver: origin # optional (default "origin")
prompt: "..." # optional task instruction for the run prompt: "..." # optional task instruction for the run
no_agent: false # optional no_agent: false # optional
Because a recipe is just a skill, it flows through the ENTIRE existing Because a blueprint is just a skill, it flows through the ENTIRE existing
skills-hub pipeline for free search, inspect, quarantine, security scan, skills-hub pipeline for free search, inspect, quarantine, security scan,
install, lock-file provenance, audit log, taps, the centralized index, and install, lock-file provenance, audit log, taps, the centralized index, and
`hermes skills publish` for sharing. No new source type, no new store, no new `hermes skills publish` for sharing. No new source type, no new store, no new
transport. This module is the thin bridge between that skill metadata and the transport. This module is the thin bridge between that skill metadata and the
existing cron `create_job()` API: existing cron `create_job()` API:
* ``parse_recipe(skill_md_text)`` -> RecipeSpec | None * ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None
* ``recipe_spec_for_installed(name)`` -> RecipeSpec | None * ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None
* ``create_recipe_job(spec, ...)`` -> the created cron job dict * ``create_blueprint_job(spec, ...)`` -> the created cron job dict
* ``export_recipe(job, body)`` -> a shareable SKILL.md string * ``export_blueprint(job, body)`` -> a shareable SKILL.md string
The dev guide's "Extend, Don't Duplicate" rule is the whole design: the recipe The dev guide's "Extend, Don't Duplicate" rule is the whole design: the blueprint
is a skill, the schedule is a cron job, sharing is the existing publish/tap/ is a skill, the schedule is a cron job, sharing is the existing publish/tap/
index path. index path.
""" """
@ -39,24 +39,24 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"RecipeSpec", "BlueprintSpec",
"parse_recipe", "parse_blueprint",
"recipe_spec_for_installed", "blueprint_spec_for_installed",
"recipe_to_job_spec", "blueprint_to_job_spec",
"create_recipe_job", "create_blueprint_job",
"register_recipe_suggestion", "register_blueprint_suggestion",
"export_recipe", "export_blueprint",
"RecipeError", "BlueprintError",
] ]
class RecipeError(ValueError): class BlueprintError(ValueError):
"""Raised when a recipe block is present but malformed.""" """Raised when a blueprint block is present but malformed."""
@dataclass @dataclass
class RecipeSpec: class BlueprintSpec:
"""Parsed ``metadata.hermes.recipe`` automation spec for a skill.""" """Parsed ``metadata.hermes.blueprint`` automation spec for a skill."""
skill_name: str skill_name: str
schedule: str schedule: str
@ -87,16 +87,16 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
data = yaml.safe_load(fm_text) data = yaml.safe_load(fm_text)
except Exception as e: # pragma: no cover - malformed YAML except Exception as e: # pragma: no cover - malformed YAML
logger.debug("recipe: frontmatter YAML parse failed: %s", e) logger.debug("blueprint: frontmatter YAML parse failed: %s", e)
return None return None
return data if isinstance(data, dict) else None return data if isinstance(data, dict) else None
def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]: def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
"""Extract a RecipeSpec from a SKILL.md string, or None if not a recipe. """Extract a BlueprintSpec from a SKILL.md string, or None if not a blueprint.
A skill is a recipe iff ``metadata.hermes.recipe`` is a mapping containing A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
a non-empty ``schedule``. Raises RecipeError if the block exists but is a non-empty ``schedule``. Raises BlueprintError if the block exists but is
structurally invalid (so a typo surfaces instead of silently no-op'ing). structurally invalid (so a typo surfaces instead of silently no-op'ing).
""" """
fm = _split_frontmatter(skill_md_text) fm = _split_frontmatter(skill_md_text)
@ -107,28 +107,28 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
meta = fm.get("metadata") meta = fm.get("metadata")
hermes = meta.get("hermes") if isinstance(meta, dict) else None hermes = meta.get("hermes") if isinstance(meta, dict) else None
recipe = hermes.get("recipe") if isinstance(hermes, dict) else None blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
if recipe is None: if blueprint is None:
return None return None
if not isinstance(recipe, dict): if not isinstance(blueprint, dict):
raise RecipeError("metadata.hermes.recipe must be a mapping") raise BlueprintError("metadata.hermes.blueprint must be a mapping")
schedule = str(recipe.get("schedule", "")).strip() schedule = str(blueprint.get("schedule", "")).strip()
if not schedule: if not schedule:
raise RecipeError("recipe.schedule is required and must be non-empty") raise BlueprintError("blueprint.schedule is required and must be non-empty")
deliver = str(recipe.get("deliver", "origin")).strip() or "origin" deliver = str(blueprint.get("deliver", "origin")).strip() or "origin"
prompt = recipe.get("prompt") prompt = blueprint.get("prompt")
if prompt is not None: if prompt is not None:
prompt = str(prompt) prompt = str(prompt)
no_agent = bool(recipe.get("no_agent", False)) no_agent = bool(blueprint.get("no_agent", False))
model = recipe.get("model") model = blueprint.get("model")
provider = recipe.get("provider") provider = blueprint.get("provider")
toolsets = recipe.get("enabled_toolsets") toolsets = blueprint.get("enabled_toolsets")
if toolsets is not None and not isinstance(toolsets, list): if toolsets is not None and not isinstance(toolsets, list):
raise RecipeError("recipe.enabled_toolsets must be a list when present") raise BlueprintError("blueprint.enabled_toolsets must be a list when present")
return RecipeSpec( return BlueprintSpec(
skill_name=name, skill_name=name,
schedule=schedule, schedule=schedule,
deliver=deliver, deliver=deliver,
@ -137,15 +137,15 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
model=str(model).strip() if model else None, model=str(model).strip() if model else None,
provider=str(provider).strip() if provider else None, provider=str(provider).strip() if provider else None,
enabled_toolsets=[str(t) for t in toolsets] if toolsets else None, enabled_toolsets=[str(t) for t in toolsets] if toolsets else None,
raw=recipe, raw=blueprint,
) )
def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]: def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
"""Locate an installed skill's SKILL.md and parse its recipe block. """Locate an installed skill's SKILL.md and parse its blueprint block.
Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns Searches the standard skills tree for ``<skill_name>/SKILL.md``. Returns
None if the skill isn't found or isn't a recipe. None if the skill isn't found or isn't a blueprint.
""" """
try: try:
from tools.skills_hub import SKILLS_DIR from tools.skills_hub import SKILLS_DIR
@ -160,7 +160,7 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
except OSError: except OSError:
continue continue
spec = parse_recipe(text) spec = parse_blueprint(text)
if spec is not None: if spec is not None:
# Prefer the frontmatter name, fall back to the directory name. # Prefer the frontmatter name, fall back to the directory name.
if not spec.skill_name: if not spec.skill_name:
@ -169,22 +169,22 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]:
return None return None
def recipe_to_job_spec( def blueprint_to_job_spec(
spec: RecipeSpec, spec: BlueprintSpec,
*, *,
name: Optional[str] = None, name: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Build the ``cron.jobs.create_job`` kwargs dict for a RecipeSpec. """Build the ``cron.jobs.create_job`` kwargs dict for a BlueprintSpec.
This is the single source of truth for translating a recipe into a job. This is the single source of truth for translating a blueprint into a job.
Both the direct ``create_recipe_job`` path and the suggestion path Both the direct ``create_blueprint_job`` path and the suggestion path
(``register_recipe_suggestion``) build on it, so a recipe scheduled now and (``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and
a recipe accepted from a suggestion produce an identical job. a blueprint accepted from a suggestion produce an identical job.
""" """
return { return {
"prompt": spec.prompt, "prompt": spec.prompt,
"schedule": spec.schedule, "schedule": spec.schedule,
"name": name or f"recipe:{spec.skill_name}", "name": name or f"blueprint:{spec.skill_name}",
"deliver": spec.deliver, "deliver": spec.deliver,
"skills": [spec.skill_name] if spec.skill_name else None, "skills": [spec.skill_name] if spec.skill_name else None,
"model": spec.model, "model": spec.model,
@ -194,31 +194,31 @@ def recipe_to_job_spec(
} }
def create_recipe_job( def create_blueprint_job(
spec: RecipeSpec, spec: BlueprintSpec,
*, *,
origin: Optional[Dict[str, Any]] = None, origin: Optional[Dict[str, Any]] = None,
name: Optional[str] = None, name: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create the cron job described by a RecipeSpec via the existing cron API. """Create the cron job described by a BlueprintSpec via the existing cron API.
The recipe's skill is loaded before the run (cron ``skills=[name]``); the The blueprint's skill is loaded before the run (cron ``skills=[name]``); the
optional ``prompt`` becomes the task instruction. Delivery, model, and optional ``prompt`` becomes the task instruction. Delivery, model, and
toolsets carry through. Returns the created job dict. toolsets carry through. Returns the created job dict.
""" """
from cron.jobs import create_job from cron.jobs import create_job
job_spec = recipe_to_job_spec(spec, name=name) job_spec = blueprint_to_job_spec(spec, name=name)
if origin is not None: if origin is not None:
job_spec["origin"] = origin job_spec["origin"] = origin
return create_job(**job_spec) return create_job(**job_spec)
def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]: def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]:
"""Turn an installed recipe into a pending Suggested Cron Job. """Turn an installed blueprint into a pending Suggested Cron Job.
Recipes are source ``recipe`` of the unified suggestion surface: installing Blueprints are source ``blueprint`` of the unified suggestion surface: installing
a skill that carries a ``recipe:`` block does NOT auto-schedule it it a skill that carries a ``blueprint:`` block does NOT auto-schedule it it
registers a suggestion the user accepts (or dismisses) like any other. registers a suggestion the user accepts (or dismisses) like any other.
Returns the suggestion record, or None if it was skipped (already Returns the suggestion record, or None if it was skipped (already
seen/dismissed, backlog full, etc.). seen/dismissed, backlog full, etc.).
@ -233,53 +233,53 @@ def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]:
return add_suggestion( return add_suggestion(
title=f"Schedule '{spec.skill_name}'", title=f"Schedule '{spec.skill_name}'",
description=( description=(
f"The '{spec.skill_name}' recipe runs on schedule {spec.schedule}" f"The '{spec.skill_name}' blueprint runs on schedule {spec.schedule}"
+ (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "") + (f", delivering to {spec.deliver}" if spec.deliver and spec.deliver != "origin" else "")
+ "." + "."
), ),
source="recipe", source="blueprint",
job_spec=recipe_to_job_spec(spec), job_spec=blueprint_to_job_spec(spec),
dedup_key=f"recipe:{spec.skill_name}:{spec.schedule}", dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
) )
def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] = None) -> str: def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str:
"""Render a shareable recipe SKILL.md from an existing cron job dict. """Render a shareable blueprint SKILL.md from an existing cron job dict.
The inverse of ``create_recipe_job``: take a cron job a user already built The inverse of ``create_blueprint_job``: take a cron job a user already built
and emit a SKILL.md (with a ``metadata.hermes.recipe`` block) they can hand and emit a SKILL.md (with a ``metadata.hermes.blueprint`` block) they can hand
to ``hermes skills publish`` to share. ``body`` is the plain-language to ``hermes skills publish`` to share. ``body`` is the plain-language
description / instructions that become the SKILL.md body. description / instructions that become the SKILL.md body.
""" """
import yaml import yaml
name = recipe_name or job.get("name") or "shared-recipe" name = blueprint_name or job.get("name") or "shared-blueprint"
# Sanitize to a valid skill identifier. # Sanitize to a valid skill identifier.
name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower()) name = "".join(c if (c.isalnum() or c in "-_") else "-" for c in str(name).lower())
name = name.strip("-_") or "shared-recipe" name = name.strip("-_") or "shared-blueprint"
schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule")) schedule = job.get("schedule_display") or _schedule_to_string(job.get("schedule"))
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else []) skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
recipe_block: Dict[str, Any] = {"schedule": schedule} blueprint_block: Dict[str, Any] = {"schedule": schedule}
deliver = job.get("deliver") deliver = job.get("deliver")
if deliver and deliver != "origin": if deliver and deliver != "origin":
recipe_block["deliver"] = deliver blueprint_block["deliver"] = deliver
if job.get("prompt"): if job.get("prompt"):
recipe_block["prompt"] = job["prompt"] blueprint_block["prompt"] = job["prompt"]
if job.get("no_agent"): if job.get("no_agent"):
recipe_block["no_agent"] = True blueprint_block["no_agent"] = True
if job.get("model"): if job.get("model"):
recipe_block["model"] = job["model"] blueprint_block["model"] = job["model"]
if job.get("provider"): if job.get("provider"):
recipe_block["provider"] = job["provider"] blueprint_block["provider"] = job["provider"]
if job.get("enabled_toolsets"): if job.get("enabled_toolsets"):
recipe_block["enabled_toolsets"] = job["enabled_toolsets"] blueprint_block["enabled_toolsets"] = job["enabled_toolsets"]
description = ( description = (
(body.strip().splitlines() or ["Shared automation recipe."])[0][:200] (body.strip().splitlines() or ["Shared automation blueprint."])[0][:200]
if body.strip() if body.strip()
else "Shared automation recipe." else "Shared automation blueprint."
) )
frontmatter = { frontmatter = {
@ -289,13 +289,13 @@ def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str]
"license": "MIT", "license": "MIT",
"metadata": { "metadata": {
"hermes": { "hermes": {
"tags": ["recipe", "automation"], "tags": ["blueprint", "automation"],
"recipe": recipe_block, "blueprint": blueprint_block,
} }
}, },
} }
fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() fm_yaml = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip()
body_text = body.strip() or f"# {name}\n\nShared automation recipe." body_text = body.strip() or f"# {name}\n\nShared automation blueprint."
return f"---\n{fm_yaml}\n---\n\n{body_text}\n" return f"---\n{fm_yaml}\n---\n\n{body_text}\n"

View File

@ -10,19 +10,19 @@ import { Badge } from "@nous-research/ui/ui/components/badge";
import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useToast } from "@nous-research/ui/hooks/use-toast";
import { Toast } from "@nous-research/ui/ui/components/toast"; import { Toast } from "@nous-research/ui/ui/components/toast";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { CronRecipe, CronRecipeField } from "@/lib/api"; import type { AutomationBlueprint, AutomationBlueprintField } from "@/lib/api";
import { cn, themedBody } from "@/lib/utils"; import { cn, themedBody } from "@/lib/utils";
interface CronRecipesProps { interface AutomationBlueprintsProps {
profile: string; profile: string;
/** Called after a recipe is instantiated so the parent can refresh its job list. */ /** Called after a blueprint is instantiated so the parent can refresh its job list. */
onCreated?: () => void; onCreated?: () => void;
} }
/** Initial form values for a recipe = each field's default (or ""). */ /** Initial form values for a blueprint = each field's default (or ""). */
function initialValues(recipe: CronRecipe): Record<string, string> { function initialValues(blueprint: AutomationBlueprint): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
for (const f of recipe.fields) out[f.name] = f.default ?? ""; for (const f of blueprint.fields) out[f.name] = f.default ?? "";
return out; return out;
} }
@ -31,7 +31,7 @@ function FieldInput({
value, value,
onChange, onChange,
}: { }: {
field: CronRecipeField; field: AutomationBlueprintField;
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
}) { }) {
@ -66,19 +66,19 @@ function FieldInput({
); );
} }
function RecipeCard({ function BlueprintCard({
recipe, blueprint,
profile, profile,
showToast, showToast,
onCreated, onCreated,
}: { }: {
recipe: CronRecipe; blueprint: AutomationBlueprint;
profile: string; profile: string;
showToast: (message: string, type: "error" | "success") => void; showToast: (message: string, type: "error" | "success") => void;
onCreated?: () => void; onCreated?: () => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, string>>(() => initialValues(recipe)); const [values, setValues] = useState<Record<string, string>>(() => initialValues(blueprint));
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -86,11 +86,11 @@ function RecipeCard({
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile); const job = await api.instantiateAutomationBlueprint({ blueprint: blueprint.key, values }, profile);
const when = job.schedule_display ? `${job.schedule_display}` : ""; const when = job.schedule_display ? `${job.schedule_display}` : "";
showToast(`${recipe.title} scheduled${when}`, "success"); showToast(`${blueprint.title} scheduled${when}`, "success");
setOpen(false); setOpen(false);
setValues(initialValues(recipe)); setValues(initialValues(blueprint));
onCreated?.(); onCreated?.();
} catch (e) { } catch (e) {
// 422 from the API carries the slot-level validation message. // 422 from the API carries the slot-level validation message.
@ -99,7 +99,7 @@ function RecipeCard({
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [recipe, values, profile, showToast, onCreated]); }, [blueprint, values, profile, showToast, onCreated]);
return ( return (
<Card className={cn("overflow-hidden", themedBody)}> <Card className={cn("overflow-hidden", themedBody)}>
@ -108,11 +108,11 @@ function RecipeCard({
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 shrink-0 opacity-70" /> <Wand2 className="h-4 w-4 shrink-0 opacity-70" />
<span className="font-medium">{recipe.title}</span> <span className="font-medium">{blueprint.title}</span>
</div> </div>
<p className="mt-1 text-sm opacity-70">{recipe.description}</p> <p className="mt-1 text-sm opacity-70">{blueprint.description}</p>
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{recipe.tags.map((t) => ( {blueprint.tags.map((t) => (
<Badge key={t} tone="secondary"> <Badge key={t} tone="secondary">
{t} {t}
</Badge> </Badge>
@ -130,9 +130,9 @@ function RecipeCard({
{open && ( {open && (
<div className="space-y-3 border-t pt-3"> <div className="space-y-3 border-t pt-3">
{recipe.fields.map((f) => ( {blueprint.fields.map((f) => (
<div key={f.name} className="space-y-1"> <div key={f.name} className="space-y-1">
<Label htmlFor={`${recipe.key}-${f.name}`}>{f.label}</Label> <Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
<FieldInput <FieldInput
field={f} field={f}
value={values[f.name] ?? ""} value={values[f.name] ?? ""}
@ -162,22 +162,22 @@ function RecipeCard({
} }
/** /**
* Cron Recipes gallery the form-where-there's-a-screen surface. Each recipe * Automation Blueprints gallery the form-where-there's-a-screen surface. Each blueprint
* card expands into an inline form (one field per typed slot); submitting POSTs * card expands into an inline form (one field per typed slot); submitting POSTs
* to /api/cron/recipes/instantiate which fills the recipe and creates the job * to /api/cron/blueprints/instantiate which fills the blueprint and creates the job
* via the same create_job path as everything else. * via the same create_job path as everything else.
*/ */
export function CronRecipes({ profile, onCreated }: CronRecipesProps) { export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const [recipes, setRecipes] = useState<CronRecipe[] | null>(null); const [blueprints, setBlueprints] = useState<AutomationBlueprint[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api api
.getCronRecipes() .getAutomationBlueprints()
.then((r) => { .then((r) => {
if (!cancelled) setRecipes(r.recipes); if (!cancelled) setBlueprints(r.blueprints);
}) })
.catch((e) => { .catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e)); if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
@ -188,27 +188,27 @@ export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
}, []); }, []);
if (loadError) { if (loadError) {
return <p className="text-sm text-red-500">Couldn't load recipes: {loadError}</p>; return <p className="text-sm text-red-500">Couldn't load blueprints: {loadError}</p>;
} }
if (recipes === null) { if (blueprints === null) {
return ( return (
<div className="flex items-center gap-2 opacity-70"> <div className="flex items-center gap-2 opacity-70">
<Spinner className="h-4 w-4" /> Loading recipes <Spinner className="h-4 w-4" /> Loading blueprints
</div> </div>
); );
} }
if (recipes.length === 0) { if (blueprints.length === 0) {
return <p className="opacity-70">No cron recipes available.</p>; return <p className="opacity-70">No automation blueprints available.</p>;
} }
return ( return (
<> <>
<Toast toast={toast} /> <Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{recipes.map((r) => ( {blueprints.map((r) => (
<RecipeCard <BlueprintCard
key={r.key} key={r.key}
recipe={r} blueprint={r}
profile={profile} profile={profile}
showToast={showToast} showToast={showToast}
onCreated={onCreated} onCreated={onCreated}
@ -219,4 +219,4 @@ export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
); );
} }
export default CronRecipes; export default AutomationBlueprints;

View File

@ -497,14 +497,14 @@ export const api = {
deleteCronJob: (id: string, profile = "default") => deleteCronJob: (id: string, profile = "default") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }), fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Cron Recipes — parameterized automation templates // Automation Blueprints — parameterized automation templates
getCronRecipes: () => getAutomationBlueprints: () =>
fetchJSON<{ recipes: CronRecipe[] }>("/api/cron/recipes"), fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
instantiateCronRecipe: ( instantiateAutomationBlueprint: (
body: { recipe: string; values: Record<string, string> }, body: { blueprint: string; values: Record<string, string> },
profile = "default", profile = "default",
) => ) =>
fetchJSON<CronJob>(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, { fetchJSON<CronJob>(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@ -1838,7 +1838,7 @@ export interface CronDeliveryTarget {
home_env_var: string | null; home_env_var: string | null;
} }
export interface CronRecipeField { export interface AutomationBlueprintField {
name: string; name: string;
type: "time" | "enum" | "text" | "weekdays"; type: "time" | "enum" | "text" | "weekdays";
label: string; label: string;
@ -1850,13 +1850,13 @@ export interface CronRecipeField {
help: string; help: string;
} }
export interface CronRecipe { export interface AutomationBlueprint {
key: string; key: string;
title: string; title: string;
description: string; description: string;
category: string; category: string;
tags: string[]; tags: string[];
fields: CronRecipeField[]; fields: AutomationBlueprintField[];
command: string; command: string;
appUrl: string; appUrl: string;
} }

View File

@ -30,7 +30,7 @@ import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader"; import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins"; import { PluginSlot } from "@/plugins";
import { Segmented } from "@nous-research/ui/ui/components/segmented"; import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { CronRecipes } from "@/components/CronRecipes"; import { AutomationBlueprints } from "@/components/AutomationBlueprints";
import { cn, themedBody } from "@/lib/utils"; import { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string { function formatTime(iso?: string | null): string {
@ -178,7 +178,7 @@ export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]); const [jobs, setJobs] = useState<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]); const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [selectedProfile, setSelectedProfile] = useState("all"); const [selectedProfile, setSelectedProfile] = useState("all");
const [view, setView] = useState<"jobs" | "recipes">("jobs"); const [view, setView] = useState<"jobs" | "blueprints">("jobs");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { t, locale } = useI18n(); const { t, locale } = useI18n();
@ -512,15 +512,15 @@ export default function CronPage() {
<Segmented <Segmented
value={view} value={view}
onChange={(v) => setView(v as "jobs" | "recipes")} onChange={(v) => setView(v as "jobs" | "blueprints")}
options={[ options={[
{ value: "jobs", label: "Jobs" }, { value: "jobs", label: "Jobs" },
{ value: "recipes", label: "Recipes" }, { value: "blueprints", label: "Blueprints" },
]} ]}
/> />
{view === "recipes" && ( {view === "blueprints" && (
<CronRecipes <AutomationBlueprints
profile={selectedProfile === "all" ? "default" : selectedProfile} profile={selectedProfile === "all" ? "default" : selectedProfile}
onCreated={loadJobs} onCreated={loadJobs}
/> />

View File

@ -66,7 +66,7 @@ metadata:
description: "What this setting controls" description: "What this setting controls"
default: "sensible-default" default: "sensible-default"
prompt: "Display prompt for setup" prompt: "Display prompt for setup"
recipe: # Optional — marks this skill a runnable automation blueprint: # Optional — marks this skill a runnable automation
schedule: "0 9 * * *" # cron expr / "every 2h" / ISO timestamp schedule: "0 9 * * *" # cron expr / "every 2h" / ISO timestamp
deliver: origin # optional (default origin) deliver: origin # optional (default origin)
prompt: "Task instruction for each run" # optional prompt: "Task instruction for each run" # optional
@ -339,28 +339,28 @@ If your skill is official and useful but not universally needed (e.g., a paid se
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`. If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`.
## Recipes: skills that are also automations ## Blueprints: skills that are also automations
A **recipe** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.recipe` block and the skill becomes a shareable, runnable automation: A **blueprint** is an ordinary skill that additionally declares a schedule in its frontmatter. Add a `metadata.hermes.blueprint` block and the skill becomes a shareable, runnable automation:
```yaml ```yaml
metadata: metadata:
hermes: hermes:
tags: [recipe, email] tags: [blueprint, email]
recipe: blueprint:
schedule: "0 8 * * *" # presence of `recipe:` marks it runnable schedule: "0 8 * * *" # presence of `blueprint:` marks it runnable
deliver: telegram # optional (default: origin) deliver: telegram # optional (default: origin)
prompt: "Summarize my unread email and today's calendar." # optional prompt: "Summarize my unread email and today's calendar." # optional
no_agent: false # optional no_agent: false # optional
``` ```
Because a recipe **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn. Because a blueprint **is** a skill, it flows through the entire skills pipeline unchanged — search, inspect, install, security scan, provenance, taps, the centralized index, and `hermes skills publish` for sharing. Nothing new to learn.
**Installing a recipe.** When you install a skill that carries a `recipe:` block, Hermes registers it as a **suggested cron job** rather than scheduling it. Scheduling is **opt-in** — installing never silently creates a recurring job. You review and accept it via `/suggestions`: **Installing a blueprint.** When you install a skill that carries a `blueprint:` block, Hermes registers it as a **suggested cron job** rather than scheduling it. Scheduling is **opt-in** — installing never silently creates a recurring job. You review and accept it via `/suggestions`:
```bash ```bash
hermes skills install owner/morning-brief hermes skills install owner/morning-brief
# → Recipe: 'morning-brief' is an automation (schedule 0 8 * * *). # → Blueprint: 'morning-brief' is an automation (schedule 0 8 * * *).
# Added to your suggestions — run /suggestions to schedule or dismiss it. # Added to your suggestions — run /suggestions to schedule or dismiss it.
# then, in a session: # then, in a session:
@ -369,11 +369,11 @@ hermes skills install owner/morning-brief
/suggestions dismiss 1 # never offer it again /suggestions dismiss 1 # never offer it again
``` ```
Recipes are one **source** of the unified Suggested Cron Jobs surface — the same place curated starter automations and (later) usage-pattern and integration suggestions appear. See [Suggested Cron Jobs](#suggested-cron-jobs) below. Blueprints are one **source** of the unified Suggested Cron Jobs surface — the same place curated starter automations and (later) usage-pattern and integration suggestions appear. See [Suggested Cron Jobs](#suggested-cron-jobs) below.
**Sharing an automation you built.** A recipe loaded by a cron job (`hermes cron create --skill <name> ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else. **Sharing an automation you built.** A blueprint loaded by a cron job (`hermes cron create --skill <name> ...`) can be exported back to a SKILL.md and published like any other skill, so an automation you tuned for yourself becomes a one-command install for someone else.
The recipe layer adds no new object type, store, or transport — the recipe is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path. The blueprint layer adds no new object type, store, or transport — the blueprint is a skill, the schedule is a cron job, and sharing is the existing publish/tap/index path.
## Suggested Cron Jobs ## Suggested Cron Jobs
@ -382,7 +382,7 @@ Hermes can *propose* automations and let you accept them with one tap, instead o
| Source | Trigger | | Source | Trigger |
|--------|---------| |--------|---------|
| `catalog` | Curated starter automations (`/suggestions catalog`) — daily briefing, important-mail monitor, weekly review, workday-start reminder | | `catalog` | Curated starter automations (`/suggestions catalog`) — daily briefing, important-mail monitor, weekly review, workday-start reminder |
| `recipe` | You installed a skill carrying a `recipe:` block | | `blueprint` | You installed a skill carrying a `blueprint:` block |
| `usage` | The background review noticed a recurring ask a schedule would serve | | `usage` | The background review noticed a recurring ask a schedule would serve |
| `integration` | You connected an account (Gmail, GitHub, ...) and the obvious automations are offered | | `integration` | You connected an account (Gmail, GitHub, ...) and the obvious automations are offered |

View File

@ -0,0 +1,36 @@
---
sidebar_position: 7
title: "Automation Blueprints Catalog"
description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
---
import AutomationBlueprintsCatalog from '@site/src/components/AutomationBlueprintsCatalog';
# Automation Blueprints
Automation Blueprints are ready-to-run automation templates. Pick one, fill in a couple
of fields, and Hermes schedules it as a cron job — no cron syntax required.
Every blueprint works from **every surface**:
- **Dashboard / desktop app** — open the Cron page, switch to the **Blueprints**
tab, fill the form, and click *Schedule it*.
- **CLI, TUI, and messengers** — type `/blueprint <name>` (e.g.
`/blueprint morning-brief`) and Hermes asks you for what it needs, one
question at a time, then schedules it. The name match is forgiving — a
prefix or near-spelling resolves. Power users can skip the questions by
passing values inline: `/blueprint morning-brief time=08:00`.
- **Desktop app** — click **Send to App** on any blueprint and it opens with the
command pre-loaded in your composer.
Blueprints never schedule anything silently — you always confirm before the job
is created. Manage created jobs anytime with `/cron`.
<AutomationBlueprintsCatalog />
## Writing your own
A blueprint is just a skill with a `metadata.hermes.blueprint` block in its
`SKILL.md` frontmatter. See
[Creating Skills → Automation Blueprints](../developer-guide/creating-skills.md) for the
slot schema and how to publish one.

View File

@ -1,36 +0,0 @@
---
sidebar_position: 7
title: "Cron Recipes Catalog"
description: "Ready-to-run automation templates — set one up from the dashboard, CLI, TUI, any messenger, or the desktop app."
---
import CronRecipesCatalog from '@site/src/components/CronRecipesCatalog';
# Cron Recipes
Cron Recipes are ready-to-run automation templates. Pick one, fill in a couple
of fields, and Hermes schedules it as a cron job — no cron syntax required.
Every recipe works from **every surface**:
- **Dashboard / desktop app** — open the Cron page, switch to the **Recipes**
tab, fill the form, and click *Schedule it*.
- **CLI, TUI, and messengers** — type `/cron-recipe <name>` (e.g.
`/cron-recipe morning-brief`) and Hermes asks you for what it needs, one
question at a time, then schedules it. The name match is forgiving — a
prefix or near-spelling resolves. Power users can skip the questions by
passing values inline: `/cron-recipe morning-brief time=08:00`.
- **Desktop app** — click **Send to App** on any recipe and it opens with the
command pre-loaded in your composer.
Recipes never schedule anything silently — you always confirm before the job
is created. Manage created jobs anytime with `/cron`.
<CronRecipesCatalog />
## Writing your own
A recipe is just a skill with a `metadata.hermes.recipe` block in its
`SKILL.md` frontmatter. See
[Creating Skills → Cron Recipes](../developer-guide/creating-skills.md) for the
slot schema and how to publish one.

View File

@ -1,13 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate the Cron Recipes catalog JSON for the docs site. """Generate the Automation Blueprints catalog JSON for the docs site.
Mirrors ``extract-skills.py``: imports the single-source-of-truth recipe Mirrors ``extract-skills.py``: imports the single-source-of-truth blueprint
definitions from ``cron/recipe_catalog.py`` and emits a flat JSON array the definitions from ``cron/blueprint_catalog.py`` and emits a flat JSON array the
docs page renders into cards (description, schedule, copy-paste slash command, docs page renders into cards (description, schedule, copy-paste slash command,
and a ``hermes://`` "Send to App" deep-link). and a ``hermes://`` "Send to App" deep-link).
Output: ``website/static/api/cron-recipes-index.json`` (served at Output: ``website/static/api/automation-blueprints-index.json`` (served at
``/docs/api/cron-recipes-index.json``). Run automatically by ``/docs/api/automation-blueprints-index.json``). Run automatically by
``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``. ``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``.
""" """
@ -21,13 +21,13 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
OUTPUT = REPO_ROOT / "website" / "static" / "api" / "cron-recipes-index.json" OUTPUT = REPO_ROOT / "website" / "static" / "api" / "automation-blueprints-index.json"
def build_index() -> list: def build_index() -> list:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
return [recipe_catalog_entry(r) for r in CATALOG] return [blueprint_catalog_entry(r) for r in CATALOG]
def main() -> int: def main() -> int:
@ -36,13 +36,13 @@ def main() -> int:
except Exception as e: # pragma: no cover - import/build failure except Exception as e: # pragma: no cover - import/build failure
# Match extract-skills.py's resilience: write an empty array so the # Match extract-skills.py's resilience: write an empty array so the
# docs build never hard-fails on a generator hiccup. # docs build never hard-fails on a generator hiccup.
sys.stderr.write(f"extract-cron-recipes: {e}; writing empty index\n") sys.stderr.write(f"extract-automation-blueprints: {e}; writing empty index\n")
index = [] index = []
OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w", encoding="utf-8") as f: with open(OUTPUT, "w", encoding="utf-8") as f:
json.dump(index, f, separators=(",", ":")) json.dump(index, f, separators=(",", ":"))
sys.stderr.write(f"extract-cron-recipes: wrote {len(index)} recipes -> {OUTPUT}\n") sys.stderr.write(f"extract-automation-blueprints: wrote {len(index)} blueprints -> {OUTPUT}\n")
return 0 return 0

View File

@ -31,7 +31,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
const websiteDir = resolve(scriptDir, ".."); const websiteDir = resolve(scriptDir, "..");
const extractScript = join(scriptDir, "extract-skills.py"); const extractScript = join(scriptDir, "extract-skills.py");
const llmsScript = join(scriptDir, "generate-llms-txt.py"); const llmsScript = join(scriptDir, "generate-llms-txt.py");
const cronRecipesScript = join(scriptDir, "extract-cron-recipes.py"); const cronBlueprintsScript = join(scriptDir, "extract-automation-blueprints.py");
const outputFile = join(websiteDir, "static", "api", "skills.json"); const outputFile = join(websiteDir, "static", "api", "skills.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json"); const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL = const UNIFIED_INDEX_URL =
@ -140,6 +140,6 @@ if (!existsSync(extractScript)) {
// 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal. // 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal.
runPython(llmsScript, "generate-llms-txt.py"); runPython(llmsScript, "generate-llms-txt.py");
// 3) cron-recipes-index.json — Cron Recipes catalog page. Non-fatal; the page // 3) automation-blueprints-index.json — Automation Blueprints catalog page. Non-fatal; the page
// renders an empty state if the generator can't run. // renders an empty state if the generator can't run.
runPython(cronRecipesScript, "extract-cron-recipes.py"); runPython(cronBlueprintsScript, "extract-automation-blueprints.py");

View File

@ -78,7 +78,7 @@ const sidebars: SidebarsConfig = {
label: 'Automation', label: 'Automation',
items: [ items: [
'user-guide/features/cron', 'user-guide/features/cron',
'reference/cron-recipes-catalog', 'reference/automation-blueprints-catalog',
'user-guide/features/delegation', 'user-guide/features/delegation',
'user-guide/features/kanban', 'user-guide/features/kanban',
'user-guide/features/codex-app-server-runtime', 'user-guide/features/codex-app-server-runtime',

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
interface RecipeField { interface BlueprintField {
name: string; name: string;
type: string; type: string;
label: string; label: string;
@ -11,19 +11,19 @@ interface RecipeField {
help: string; help: string;
} }
interface Recipe { interface Blueprint {
key: string; key: string;
title: string; title: string;
description: string; description: string;
category: string; category: string;
tags: string[]; tags: string[];
fields: RecipeField[]; fields: BlueprintField[];
scheduleHuman: string; scheduleHuman: string;
command: string; command: string;
appUrl: string; appUrl: string;
} }
const INDEX_URL = "/docs/api/cron-recipes-index.json"; const INDEX_URL = "/docs/api/automation-blueprints-index.json";
function CopyButton({ text }: { text: string }): JSX.Element { function CopyButton({ text }: { text: string }): JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -44,17 +44,17 @@ function CopyButton({ text }: { text: string }): JSX.Element {
); );
} }
function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element { function BlueprintCard({ blueprint }: { blueprint: Blueprint }): JSX.Element {
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.cardHead}> <div className={styles.cardHead}>
<h3 className={styles.title}>{recipe.title}</h3> <h3 className={styles.title}>{blueprint.title}</h3>
<span className={styles.schedule}>{recipe.scheduleHuman}</span> <span className={styles.schedule}>{blueprint.scheduleHuman}</span>
</div> </div>
<p className={styles.desc}>{recipe.description}</p> <p className={styles.desc}>{blueprint.description}</p>
<div className={styles.tags}> <div className={styles.tags}>
{recipe.tags.map((t) => ( {blueprint.tags.map((t) => (
<span key={t} className={styles.tag}> <span key={t} className={styles.tag}>
{t} {t}
</span> </span>
@ -62,12 +62,12 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
</div> </div>
<div className={styles.cmdRow}> <div className={styles.cmdRow}>
<code className={styles.cmd}>{recipe.command}</code> <code className={styles.cmd}>{blueprint.command}</code>
<CopyButton text={recipe.command} /> <CopyButton text={blueprint.command} />
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<a className={styles.appBtn} href={recipe.appUrl}> <a className={styles.appBtn} href={blueprint.appUrl}>
Send to App Send to App
</a> </a>
<span className={styles.hint}> <span className={styles.hint}>
@ -78,16 +78,16 @@ function RecipeCard({ recipe }: { recipe: Recipe }): JSX.Element {
); );
} }
export default function CronRecipesCatalog(): JSX.Element { export default function AutomationBlueprintsCatalog(): JSX.Element {
const [recipes, setRecipes] = useState<Recipe[] | null>(null); const [blueprints, setBlueprints] = useState<Blueprint[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetch(INDEX_URL) fetch(INDEX_URL)
.then((r) => r.json()) .then((r) => r.json())
.then((data: Recipe[]) => { .then((data: Blueprint[]) => {
if (!cancelled) setRecipes(data); if (!cancelled) setBlueprints(data);
}) })
.catch((e) => { .catch((e) => {
if (!cancelled) setError(String(e)); if (!cancelled) setError(String(e));
@ -98,19 +98,19 @@ export default function CronRecipesCatalog(): JSX.Element {
}, []); }, []);
if (error) { if (error) {
return <p>Couldn't load the recipe catalog: {error}</p>; return <p>Couldn't load the blueprint catalog: {error}</p>;
} }
if (recipes === null) { if (blueprints === null) {
return <p>Loading recipes</p>; return <p>Loading blueprints</p>;
} }
if (recipes.length === 0) { if (blueprints.length === 0) {
return <p>No cron recipes are available.</p>; return <p>No automation blueprints are available.</p>;
} }
return ( return (
<div className={styles.grid}> <div className={styles.grid}>
{recipes.map((r) => ( {blueprints.map((r) => (
<RecipeCard key={r.key} recipe={r} /> <BlueprintCard key={r.key} blueprint={r} />
))} ))}
</div> </div>
); );