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.
This commit is contained in:
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).
website/static/api/skills.json
website/static/api/skills-meta.json
# cron-recipes-index.json is a build artifact emitted by
# website/scripts/extract-cron-recipes.py during prebuild.
website/static/api/cron-recipes-index.json
# automation-blueprints-index.json is a build artifact emitted by
# website/scripts/extract-automation-blueprints.py during prebuild.
website/static/api/automation-blueprints-index.json
models-dev-upstream/
# 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))
// ---------------------------------------------------------------------------
// 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
// running app's chat composer. Three delivery paths: macOS 'open-url',
// 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}`)
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 name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
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).
// Build the equivalent /cron-recipe slash command from the payload and drop
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
// Build the equivalent /blueprint slash command from the payload and drop
// 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
// that arrived during boot is flushed exactly once.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onDeepLink?.((payload) => {
if (!payload || payload.kind !== 'cron-recipe' || !payload.name) {
if (!payload || payload.kind !== 'blueprint' || !payload.name) {
return
}
const slots = Object.entries(payload.params || {})
@ -283,7 +283,7 @@ export function DesktopController() {
return `${k}=${sval}`
})
.join(' ')
const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}`
const command = `/blueprint ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: '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
# else). See #34584.
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
# by the interactive loop immediately after process_command() returns.
self._pending_agent_seed = None
@ -7415,8 +7415,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_cron_command(cmd_original)
elif canonical == "suggestions":
self._handle_suggestions_command(cmd_original)
elif canonical == "cron-recipe":
self._handle_cron_recipe_command(cmd_original)
elif canonical == "blueprint":
self._handle_blueprint_command(cmd_original)
elif canonical == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":
@ -12837,7 +12837,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint("\n[dim]Command interrupted.[/dim]")
continue
# 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
# as the user message instead of looping back to idle.
_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:
* 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
* Docs catalog -> a copy-paste command + a ``hermes://`` deep-link
The single source of truth is the slot schema below. ``recipe_form_schema``
emits what a form renderer needs; ``recipe_slash_command`` emits the flattened
one-line command; ``fill_recipe`` validates user-supplied values and turns a
recipe into a ``cron.jobs.create_job`` kwargs dict (so there is no second job
The single source of truth is the slot schema below. ``blueprint_form_schema``
emits what a form renderer needs; ``blueprint_slash_command`` emits the flattened
one-line command; ``fill_blueprint`` validates user-supplied values and turns a
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
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
(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.
"""
@ -28,21 +28,21 @@ from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
__all__ = [
"RecipeSlot",
"CronRecipe",
"BlueprintSlot",
"AutomationBlueprint",
"CATALOG",
"get_recipe",
"recipe_form_schema",
"recipe_slash_command",
"recipe_deeplink",
"recipe_catalog_entry",
"fill_recipe",
"RecipeFillError",
"get_blueprint",
"blueprint_form_schema",
"blueprint_slash_command",
"blueprint_deeplink",
"blueprint_catalog_entry",
"fill_blueprint",
"BlueprintFillError",
"WEEKDAY_PRESETS",
]
class RecipeFillError(ValueError):
class BlueprintFillError(ValueError):
"""Raised when supplied slot values fail validation."""
@ -58,8 +58,8 @@ WEEKDAY_PRESETS: Dict[str, str] = {
@dataclass(frozen=True)
class RecipeSlot:
"""A single fillable field on a recipe."""
class BlueprintSlot:
"""A single fillable field on a blueprint."""
name: str
type: str
@ -80,7 +80,7 @@ class RecipeSlot:
@dataclass(frozen=True)
class CronRecipe:
class AutomationBlueprint:
"""A parameterized automation template."""
key: str
@ -93,7 +93,7 @@ class CronRecipe:
schedule_template: str
# Seed instruction for the agent / the cron job prompt; may contain {slot}s.
prompt_template: str
slots: List[RecipeSlot] = field(default_factory=list)
slots: List[BlueprintSlot] = field(default_factory=list)
deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running
tags: tuple = ()
@ -103,11 +103,11 @@ class CronRecipe:
# 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,
help="24h local time, e.g. 08:00",
)
_DELIVER = RecipeSlot(
_DELIVER = BlueprintSlot(
name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"),
optional=False, strict=False,
@ -117,8 +117,8 @@ _DELIVER = RecipeSlot(
)
CATALOG: List[CronRecipe] = [
CronRecipe(
CATALOG: List[AutomationBlueprint] = [
AutomationBlueprint(
key="morning-brief",
title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and "
@ -134,7 +134,7 @@ CATALOG: List[CronRecipe] = [
slots=[_TIME("08:00"), _DELIVER],
tags=("daily", "briefing"),
),
CronRecipe(
AutomationBlueprint(
key="important-mail",
title="Important-mail monitor",
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."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"),
help="minutes between checks",
),
RecipeSlot(
BlueprintSlot(
name="criteria", type="text",
label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, "
@ -164,7 +164,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("email", "monitor"),
),
CronRecipe(
AutomationBlueprint(
key="weekly-review",
title="Weekly review",
description="A weekly recap: what got done, what's still open, and "
@ -178,7 +178,7 @@ CATALOG: List[CronRecipe] = [
),
slots=[
_TIME("18:00"),
RecipeSlot(
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
@ -187,7 +187,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("weekly", "review"),
),
CronRecipe(
AutomationBlueprint(
key="workday-start",
title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.",
@ -201,7 +201,7 @@ CATALOG: List[CronRecipe] = [
slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"),
),
CronRecipe(
AutomationBlueprint(
key="custom-reminder",
title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.",
@ -209,10 +209,10 @@ CATALOG: List[CronRecipe] = [
schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}",
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"),
_TIME("14:00"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
@ -221,7 +221,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("reminder",),
),
CronRecipe(
AutomationBlueprint(
key="evening-winddown",
title="Evening wind-down",
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],
tags=("daily", "evening"),
),
CronRecipe(
AutomationBlueprint(
key="news-digest",
title="Topic news digest",
description="A recurring digest on a topic you care about — deduped "
@ -253,18 +253,18 @@ CATALOG: List[CronRecipe] = [
"last run, respond with [SILENT]."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="topic", type="text", label="What topic?",
default="AI and technology",
help="a subject, product, person, or search phrase",
),
_TIME("18:00"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
RecipeSlot(
BlueprintSlot(
name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"),
),
@ -272,7 +272,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("digest", "research"),
),
CronRecipe(
AutomationBlueprint(
key="bill-renewal-watch",
title="Bills & renewals reminder",
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."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="what", type="text", label="What's due?",
default="my streaming subscription renews soon",
),
_TIME("10:00"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
@ -299,7 +299,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("reminder", "finance"),
),
CronRecipe(
AutomationBlueprint(
key="habit-checkin",
title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect "
@ -312,12 +312,12 @@ CATALOG: List[CronRecipe] = [
"of encouragement. One short message."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="habit", type="text", label="Which habit?",
default="20 minutes of reading",
),
_TIME("20:00"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
@ -326,7 +326,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("habit", "wellbeing"),
),
CronRecipe(
AutomationBlueprint(
key="hydration-move",
title="Hydration & movement nudge",
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."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="interval_hours", type="enum", label="How often?",
default="1", options=("1", "2", "3"),
help="hours between nudges",
),
RecipeSlot(
BlueprintSlot(
name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)",
),
RecipeSlot(
BlueprintSlot(
name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)",
@ -361,7 +361,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("wellbeing", "focus"),
),
CronRecipe(
AutomationBlueprint(
key="meal-plan",
title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, "
@ -371,27 +371,27 @@ CATALOG: List[CronRecipe] = [
prompt_template=(
"Build the user a meal plan for the coming week: {meals} per day, "
"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."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="diet", type="enum", label="Diet?",
default="no restrictions",
options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"),
),
RecipeSlot(
BlueprintSlot(
name="meals", type="enum", label="Meals per day?",
default="dinner only",
options=("dinner only", "lunch and dinner", "all three"),
),
RecipeSlot(
BlueprintSlot(
name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"),
),
_TIME("17:00"),
RecipeSlot(
BlueprintSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
@ -400,7 +400,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("weekly", "food"),
),
CronRecipe(
AutomationBlueprint(
key="learn-daily",
title="Daily learning drip",
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."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="topic", type="text", label="Learn about…",
default="Spanish vocabulary",
),
_TIME("08:30"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
@ -428,7 +428,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("learning", "daily"),
),
CronRecipe(
AutomationBlueprint(
key="gratitude-journal",
title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note "
@ -443,7 +443,7 @@ CATALOG: List[CronRecipe] = [
),
slots=[
_TIME("21:30"),
RecipeSlot(
BlueprintSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
@ -452,7 +452,7 @@ CATALOG: List[CronRecipe] = [
],
tags=("wellbeing", "reflection"),
),
CronRecipe(
AutomationBlueprint(
key="on-this-day",
title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, "
@ -465,7 +465,7 @@ CATALOG: List[CronRecipe] = [
"no filler."
),
slots=[
RecipeSlot(
BlueprintSlot(
name="flavor", type="enum", label="What kind?",
default="on this day in history",
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}
def get_recipe(key: str) -> Optional[CronRecipe]:
def get_blueprint(key: str) -> Optional[AutomationBlueprint]:
return _CATALOG_BY_KEY.get(key)
@ -489,14 +489,14 @@ def get_recipe(key: str) -> Optional[CronRecipe]:
# Renderers
# ---------------------------------------------------------------------------
def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this recipe."""
def blueprint_form_schema(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this blueprint."""
return {
"key": recipe.key,
"title": recipe.title,
"description": recipe.description,
"category": recipe.category,
"tags": list(recipe.tags),
"key": blueprint.key,
"title": blueprint.title,
"description": blueprint.description,
"category": blueprint.category,
"tags": list(blueprint.tags),
"fields": [
{
"name": s.name,
@ -508,20 +508,20 @@ def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]:
"strict": s.strict,
"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:
"""Build the flattened ``/cron-recipe <key> slot=val …`` command string.
def blueprint_slash_command(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/blueprint <key> slot=val …`` command string.
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.
"""
values = values or {}
parts = [f"/cron-recipe {recipe.key}"]
for s in recipe.slots:
parts = [f"/blueprint {blueprint.key}"]
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val is None or val == "":
if s.optional:
@ -534,38 +534,38 @@ def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] =
return " ".join(parts)
def recipe_deeplink(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://cron-recipe/<key>?slot=val`` deep-link URL."""
def blueprint_deeplink(blueprint: AutomationBlueprint, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the ``hermes://blueprint/<key>?slot=val`` deep-link URL."""
from urllib.parse import quote, urlencode
values = values or {}
query = {}
for s in recipe.slots:
for s in blueprint.slots:
val = values.get(s.name, s.default)
if val not in (None, ""):
query[s.name] = str(val)
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:
"""A short human-readable description of when a recipe runs (defaults)."""
sched = recipe.schedule_template
def _humanize_schedule(blueprint: AutomationBlueprint) -> str:
"""A short human-readable description of when a blueprint runs (defaults)."""
sched = blueprint.schedule_template
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]
return f"every {every} minutes"
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")
scope = "weekdays, " if "* * 1-5" in sched else ""
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
if "* * 1-5" in sched:
return f"weekdays at {when}" if when else "every weekday"
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 ""
if scope and when:
return f"{scope} at {when}"
@ -575,17 +575,17 @@ def _humanize_schedule(recipe: CronRecipe) -> str:
return "on a schedule"
def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]:
"""Unified serializable shape for a recipe — used by the docs generator
def blueprint_catalog_entry(blueprint: AutomationBlueprint) -> Dict[str, Any]:
"""Unified serializable shape for a blueprint — used by the docs generator
and the dashboard API. Combines the form schema, the ready-to-paste slash
command, the deep-link URL, and a human-readable schedule.
"""
return {
**recipe_form_schema(recipe),
"schedule": recipe.schedule_template,
"scheduleHuman": _humanize_schedule(recipe),
"command": recipe_slash_command(recipe),
"appUrl": recipe_deeplink(recipe),
**blueprint_form_schema(blueprint),
"schedule": blueprint.schedule_template,
"scheduleHuman": _humanize_schedule(blueprint),
"command": blueprint_slash_command(blueprint),
"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."""
sched = recipe.schedule_template
sched = blueprint.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility).
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")
if "{minute}" in sched or "{hour}" in sched:
if not time_val:
raise RecipeFillError("a time is required")
raise BlueprintFillError("a time is required")
m = _TIME_RE.match(str(time_val).strip())
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["minute"] = str(int(m.group(2)))
@ -626,14 +626,14 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS:
raise RecipeFillError(
raise BlueprintFillError(
f"unknown recurrence {preset!r} — one of {', '.join(WEEKDAY_PRESETS)}"
)
repl["dow"] = WEEKDAY_PRESETS[preset]
elif "day" in values:
day = str(values.get("day", "")).lower()
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]
else:
repl["dow"] = "*"
@ -642,12 +642,12 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
if "{interval_min}" in sched:
iv = str(values.get("interval_min", "")).strip()
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
# Any remaining {slot} placeholders are filled verbatim from validated
# 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):
if name not in repl and name in values:
repl[name] = str(values[name])
@ -655,59 +655,59 @@ def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
try:
return sched.format(**repl)
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(
recipe: CronRecipe,
def fill_blueprint(
blueprint: AutomationBlueprint,
values: Dict[str, Any],
*,
origin: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""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.
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
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)
if unknown:
raise RecipeFillError(
raise BlueprintFillError(
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] = {}
for s in recipe.slots:
for s in blueprint.slots:
raw = values.get(s.name, s.default)
if raw in (None, ""):
if s.optional:
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}:
raise RecipeFillError(
raise BlueprintFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
)
resolved[s.name] = raw
schedule = _resolve_schedule(recipe, resolved)
schedule = _resolve_schedule(blueprint, resolved)
# Render the prompt with whatever slots it references.
try:
prompt = recipe.prompt_template.format(**resolved)
prompt = blueprint.prompt_template.format(**resolved)
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] = {
"prompt": prompt,
"schedule": schedule,
"name": recipe.title,
"deliver": resolved.get("deliver", recipe.deliver_default),
"name": blueprint.title,
"deliver": resolved.get("deliver", blueprint.deliver_default),
}
if recipe.skills:
spec["skills"] = list(recipe.skills)
if blueprint.skills:
spec["skills"] = list(blueprint.skills)
if origin is not None:
spec["origin"] = origin
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
monitor, weekly digest, ...).
* ``recipe`` the user installed a skill that carries a ``recipe:`` block
(see ``tools/recipes.py``); installing it registers a
* ``blueprint`` the user installed a skill that carries a ``blueprint:`` block
(see ``tools/blueprints.py``); installing it registers a
suggestion instead of auto-scheduling.
* ``usage`` the background self-improvement review noticed a recurring
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).
MAX_PENDING = 5
VALID_SOURCES = frozenset({"catalog", "recipe", "usage", "integration"})
VALID_SOURCES = frozenset({"catalog", "blueprint", "usage", "integration"})
_STATUS_PENDING = "pending"
_STATUS_ACCEPTED = "accepted"
_STATUS_DISMISSED = "dismissed"

View file

@ -7174,11 +7174,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "suggestions":
return await self._handle_suggestions_command(event)
if canonical == "cron-recipe":
_recipe_result = await self._handle_cron_recipe_command(event)
_recipe_seed = getattr(_recipe_result, "agent_seed", None)
if _recipe_seed:
# Recipe matched — rewrite the turn to the seed and fall
if canonical == "blueprint":
_blueprint_result = await self._handle_blueprint_command(event)
_blueprint_seed = getattr(_blueprint_result, "agent_seed", None)
if _blueprint_seed:
# Blueprint matched — rewrite the turn to the seed and fall
# through to _handle_message_with_agent so the agent asks the
# user for each slot value conversationally and then calls the
# 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
# immediate feedback CLI users see, instead of silence until
# the agent's first question.
_ack = getattr(_recipe_result, "text", "") or ""
_ack = getattr(_blueprint_result, "text", "") or ""
if _ack:
try:
adapter = self.adapters.get(source.platform)
@ -7194,13 +7194,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_ack_meta = self._thread_metadata_for_source(source)
await adapter.send(str(source.chat_id), _ack, metadata=_ack_meta)
except Exception:
logger.debug("cron-recipe ack send failed", exc_info=True)
logger.debug("blueprint ack send failed", exc_info=True)
try:
event.text = _recipe_seed
event.text = _blueprint_seed
except Exception:
return getattr(_recipe_result, "text", "") or None
return getattr(_blueprint_result, "text", "") or None
else:
return getattr(_recipe_result, "text", "") or None
return getattr(_blueprint_result, "text", "") or None
if canonical == "retry":
return await self._handle_retry_command(event)
@ -9298,15 +9298,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("suggestions command failed: %s", e)
return f"Suggestions command failed: {e}"
async def _handle_cron_recipe_command(self, event: MessageEvent):
"""Handle /cron-recipe in the gateway.
async def _handle_blueprint_command(self, event: MessageEvent):
"""Handle /blueprint in the gateway.
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
seed and falls through to the agent (the ``/steer`` pattern) so 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()
source = event.source
@ -9324,14 +9324,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
origin = None
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:
logger.debug("cron-recipe command failed: %s", e)
from hermes_cli.cron_recipe_cmd import RecipeCommandResult
logger.debug("blueprint command failed: %s", e)
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)

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
calls ``fill_recipe`` -> ``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
needs pick a recipe 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
calls ``fill_blueprint`` -> ``create_job`` directly. Where a surface is just a
chat line, the user picks a blueprint by name and the agent asks for what it
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 blueprint it asks you
a couple things done).
Subcommand shapes:
/cron-recipe list the catalog
/cron-recipe <name> name-match a recipe, then SEED THE AGENT to
/blueprint list the catalog
/blueprint <name> name-match a blueprint, then SEED THE AGENT to
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-
user shortcut no agent turn)
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
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
(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
@ -41,12 +41,12 @@ logger = logging.getLogger(__name__)
@dataclass
class RecipeCommandResult:
"""Outcome of a ``/cron-recipe`` invocation.
class BlueprintCommandResult:
"""Outcome of a ``/blueprint`` invocation.
``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
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
(catalog listing, direct create, or an error) and nothing is sent to the
agent.
@ -91,11 +91,11 @@ def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
return values, leftovers
def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed recipe name to a recipe.
def match_blueprint(query: str) -> Tuple[Optional[Any], List[Any]]:
"""Resolve a free-typed blueprint name to a blueprint.
Returns ``(recipe, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(recipe, [])``
Returns ``(blueprint, candidates)``:
* exact key or unique prefix / fuzzy match -> ``(blueprint, [])``
* ambiguous (2+ plausible) -> ``(None, [candidates])``
* 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
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()
if not q:
return None, []
exact = get_recipe(q)
exact = get_blueprint(q)
if exact is not None:
return exact, []
@ -138,43 +138,43 @@ def match_recipe(query: str) -> Tuple[Optional[Any], List[Any]]:
keys = [r.key for r in CATALOG]
close = difflib.get_close_matches(q, keys, n=3, cutoff=0.6)
if len(close) == 1:
return get_recipe(close[0]), []
return get_blueprint(close[0]), []
if len(close) > 1:
return None, [get_recipe(k) for k in close]
return None, [get_blueprint(k) for k in close]
return None, []
def _humanize_schedule(recipe) -> str:
from cron.recipe_catalog import _humanize_schedule as _h
def _humanize_schedule(blueprint) -> str:
from cron.blueprint_catalog import _humanize_schedule as _h
try:
return _h(recipe)
return _h(blueprint)
except Exception:
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.
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
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.
"""
from cron.recipe_catalog import WEEKDAY_PRESETS
from cron.blueprint_catalog import WEEKDAY_PRESETS
lines: List[str] = []
lines.append(
f"Set up the '{recipe.title}' automation for me (cron recipe "
f"'{recipe.key}'). {recipe.description}"
f"Set up the '{blueprint.title}' automation for me (automation blueprint "
f"'{blueprint.key}'). {blueprint.description}"
)
lines.append("")
lines.append(
"Ask me for each of these, one at a time, offering the default in "
"brackets if I don't have a preference:"
)
for s in recipe.slots:
for s in blueprint.slots:
bits = [f"- {s.label} ({s.name})"]
if s.options:
bits.append(f" — one of: {', '.join(map(str, s.options))}")
@ -190,47 +190,47 @@ def build_recipe_seed(recipe) -> str:
lines.append(
"Once you have my answers, create the job by calling the cronjob tool "
"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 "
f"choice using {dict(WEEKDAY_PRESETS)}, {{interval_min}} from any "
"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."
)
return "\n".join(lines)
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:
lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}")
lines.append(
"\nTip: `/cron-recipe <name>` walks you through it. Power users can "
"pass values inline, e.g. `/cron-recipe morning-brief time=08:00`."
"\nTip: `/blueprint <name>` walks you through it. Power users can "
"pass values inline, e.g. `/blueprint morning-brief time=08:00`."
)
return "\n".join(lines)
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:
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)
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]
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:
msg += " Did you mean: " + ", ".join(close) + "?"
msg += " Run /cron-recipe to see the catalog."
msg += " Run /blueprint to see the catalog."
return msg
@ -243,28 +243,28 @@ def _manage_hint(surface: str) -> str:
return "Ask me to list, pause, or remove it any time."
def handle_cron_recipe_command(
def handle_blueprint_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
surface: str = "cli",
) -> RecipeCommandResult:
"""Dispatch a ``/cron-recipe`` invocation.
) -> BlueprintCommandResult:
"""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
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``
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints
``/cron`` only exists on the CLI.
"""
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
logger.debug("recipe catalog import failed: %s", e)
return RecipeCommandResult("Cron Recipes are unavailable in this build.")
logger.debug("blueprint catalog import failed: %s", e)
return BlueprintCommandResult("Automation Blueprints are unavailable in this build.")
try:
tokens = shlex.split(args or "")
@ -273,33 +273,33 @@ def handle_cron_recipe_command(
# Bare -> list catalog.
if not tokens:
return RecipeCommandResult(_fmt_catalog())
return BlueprintCommandResult(_fmt_catalog())
query = tokens[0]
values, _leftover = _parse_kv(tokens[1:])
recipe, candidates = match_recipe(query)
if recipe is None:
blueprint, candidates = match_blueprint(query)
if blueprint is None:
if candidates:
return RecipeCommandResult(_fmt_candidates(query, candidates))
return RecipeCommandResult(_fmt_no_match(query))
return BlueprintCommandResult(_fmt_candidates(query, candidates))
return BlueprintCommandResult(_fmt_no_match(query))
# `<name>` with no inline slot values -> seed the agent to ask for them.
if not values:
seed = build_recipe_seed(recipe)
seed = build_blueprint_seed(blueprint)
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…"
)
return RecipeCommandResult(text, agent_seed=seed)
return BlueprintCommandResult(text, agent_seed=seed)
# `<name> slot=val …` -> fill + create directly (deterministic shortcut).
try:
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
except RecipeFillError as e:
return RecipeCommandResult(
f"Can't set up '{recipe.title}': {e}\n"
f"Or just run /cron-recipe {recipe.key} and I'll ask you for the values."
spec = fill_blueprint(blueprint, values, origin=_resolve_origin(origin))
except BlueprintFillError as e:
return BlueprintCommandResult(
f"Can't set up '{blueprint.title}': {e}\n"
f"Or just run /blueprint {blueprint.key} and I'll ask you for the values."
)
try:
@ -307,12 +307,12 @@ def handle_cron_recipe_command(
job = create_job(**spec)
except Exception as e:
logger.debug("cron-recipe create_job failed: %s", e)
return RecipeCommandResult(f"Failed to create the job: {e}")
logger.debug("blueprint create_job failed: %s", e)
return BlueprintCommandResult(f"Failed to create the job: {e}")
sched = job.get("schedule_display") or spec.get("schedule", "")
return RecipeCommandResult(
f"Scheduled '{recipe.title}'"
return BlueprintCommandResult(
f"Scheduled '{blueprint.title}'"
+ (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
)

View file

@ -1276,13 +1276,13 @@ class CLICommandsMixin:
output = f"Suggestions command failed: {e}"
self._console_print(output)
def _handle_cron_recipe_command(self, cmd: str):
"""Handle /cron-recipe — set up an automation from a recipe template.
def _handle_blueprint_command(self, cmd: str):
"""Handle /blueprint — set up an automation from a blueprint template.
Delegates to the shared handler. A bare ``/cron-recipe`` lists the
catalog; ``/cron-recipe <name>`` name-matches a recipe and seeds the
Delegates to the shared handler. A bare ``/blueprint`` lists the
catalog; ``/blueprint <name>`` name-matches a blueprint and seeds the
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
message the interactive loop runs as the next agent turn.
"""
@ -1294,10 +1294,10 @@ class CLICommandsMixin:
tokens = (cmd or "").split()[1:]
args = " ".join(shlex.quote(t) for t in tokens)
try:
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
result = handle_cron_recipe_command(args)
from hermes_cli.blueprint_cmd import handle_blueprint_command
result = handle_blueprint_command(args)
except Exception as e:
self._console_print(f"Cron recipe command failed: {e}")
self._console_print(f"Cron blueprint command failed: {e}")
return
self._console_print(result.text)
seed = getattr(result, "agent_seed", None)

View file

@ -182,8 +182,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("suggestions", "Review suggested automations (accept/dismiss)",
"Tools & Skills", aliases=("suggest",), args_hint="[accept|dismiss N | catalog]",
subcommands=("accept", "dismiss", "catalog", "clear")),
CommandDef("cron-recipe", "Set up an automation from a recipe template",
"Tools & Skills", aliases=("recipe",), args_hint="[name] [slot=value ...]"),
CommandDef("blueprint", "Set up an automation from a blueprint template",
"Tools & Skills", aliases=("bp",), args_hint="[name] [slot=value ...]"),
CommandDef("curator", "Background skill maintenance (status, run, pin, archive, list-archived)",
"Tools & Skills", args_hint="[subcommand]",
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"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
# Recipe detection: if the installed skill declares a
# metadata.hermes.recipe block, it is a runnable automation. Register it as
# Blueprint detection: if the installed skill declares a
# metadata.hermes.blueprint block, it is a runnable automation. Register it as
# a Suggested Cron Job rather than auto-scheduling — installing never
# silently creates a recurring job; the user accepts it via /suggestions.
# This is the single surface every automation proposal flows through.
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:
spec = recipe_spec_for_installed(bundle.name)
except RecipeError as _rec_err:
c.print(f"[yellow]Recipe block present but invalid:[/] {_rec_err}\n")
spec = blueprint_spec_for_installed(bundle.name)
except BlueprintError as _rec_err:
c.print(f"[yellow]Blueprint block present but invalid:[/] {_rec_err}\n")
spec = None
if spec is not None:
registered = register_recipe_suggestion(spec)
registered = register_blueprint_suggestion(spec)
if registered is not None:
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}[/])."
)
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 —
# the user can still schedule it by hand.
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 "
"your suggestions (already offered/dismissed, or the pending "
"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 "
"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
if invalidate_cache:

View file

@ -25,7 +25,7 @@ def _fmt_pending(pending: list) -> str:
return (
"No suggested automations right now.\n"
"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"]
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
# 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):
recipe: str # recipe key, e.g. "morning-brief"
class AutomationBlueprintInstantiate(BaseModel):
blueprint: str # blueprint key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form
@app.get("/api/cron/recipes")
async def list_cron_recipes():
"""Return the recipe catalog as form schemas for the dashboard gallery.
@app.get("/api/cron/blueprints")
async def list_cron_blueprints():
"""Return the blueprint catalog as form schemas for the dashboard gallery.
The ``deliver`` slot's options are rewritten from the user's actually
configured gateway platforms (plus the universal origin/local/all), so the
form never offers a platform that isn't connected.
"""
try:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
deliver_options = None
try:
@ -6810,40 +6810,40 @@ async def list_cron_recipes():
entries = []
for r in CATALOG:
entry = recipe_catalog_entry(r)
entry = blueprint_catalog_entry(r)
if deliver_options:
for f in entry.get("fields", []):
if f.get("name") == "deliver":
f["options"] = deliver_options
entries.append(entry)
return {"recipes": entries}
return {"blueprints": entries}
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))
@app.post("/api/cron/recipes/instantiate")
async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"):
"""Fill a recipe's slots and create the cron job (form-submit path)."""
@app.post("/api/cron/blueprints/instantiate")
async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
"""Fill a blueprint's slots and create the cron job (form-submit path)."""
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)
if recipe is None:
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}")
blueprint = get_blueprint(body.blueprint)
if blueprint is None:
raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
try:
spec = fill_recipe(recipe, body.values)
except RecipeFillError as exc:
spec = fill_blueprint(blueprint, body.values)
except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
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"].
spec.pop("origin", None)
return _call_cron_for_profile(profile, "create_job", **spec)
except HTTPException:
raise
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))

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

View file

@ -1,7 +1,7 @@
"""Tests for the Suggested Cron Jobs feature.
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.
"""
@ -136,23 +136,23 @@ class TestCatalog:
assert Path(classify_items_script_path()).name == "classify_items.py"
class TestRecipeBridge:
def test_recipe_registers_suggestion(self, store):
from tools.recipes import RecipeSpec, register_recipe_suggestion
class TestBlueprintBridge:
def test_blueprint_registers_suggestion(self, store):
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):
rec = register_recipe_suggestion(spec)
rec = register_blueprint_suggestion(spec)
assert rec is not None
assert rec["source"] == "recipe"
assert rec["source"] == "blueprint"
assert rec["job_spec"]["skills"] == ["morning-brief"]
assert rec["job_spec"]["schedule"] == "0 8 * * *"
def test_recipe_to_job_spec_matches_create_recipe_job(self):
from tools.recipes import RecipeSpec, recipe_to_job_spec
def test_blueprint_to_job_spec_matches_create_blueprint_job(self):
from tools.blueprints import BlueprintSpec, blueprint_to_job_spec
spec = RecipeSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p")
js = recipe_to_job_spec(spec)
spec = BlueprintSpec(skill_name="x", schedule="every 2h", deliver="origin", prompt="p")
js = blueprint_to_job_spec(spec)
assert js["skills"] == ["x"]
assert js["schedule"] == "every 2h"
assert js["prompt"] == "p"

View file

@ -2360,39 +2360,39 @@ class TestNewEndpoints:
resp = self.client.get("/api/cron/jobs/nonexistent-id")
assert resp.status_code == 404
# --- Cron Recipes ---
# --- Automation Blueprints ---
def test_cron_recipes_list(self):
resp = self.client.get("/api/cron/recipes")
def test_cron_blueprints_list(self):
resp = self.client.get("/api/cron/blueprints")
assert resp.status_code == 200
recipes = resp.json()["recipes"]
assert len(recipes) >= 1
first = recipes[0]
blueprints = resp.json()["blueprints"]
assert len(blueprints) >= 1
first = blueprints[0]
assert "fields" in first
assert first["command"].startswith("/cron-recipe")
assert first["command"].startswith("/blueprint")
assert first["appUrl"].startswith("hermes://")
def test_cron_recipe_instantiate_creates_job(self):
def test_blueprint_instantiate_creates_job(self):
resp = self.client.post(
"/api/cron/recipes/instantiate",
json={"recipe": "morning-brief", "values": {"time": "07:30", "deliver": "local"}},
"/api/cron/blueprints/instantiate",
json={"blueprint": "morning-brief", "values": {"time": "07:30", "deliver": "local"}},
)
assert resp.status_code == 200
job = resp.json()
assert (job.get("schedule_display") or "").strip() == "30 7 * * *" or \
(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(
"/api/cron/recipes/instantiate",
json={"recipe": "does-not-exist", "values": {}},
"/api/cron/blueprints/instantiate",
json={"blueprint": "does-not-exist", "values": {}},
)
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(
"/api/cron/recipes/instantiate",
json={"recipe": "morning-brief", "values": {"time": "99:99"}},
"/api/cron/blueprints/instantiate",
json={"blueprint": "morning-brief", "values": {"time": "99:99"}},
)
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
cron store.
"""
@ -11,24 +11,24 @@ from unittest.mock import patch
import pytest
from tools.recipes import (
RecipeError,
RecipeSpec,
create_recipe_job,
export_recipe,
parse_recipe,
recipe_spec_for_installed,
from tools.blueprints import (
BlueprintError,
BlueprintSpec,
create_blueprint_job,
export_blueprint,
parse_blueprint,
blueprint_spec_for_installed,
)
RECIPE_SKILL = """---
BLUEPRINT_SKILL = """---
name: morning-brief
description: Summarize unread email and calendar every morning.
version: 1.0.0
metadata:
hermes:
tags: [recipe, email]
recipe:
tags: [blueprint, email]
blueprint:
schedule: "0 8 * * *"
deliver: telegram
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 = """---
name: not-a-recipe
name: not-a-blueprint
description: Just a regular skill.
metadata:
hermes:
tags: [misc]
---
# Not a recipe
# Not a blueprint
"""
MALFORMED_RECIPE = """---
MALFORMED_BLUEPRINT = """---
name: broken
description: Recipe with no schedule.
description: Blueprint with no schedule.
metadata:
hermes:
recipe:
blueprint:
deliver: origin
---
@ -63,49 +63,49 @@ metadata:
"""
class TestParseRecipe:
def test_parses_full_recipe(self):
spec = parse_recipe(RECIPE_SKILL)
class TestParseBlueprint:
def test_parses_full_blueprint(self):
spec = parse_blueprint(BLUEPRINT_SKILL)
assert spec is not None
assert spec.skill_name == "morning-brief"
assert spec.schedule == "0 8 * * *"
assert spec.deliver == "telegram"
assert spec.prompt is not None and spec.prompt.startswith("Summarize")
def test_plain_skill_is_not_a_recipe(self):
assert parse_recipe(PLAIN_SKILL) is None
def test_plain_skill_is_not_a_blueprint(self):
assert parse_blueprint(PLAIN_SKILL) is None
def test_no_frontmatter_is_not_a_recipe(self):
assert parse_recipe("just some text, no frontmatter") is None
def test_no_frontmatter_is_not_a_blueprint(self):
assert parse_blueprint("just some text, no frontmatter") is None
def test_missing_schedule_raises(self):
with pytest.raises(RecipeError):
parse_recipe(MALFORMED_RECIPE)
with pytest.raises(BlueprintError):
parse_blueprint(MALFORMED_BLUEPRINT)
def test_recipe_not_mapping_raises(self):
bad = "---\nname: x\nmetadata:\n hermes:\n recipe: not-a-dict\n---\n\nbody"
with pytest.raises(RecipeError):
parse_recipe(bad)
def test_blueprint_not_mapping_raises(self):
bad = "---\nname: x\nmetadata:\n hermes:\n blueprint: not-a-dict\n---\n\nbody"
with pytest.raises(BlueprintError):
parse_blueprint(bad)
def test_deliver_defaults_to_origin(self):
skill = (
"---\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.deliver == "origin"
class TestRecipeSpecForInstalled:
def test_finds_and_parses_installed_recipe(self, tmp_path):
class TestBlueprintSpecForInstalled:
def test_finds_and_parses_installed_blueprint(self, tmp_path):
skills_dir = tmp_path / "skills"
rec_dir = skills_dir / "productivity" / "morning-brief"
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):
spec = recipe_spec_for_installed("morning-brief")
spec = blueprint_spec_for_installed("morning-brief")
assert spec is not None
assert spec.schedule == "0 8 * * *"
@ -113,20 +113,20 @@ class TestRecipeSpecForInstalled:
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
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):
skills_dir = tmp_path / "skills"
d = skills_dir / "misc" / "not-a-recipe"
d = skills_dir / "misc" / "not-a-blueprint"
d.mkdir(parents=True)
(d / "SKILL.md").write_text(PLAIN_SKILL, encoding="utf-8")
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):
spec = parse_recipe(RECIPE_SKILL)
spec = parse_blueprint(BLUEPRINT_SKILL)
assert spec is not None
captured = {}
@ -135,7 +135,7 @@ class TestCreateRecipeJob:
return {"id": "abc123", **kwargs}
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["skills"] == ["morning-brief"]
@ -144,7 +144,7 @@ class TestCreateRecipeJob:
assert job["id"] == "abc123"
class TestExportRecipe:
class TestExportBlueprint:
def test_round_trips_job_to_skill_md(self):
job = {
"name": "My Morning Brief",
@ -153,19 +153,19 @@ class TestExportRecipe:
"deliver": "telegram",
"prompt": "Summarize my unread email.",
}
md = export_recipe(job, "# Morning Brief\n\nDoes the morning digest.")
# The exported SKILL.md must itself parse back as a recipe.
spec = parse_recipe(md)
md = export_blueprint(job, "# Morning Brief\n\nDoes the morning digest.")
# The exported SKILL.md must itself parse back as a blueprint.
spec = parse_blueprint(md)
assert spec is not None
assert spec.schedule == "0 8 * * *"
assert spec.deliver == "telegram"
# Name is sanitized to a valid skill identifier.
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"]}
md = export_recipe(job, "body")
assert "recipe" in md
md = export_blueprint(job, "body")
assert "blueprint" in md
assert "automation" in md
def test_export_interval_job_without_display(self):
@ -177,12 +177,12 @@ class TestExportRecipe:
"schedule": {"kind": "interval", "minutes": 30},
"skills": ["poller"],
}
md = export_recipe(job, "body")
spec = parse_recipe(md)
md = export_blueprint(job, "body")
spec = parse_blueprint(md)
assert spec is not None
assert spec.schedule == "every 30m"
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.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
frontmatter:
metadata:
hermes:
recipe:
schedule: "0 9 * * *" # presence of `recipe:` marks it runnable
blueprint:
schedule: "0 9 * * *" # presence of `blueprint:` marks it runnable
deliver: origin # optional (default "origin")
prompt: "..." # optional task instruction for the run
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,
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
transport. This module is the thin bridge between that skill metadata and the
existing cron `create_job()` API:
* ``parse_recipe(skill_md_text)`` -> RecipeSpec | None
* ``recipe_spec_for_installed(name)`` -> RecipeSpec | None
* ``create_recipe_job(spec, ...)`` -> the created cron job dict
* ``export_recipe(job, body)`` -> a shareable SKILL.md string
* ``parse_blueprint(skill_md_text)`` -> BlueprintSpec | None
* ``blueprint_spec_for_installed(name)`` -> BlueprintSpec | None
* ``create_blueprint_job(spec, ...)`` -> the created cron job dict
* ``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/
index path.
"""
@ -39,24 +39,24 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
__all__ = [
"RecipeSpec",
"parse_recipe",
"recipe_spec_for_installed",
"recipe_to_job_spec",
"create_recipe_job",
"register_recipe_suggestion",
"export_recipe",
"RecipeError",
"BlueprintSpec",
"parse_blueprint",
"blueprint_spec_for_installed",
"blueprint_to_job_spec",
"create_blueprint_job",
"register_blueprint_suggestion",
"export_blueprint",
"BlueprintError",
]
class RecipeError(ValueError):
"""Raised when a recipe block is present but malformed."""
class BlueprintError(ValueError):
"""Raised when a blueprint block is present but malformed."""
@dataclass
class RecipeSpec:
"""Parsed ``metadata.hermes.recipe`` automation spec for a skill."""
class BlueprintSpec:
"""Parsed ``metadata.hermes.blueprint`` automation spec for a skill."""
skill_name: str
schedule: str
@ -87,16 +87,16 @@ def _split_frontmatter(text: str) -> Optional[Dict[str, Any]]:
data = yaml.safe_load(fm_text)
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 data if isinstance(data, dict) else None
def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
"""Extract a RecipeSpec from a SKILL.md string, or None if not a recipe.
def parse_blueprint(skill_md_text: str) -> Optional[BlueprintSpec]:
"""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 non-empty ``schedule``. Raises RecipeError if the block exists but is
A skill is a blueprint iff ``metadata.hermes.blueprint`` is a mapping containing
a non-empty ``schedule``. Raises BlueprintError if the block exists but is
structurally invalid (so a typo surfaces instead of silently no-op'ing).
"""
fm = _split_frontmatter(skill_md_text)
@ -107,28 +107,28 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
meta = fm.get("metadata")
hermes = meta.get("hermes") if isinstance(meta, dict) else None
recipe = hermes.get("recipe") if isinstance(hermes, dict) else None
if recipe is None:
blueprint = hermes.get("blueprint") if isinstance(hermes, dict) else None
if blueprint is None:
return None
if not isinstance(recipe, dict):
raise RecipeError("metadata.hermes.recipe must be a mapping")
if not isinstance(blueprint, dict):
raise BlueprintError("metadata.hermes.blueprint must be a mapping")
schedule = str(recipe.get("schedule", "")).strip()
schedule = str(blueprint.get("schedule", "")).strip()
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"
prompt = recipe.get("prompt")
deliver = str(blueprint.get("deliver", "origin")).strip() or "origin"
prompt = blueprint.get("prompt")
if prompt is not None:
prompt = str(prompt)
no_agent = bool(recipe.get("no_agent", False))
model = recipe.get("model")
provider = recipe.get("provider")
toolsets = recipe.get("enabled_toolsets")
no_agent = bool(blueprint.get("no_agent", False))
model = blueprint.get("model")
provider = blueprint.get("provider")
toolsets = blueprint.get("enabled_toolsets")
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,
schedule=schedule,
deliver=deliver,
@ -137,15 +137,15 @@ def parse_recipe(skill_md_text: str) -> Optional[RecipeSpec]:
model=str(model).strip() if model else None,
provider=str(provider).strip() if provider 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]:
"""Locate an installed skill's SKILL.md and parse its recipe block.
def blueprint_spec_for_installed(skill_name: str) -> Optional[BlueprintSpec]:
"""Locate an installed skill's SKILL.md and parse its blueprint block.
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:
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")
except OSError:
continue
spec = parse_recipe(text)
spec = parse_blueprint(text)
if spec is not None:
# Prefer the frontmatter name, fall back to the directory name.
if not spec.skill_name:
@ -169,22 +169,22 @@ def recipe_spec_for_installed(skill_name: str) -> Optional[RecipeSpec]:
return None
def recipe_to_job_spec(
spec: RecipeSpec,
def blueprint_to_job_spec(
spec: BlueprintSpec,
*,
name: Optional[str] = None,
) -> 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.
Both the direct ``create_recipe_job`` path and the suggestion path
(``register_recipe_suggestion``) build on it, so a recipe scheduled now and
a recipe accepted from a suggestion produce an identical job.
This is the single source of truth for translating a blueprint into a job.
Both the direct ``create_blueprint_job`` path and the suggestion path
(``register_blueprint_suggestion``) build on it, so a blueprint scheduled now and
a blueprint accepted from a suggestion produce an identical job.
"""
return {
"prompt": spec.prompt,
"schedule": spec.schedule,
"name": name or f"recipe:{spec.skill_name}",
"name": name or f"blueprint:{spec.skill_name}",
"deliver": spec.deliver,
"skills": [spec.skill_name] if spec.skill_name else None,
"model": spec.model,
@ -194,31 +194,31 @@ def recipe_to_job_spec(
}
def create_recipe_job(
spec: RecipeSpec,
def create_blueprint_job(
spec: BlueprintSpec,
*,
origin: Optional[Dict[str, Any]] = None,
name: Optional[str] = None,
) -> 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
toolsets carry through. Returns the created job dict.
"""
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:
job_spec["origin"] = origin
return create_job(**job_spec)
def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]:
"""Turn an installed recipe into a pending Suggested Cron Job.
def register_blueprint_suggestion(spec: BlueprintSpec) -> Optional[Dict[str, Any]]:
"""Turn an installed blueprint into a pending Suggested Cron Job.
Recipes are source ``recipe`` of the unified suggestion surface: installing
a skill that carries a ``recipe:`` block does NOT auto-schedule it it
Blueprints are source ``blueprint`` of the unified suggestion surface: installing
a skill that carries a ``blueprint:`` block does NOT auto-schedule it it
registers a suggestion the user accepts (or dismisses) like any other.
Returns the suggestion record, or None if it was skipped (already
seen/dismissed, backlog full, etc.).
@ -233,53 +233,53 @@ def register_recipe_suggestion(spec: RecipeSpec) -> Optional[Dict[str, Any]]:
return add_suggestion(
title=f"Schedule '{spec.skill_name}'",
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 "")
+ "."
),
source="recipe",
job_spec=recipe_to_job_spec(spec),
dedup_key=f"recipe:{spec.skill_name}:{spec.schedule}",
source="blueprint",
job_spec=blueprint_to_job_spec(spec),
dedup_key=f"blueprint:{spec.skill_name}:{spec.schedule}",
)
def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str] = None) -> str:
"""Render a shareable recipe SKILL.md from an existing cron job dict.
def export_blueprint(job: Dict[str, Any], body: str, *, blueprint_name: Optional[str] = None) -> str:
"""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
and emit a SKILL.md (with a ``metadata.hermes.recipe`` block) they can hand
The inverse of ``create_blueprint_job``: take a cron job a user already built
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
description / instructions that become the SKILL.md body.
"""
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.
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"))
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")
if deliver and deliver != "origin":
recipe_block["deliver"] = deliver
blueprint_block["deliver"] = deliver
if job.get("prompt"):
recipe_block["prompt"] = job["prompt"]
blueprint_block["prompt"] = job["prompt"]
if job.get("no_agent"):
recipe_block["no_agent"] = True
blueprint_block["no_agent"] = True
if job.get("model"):
recipe_block["model"] = job["model"]
blueprint_block["model"] = job["model"]
if job.get("provider"):
recipe_block["provider"] = job["provider"]
blueprint_block["provider"] = job["provider"]
if job.get("enabled_toolsets"):
recipe_block["enabled_toolsets"] = job["enabled_toolsets"]
blueprint_block["enabled_toolsets"] = job["enabled_toolsets"]
description = (
(body.strip().splitlines() or ["Shared automation recipe."])[0][:200]
(body.strip().splitlines() or ["Shared automation blueprint."])[0][:200]
if body.strip()
else "Shared automation recipe."
else "Shared automation blueprint."
)
frontmatter = {
@ -289,13 +289,13 @@ def export_recipe(job: Dict[str, Any], body: str, *, recipe_name: Optional[str]
"license": "MIT",
"metadata": {
"hermes": {
"tags": ["recipe", "automation"],
"recipe": recipe_block,
"tags": ["blueprint", "automation"],
"blueprint": blueprint_block,
}
},
}
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"

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 { Toast } from "@nous-research/ui/ui/components/toast";
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";
interface CronRecipesProps {
interface AutomationBlueprintsProps {
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;
}
/** Initial form values for a recipe = each field's default (or ""). */
function initialValues(recipe: CronRecipe): Record<string, string> {
/** Initial form values for a blueprint = each field's default (or ""). */
function initialValues(blueprint: AutomationBlueprint): 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;
}
@ -31,7 +31,7 @@ function FieldInput({
value,
onChange,
}: {
field: CronRecipeField;
field: AutomationBlueprintField;
value: string;
onChange: (v: string) => void;
}) {
@ -66,19 +66,19 @@ function FieldInput({
);
}
function RecipeCard({
recipe,
function BlueprintCard({
blueprint,
profile,
showToast,
onCreated,
}: {
recipe: CronRecipe;
blueprint: AutomationBlueprint;
profile: string;
showToast: (message: string, type: "error" | "success") => void;
onCreated?: () => void;
}) {
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 [error, setError] = useState<string | null>(null);
@ -86,11 +86,11 @@ function RecipeCard({
setSubmitting(true);
setError(null);
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}` : "";
showToast(`${recipe.title} scheduled${when}`, "success");
showToast(`${blueprint.title} scheduled${when}`, "success");
setOpen(false);
setValues(initialValues(recipe));
setValues(initialValues(blueprint));
onCreated?.();
} catch (e) {
// 422 from the API carries the slot-level validation message.
@ -99,7 +99,7 @@ function RecipeCard({
} finally {
setSubmitting(false);
}
}, [recipe, values, profile, showToast, onCreated]);
}, [blueprint, values, profile, showToast, onCreated]);
return (
<Card className={cn("overflow-hidden", themedBody)}>
@ -108,11 +108,11 @@ function RecipeCard({
<div className="min-w-0">
<div className="flex items-center gap-2">
<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>
<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">
{recipe.tags.map((t) => (
{blueprint.tags.map((t) => (
<Badge key={t} tone="secondary">
{t}
</Badge>
@ -130,9 +130,9 @@ function RecipeCard({
{open && (
<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">
<Label htmlFor={`${recipe.key}-${f.name}`}>{f.label}</Label>
<Label htmlFor={`${blueprint.key}-${f.name}`}>{f.label}</Label>
<FieldInput
field={f}
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
* 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.
*/
export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
export function AutomationBlueprints({ profile, onCreated }: AutomationBlueprintsProps) {
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);
useEffect(() => {
let cancelled = false;
api
.getCronRecipes()
.getAutomationBlueprints()
.then((r) => {
if (!cancelled) setRecipes(r.recipes);
if (!cancelled) setBlueprints(r.blueprints);
})
.catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
@ -188,27 +188,27 @@ export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
}, []);
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 (
<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>
);
}
if (recipes.length === 0) {
return <p className="opacity-70">No cron recipes available.</p>;
if (blueprints.length === 0) {
return <p className="opacity-70">No automation blueprints available.</p>;
}
return (
<>
<Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{recipes.map((r) => (
<RecipeCard
{blueprints.map((r) => (
<BlueprintCard
key={r.key}
recipe={r}
blueprint={r}
profile={profile}
showToast={showToast}
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") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Cron Recipes — parameterized automation templates
getCronRecipes: () =>
fetchJSON<{ recipes: CronRecipe[] }>("/api/cron/recipes"),
instantiateCronRecipe: (
body: { recipe: string; values: Record<string, string> },
// Automation Blueprints — parameterized automation templates
getAutomationBlueprints: () =>
fetchJSON<{ blueprints: AutomationBlueprint[] }>("/api/cron/blueprints"),
instantiateAutomationBlueprint: (
body: { blueprint: string; values: Record<string, string> },
profile = "default",
) =>
fetchJSON<CronJob>(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, {
fetchJSON<CronJob>(`/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@ -1838,7 +1838,7 @@ export interface CronDeliveryTarget {
home_env_var: string | null;
}
export interface CronRecipeField {
export interface AutomationBlueprintField {
name: string;
type: "time" | "enum" | "text" | "weekdays";
label: string;
@ -1850,13 +1850,13 @@ export interface CronRecipeField {
help: string;
}
export interface CronRecipe {
export interface AutomationBlueprint {
key: string;
title: string;
description: string;
category: string;
tags: string[];
fields: CronRecipeField[];
fields: AutomationBlueprintField[];
command: string;
appUrl: string;
}

View file

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

View file

@ -66,7 +66,7 @@ metadata:
description: "What this setting controls"
default: "sensible-default"
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
deliver: origin # optional (default origin)
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`.
## 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
metadata:
hermes:
tags: [recipe, email]
recipe:
schedule: "0 8 * * *" # presence of `recipe:` marks it runnable
tags: [blueprint, email]
blueprint:
schedule: "0 8 * * *" # presence of `blueprint:` marks it runnable
deliver: telegram # optional (default: origin)
prompt: "Summarize my unread email and today's calendar." # 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
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.
# then, in a session:
@ -369,11 +369,11 @@ hermes skills install owner/morning-brief
/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
@ -382,7 +382,7 @@ Hermes can *propose* automations and let you accept them with one tap, instead o
| Source | Trigger |
|--------|---------|
| `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 |
| `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
"""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
definitions from ``cron/recipe_catalog.py`` and emits a flat JSON array the
Mirrors ``extract-skills.py``: imports the single-source-of-truth blueprint
definitions from ``cron/blueprint_catalog.py`` and emits a flat JSON array the
docs page renders into cards (description, schedule, copy-paste slash command,
and a ``hermes://`` "Send to App" deep-link).
Output: ``website/static/api/cron-recipes-index.json`` (served at
``/docs/api/cron-recipes-index.json``). Run automatically by
Output: ``website/static/api/automation-blueprints-index.json`` (served at
``/docs/api/automation-blueprints-index.json``). Run automatically by
``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]
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:
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:
@ -36,13 +36,13 @@ def main() -> int:
except Exception as e: # pragma: no cover - import/build failure
# Match extract-skills.py's resilience: write an empty array so the
# 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 = []
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT, "w", encoding="utf-8") as f:
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

View file

@ -31,7 +31,7 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
const websiteDir = resolve(scriptDir, "..");
const extractScript = join(scriptDir, "extract-skills.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 unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
@ -140,6 +140,6 @@ if (!existsSync(extractScript)) {
// 2) llms.txt + llms-full.txt — agent-friendly docs entrypoints. Non-fatal.
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.
runPython(cronRecipesScript, "extract-cron-recipes.py");
runPython(cronBlueprintsScript, "extract-automation-blueprints.py");

View file

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

View file

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