feat(cron): Cron Recipes — parameterized automation templates across every surface

A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.

Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
  recipe's typed slots as a form (time-picker, enum dropdown, free-text);
  submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
  fields, or fills + creates from a pasted 'key slot=val' command. The shared
  handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
  the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
  shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
  setAsDefaultProtocolClient + open-url/second-instance) routes
  hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.

Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.

Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
  recipe_form_schema / recipe_slash_command / recipe_deeplink /
  recipe_catalog_entry renderers, fill_recipe (validate + translate to
  create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
  gateway never drift). CommandDef + dispatch in commands.py / cli.py /
  gateway/run.py.

Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.

Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).

Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.

Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
This commit is contained in:
teknium1 2026-06-07 18:49:09 -07:00 committed by Teknium
parent 9a09ea69fb
commit 1593ca5406
25 changed files with 1975 additions and 0 deletions

3
.gitignore vendored
View file

@ -89,6 +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
models-dev-upstream/
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)

View file

@ -6111,6 +6111,111 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla
// Search the Marketplace for color-theme extensions (empty query = top installs).
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).
// 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.
// ---------------------------------------------------------------------------
const HERMES_PROTOCOL = 'hermes'
let _pendingDeepLink = null
let _rendererReadyForDeepLink = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) return null
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
}
function handleDeepLink(url) {
if (!url || typeof url !== 'string') return
let parsed
try {
parsed = new URL(url)
} catch {
rememberLog(`[deeplink] ignoring malformed url: ${url}`)
return
}
// hermes://cron-recipe/<key>?slot=val -> host="cron-recipe", path="/<key>"
const kind = parsed.hostname || ''
const name = decodeURIComponent((parsed.pathname || '').replace(/^\//, ''))
const params = {}
parsed.searchParams.forEach((v, k) => {
params[k] = v
})
const payload = { kind, name, params }
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
_pendingDeepLink = payload
return
}
try {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
mainWindow.webContents.send('hermes:deep-link', payload)
rememberLog(`[deeplink] delivered ${kind}/${name}`)
} catch (err) {
rememberLog(`[deeplink] delivery failed: ${err.message}`)
}
}
// Renderer calls this (via IPC) once it has mounted its deep-link listener, so
// a link that arrived during boot/install is flushed exactly once.
ipcMain.handle('hermes:deep-link-ready', () => {
_rendererReadyForDeepLink = true
if (_pendingDeepLink) {
const queued = _pendingDeepLink
_pendingDeepLink = null
handleDeepLink(
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
(Object.keys(queued.params).length
? '?' + new URLSearchParams(queued.params).toString()
: ''),
)
}
return { ok: true }
})
function registerDeepLinkProtocol() {
try {
if (process.defaultApp && process.argv.length >= 2) {
// Dev: register with the electron exec path + entry script so the OS can
// relaunch us with the URL.
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
])
} else {
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
}
} catch (err) {
rememberLog(`[deeplink] protocol registration failed: ${err.message}`)
}
}
// Single-instance lock: deep links on a running app (Win/Linux) arrive as a
// second-instance argv. Without the lock a second `hermes://` launch spawns a
// whole new app instead of routing into the running one.
const _gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!_gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', (_event, argv) => {
const url = _extractDeepLink(argv)
if (url) handleDeepLink(url)
else if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
}
// macOS delivers deep links via 'open-url' — register early (can fire before
// whenReady; handleDeepLink queues until the renderer is ready).
app.on('open-url', (event, url) => {
event.preventDefault()
handleDeepLink(url)
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
@ -6119,11 +6224,16 @@ app.whenReady().then(() => {
}
installMediaPermissions()
registerMediaProtocol()
registerDeepLinkProtocol()
ensureWslWindowsFonts()
configureSpellChecker()
registerPowerResumeListeners()
createWindow()
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
const _coldStartLink = _extractDeepLink(process.argv)
if (_coldStartLink) handleDeepLink(_coldStartLink)
app.on('activate', () => {
// Recreate the primary window if it's gone. Guard on mainWindow directly
// (not just total window count) so a dock click still restores the main

View file

@ -80,6 +80,12 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
ipcRenderer.on('hermes:open-updates', listener)
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
},
onDeepLink: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:deep-link', listener)
return () => ipcRenderer.removeListener('hermes:deep-link', listener)
},
signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'),
onWindowStateChanged: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:window-state-changed', listener)

View file

@ -132,6 +132,14 @@
"appId": "com.nousresearch.hermes",
"productName": "Hermes",
"executableName": "Hermes",
"protocols": [
{
"name": "Hermes Protocol",
"schemes": [
"hermes"
]
}
],
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
"icon": "assets/icon",
"directories": {

View file

@ -11,6 +11,7 @@ import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
@ -266,6 +267,31 @@ 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
// 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) {
return
}
const slots = Object.entries(payload.params || {})
.map(([k, v]) => {
const sval = /\s/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v
return `${k}=${sval}`
})
.join(' ')
const command = `/cron-recipe ${payload.name}${slots ? ' ' + slots : ''}`
requestComposerInsert(command, { mode: 'block', target: 'main' })
requestComposerFocus('main')
})
// Tell the main process the renderer is ready to receive deep links.
void window.hermesDesktop?.signalDeepLinkReady?.()
return () => unsubscribe?.()
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {

View file

@ -75,6 +75,10 @@ declare global {
}
onClosePreviewRequested?: (callback: () => void) => () => void
onOpenUpdatesRequested?: (callback: () => void) => () => void
onDeepLink?: (
callback: (payload: { kind: string; name: string; params: Record<string, string> }) => void,
) => () => void
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
onBackendExit: (callback: (payload: BackendExit) => void) => () => void

4
cli.py
View file

@ -7409,6 +7409,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.save_conversation()
elif canonical == "cron":
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 == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":

688
cron/recipe_catalog.py Normal file
View file

@ -0,0 +1,688 @@
"""Cron Recipes — parameterized automation templates with typed slots.
A *recipe* 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
* 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
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
in ``schedule_template`` and parameterizes only the human-friendly parts
(time-of-day, weekday set). Recipes needing full flexibility expose a ``text``
slot named ``schedule`` that passes through verbatim.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
__all__ = [
"RecipeSlot",
"CronRecipe",
"CATALOG",
"get_recipe",
"recipe_form_schema",
"recipe_slash_command",
"recipe_deeplink",
"recipe_catalog_entry",
"fill_recipe",
"RecipeFillError",
"WEEKDAY_PRESETS",
]
class RecipeFillError(ValueError):
"""Raised when supplied slot values fail validation."""
# Slot types the renderers understand.
_SLOT_TYPES = frozenset({"time", "enum", "text", "weekdays"})
# Named weekday recurrences -> cron day-of-week field.
WEEKDAY_PRESETS: Dict[str, str] = {
"everyday": "*",
"weekdays": "1-5",
"weekends": "0,6",
}
@dataclass(frozen=True)
class RecipeSlot:
"""A single fillable field on a recipe."""
name: str
type: str
label: str
default: Any = None
options: tuple = () # for type="enum": allowed values
optional: bool = False
help: str = ""
def __post_init__(self) -> None:
if self.type not in _SLOT_TYPES:
raise ValueError(f"unknown slot type {self.type!r} (slot {self.name})")
@dataclass(frozen=True)
class CronRecipe:
"""A parameterized automation template."""
key: str
title: str
description: str
category: str
# Cron expression with ``{slot}`` placeholders, e.g. "{minute} {hour} * * {dow}".
# Placeholders are filled from resolved slot values (time -> minute/hour,
# weekdays -> dow). A literal cron string with no placeholders = fixed schedule.
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)
deliver_default: str = "origin"
skills: tuple = () # skills the job loads before running
tags: tuple = ()
# ---------------------------------------------------------------------------
# Curated in-repo catalog
# ---------------------------------------------------------------------------
_TIME = lambda default="08:00": RecipeSlot( # noqa: E731 - concise factory
name="time", type="time", label="What time?", default=default,
help="24h local time, e.g. 08:00",
)
_DELIVER = RecipeSlot(
name="deliver", type="enum", label="Where to deliver?",
default="origin", options=("origin", "local", "telegram", "discord", "email"),
help="origin = the chat you set this up from; local = save only, no message",
)
CATALOG: List[CronRecipe] = [
CronRecipe(
key="morning-brief",
title="Morning briefing",
description="A short daily briefing: today's calendar, weather, and "
"anything urgent waiting on you.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Produce a concise morning briefing for the user: today's calendar "
"events, the local weather, and any urgent items. Keep it short and "
"scannable. If no data sources are connected, give a brief "
"good-morning with the date and offer to connect calendar/email."
),
slots=[_TIME("08:00"), _DELIVER],
tags=("daily", "briefing"),
),
CronRecipe(
key="important-mail",
title="Important-mail monitor",
description="Check your inbox periodically and ping you ONLY about mail "
"that actually needs attention.",
category="email",
schedule_template="*/{interval_min} * * * *",
prompt_template=(
"Check the user's inbox for new messages since the last run. Surface "
"ONLY mail matching: {criteria}. Score candidates with the urgency "
"classifier and deliver only what clears the bar; if nothing does, "
"respond with [SILENT]. Requires a connected mail source; if none is "
"configured, explain how to connect one and stop."
),
slots=[
RecipeSlot(
name="interval_min", type="enum", label="How often?",
default="30", options=("15", "30", "60"),
help="minutes between checks",
),
RecipeSlot(
name="criteria", type="text",
label="Only notify me if the mail…",
default="needs a reply today, is from my manager or family, "
"or mentions a deadline",
),
_DELIVER,
],
tags=("email", "monitor"),
),
CronRecipe(
key="weekly-review",
title="Weekly review",
description="A weekly recap: what got done, what's still open, and "
"what's coming up.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Produce a weekly review for the user: what was accomplished this "
"week, still-open items, and next week's calendar. Pull from "
"connected sources. Keep it tight."
),
slots=[
_TIME("18:00"),
RecipeSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "review"),
),
CronRecipe(
key="workday-start",
title="Workday start reminder",
description="A weekday nudge with your agenda and top priorities.",
category="daily",
schedule_template="{minute} {hour} * * 1-5",
prompt_template=(
"Give the user a brief weekday start-of-day nudge: today's calendar "
"and the 1-3 highest-priority things to focus on, inferred from "
"recent context and any task tools. Encouraging, short, one message."
),
slots=[_TIME("09:00"), _DELIVER],
tags=("daily", "focus"),
),
CronRecipe(
key="custom-reminder",
title="Custom reminder",
description="A recurring reminder in your own words, on your schedule.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template="Remind the user: {what}",
slots=[
RecipeSlot(name="what", type="text", label="Remind me to…",
default="take a break and stretch"),
_TIME("14:00"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder",),
),
CronRecipe(
key="evening-winddown",
title="Evening wind-down",
description="An end-of-day check-in: tomorrow's calendar at a glance "
"and anything you should prep tonight.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user a short evening wind-down: tomorrow's calendar, any "
"early commitments to prep for, and one gentle nudge to wrap up "
"loose ends from today. Keep it calm and brief — one message. If no "
"calendar is connected, just offer a friendly sign-off and the "
"weather for tomorrow."
),
slots=[_TIME("21:00"), _DELIVER],
tags=("daily", "evening"),
),
CronRecipe(
key="news-digest",
title="Topic news digest",
description="A recurring digest on a topic you care about — deduped "
"against what was already sent, so only genuinely new items land.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Search the web for new and noteworthy items about: {topic}. "
"Dedupe against what you sent in previous runs — only include "
"genuinely new developments. Deliver a tight digest of at most "
"{count} bullets, each one line with a link. If nothing new since "
"last run, respond with [SILENT]."
),
slots=[
RecipeSlot(
name="topic", type="text", label="What topic?",
default="AI and technology",
help="a subject, product, person, or search phrase",
),
_TIME("18:00"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
RecipeSlot(
name="count", type="enum", label="How many bullets?",
default="5", options=("3", "5", "8"),
),
_DELIVER,
],
tags=("digest", "research"),
),
CronRecipe(
key="bill-renewal-watch",
title="Bills & renewals reminder",
description="A heads-up before a recurring payment, subscription "
"renewal, or due date — so nothing auto-charges by surprise.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Remind the user about an upcoming payment or renewal: {what}. "
"Phrase it as an actionable heads-up (e.g. 'review or cancel before "
"it renews'), not just a notification. One short message."
),
slots=[
RecipeSlot(
name="what", type="text", label="What's due?",
default="my streaming subscription renews soon",
),
_TIME("10:00"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("reminder", "finance"),
),
CronRecipe(
key="habit-checkin",
title="Habit check-in",
description="A recurring nudge to keep a habit on track and reflect "
"on whether you did it.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Nudge the user about their habit: {habit}. Ask whether they did it "
"today, keep it warm and non-judgmental, and offer a one-line word "
"of encouragement. One short message."
),
slots=[
RecipeSlot(
name="habit", type="text", label="Which habit?",
default="20 minutes of reading",
),
_TIME("20:00"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("habit", "wellbeing"),
),
CronRecipe(
key="hydration-move",
title="Hydration & movement nudge",
description="A periodic nudge during the day to drink water, stand up, "
"and stretch.",
category="general",
schedule_template="*/{interval_min} {start_hour}-{end_hour} * * 1-5",
prompt_template=(
"Send the user a brief, friendly nudge to drink some water, stand "
"up, and stretch for a moment. Vary the wording each time so it "
"doesn't feel robotic. One short line."
),
slots=[
RecipeSlot(
name="interval_min", type="enum", label="How often?",
default="90", options=("60", "90", "120"),
help="minutes between nudges",
),
RecipeSlot(
name="start_hour", type="enum", label="Start hour",
default="9", options=("7", "8", "9", "10"),
help="first hour of the active window (24h)",
),
RecipeSlot(
name="end_hour", type="enum", label="End hour",
default="17", options=("16", "17", "18", "19"),
help="last hour of the active window (24h)",
),
_DELIVER,
],
tags=("wellbeing", "focus"),
),
CronRecipe(
key="meal-plan",
title="Weekly meal plan",
description="A weekly meal plan plus a consolidated grocery list, "
"tuned to your diet and how much time you have to cook.",
category="weekly",
schedule_template="{minute} {hour} * * {dow}",
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 "
"simple and skimmable."
),
slots=[
RecipeSlot(
name="diet", type="enum", label="Diet?",
default="no restrictions",
options=("no restrictions", "vegetarian", "vegan",
"high-protein", "low-carb"),
),
RecipeSlot(
name="meals", type="enum", label="Meals per day?",
default="dinner only",
options=("dinner only", "lunch and dinner", "all three"),
),
RecipeSlot(
name="effort", type="enum", label="Cooking effort?",
default="quick", options=("quick", "medium", "ambitious"),
),
_TIME("17:00"),
RecipeSlot(
name="day", type="enum", label="Which day?",
default="sunday",
options=("sunday", "monday", "friday", "saturday"),
),
_DELIVER,
],
tags=("weekly", "food"),
),
CronRecipe(
key="learn-daily",
title="Daily learning drip",
description="One bite-sized lesson a day on a topic you want to learn, "
"building progressively over time.",
category="daily",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Teach the user one bite-sized lesson about: {topic}. Build on "
"earlier lessons so it progresses rather than repeating. Keep it to "
"a couple of short paragraphs with one concrete example, and end "
"with a single question to check understanding."
),
slots=[
RecipeSlot(
name="topic", type="text", label="Learn about…",
default="Spanish vocabulary",
),
_TIME("08:30"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="weekdays",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("learning", "daily"),
),
CronRecipe(
key="gratitude-journal",
title="Gratitude & reflection prompt",
description="A gentle evening prompt to reflect on the day and note "
"what went well.",
category="general",
schedule_template="{minute} {hour} * * {dow}",
prompt_template=(
"Send the user a short, warm reflection prompt for the end of the "
"day — invite them to note one thing that went well, one thing they "
"are grateful for, and one small win. If they reply, acknowledge it "
"kindly. One message."
),
slots=[
_TIME("21:30"),
RecipeSlot(
name="recurrence", type="weekdays", label="Repeat on",
default="everyday",
options=tuple(WEEKDAY_PRESETS.keys()),
),
_DELIVER,
],
tags=("wellbeing", "reflection"),
),
CronRecipe(
key="on-this-day",
title="On-this-day discovery",
description="A daily dose of curiosity: a notable historical event, "
"fact, or word for the day.",
category="daily",
schedule_template="{minute} {hour} * * *",
prompt_template=(
"Give the user one interesting '{flavor}' item for today — keep it "
"short, surprising, and genuinely interesting. One or two sentences, "
"no filler."
),
slots=[
RecipeSlot(
name="flavor", type="enum", label="What kind?",
default="on this day in history",
options=("on this day in history", "word of the day",
"science fact", "quote of the day"),
),
_TIME("07:30"),
_DELIVER,
],
tags=("daily", "curiosity"),
),
]
_CATALOG_BY_KEY = {r.key: r for r in CATALOG}
def get_recipe(key: str) -> Optional[CronRecipe]:
return _CATALOG_BY_KEY.get(key)
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def recipe_form_schema(recipe: CronRecipe) -> Dict[str, Any]:
"""Emit the JSON a form renderer (dashboard / GUI) needs for this recipe."""
return {
"key": recipe.key,
"title": recipe.title,
"description": recipe.description,
"category": recipe.category,
"tags": list(recipe.tags),
"fields": [
{
"name": s.name,
"type": s.type,
"label": s.label,
"default": s.default,
"options": list(s.options),
"optional": s.optional,
"help": s.help,
}
for s in recipe.slots
],
}
def recipe_slash_command(recipe: CronRecipe, values: Optional[Dict[str, Any]] = None) -> str:
"""Build the flattened ``/cron-recipe <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:
val = values.get(s.name, s.default)
if val is None or val == "":
if s.optional:
continue
val = ""
sval = str(val)
if s.type == "text" or " " in sval:
sval = '"' + sval.replace('"', '\\"') + '"'
parts.append(f"{s.name}={sval}")
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."""
from urllib.parse import quote, urlencode
values = values or {}
query = {}
for s in recipe.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}"
def _humanize_schedule(recipe: CronRecipe) -> str:
"""A short human-readable description of when a recipe runs (defaults)."""
sched = recipe.schedule_template
if sched.startswith("*/"):
iv = next((s for s in recipe.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"
time_slot = next((s for s in recipe.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)
scope = (day_slot.default if day_slot else "") or ""
if scope and when:
return f"{scope} at {when}"
return f"at {when}" if when else "on a schedule"
if when:
return f"daily at {when}"
return "on a schedule"
def recipe_catalog_entry(recipe: CronRecipe) -> Dict[str, Any]:
"""Unified serializable shape for a recipe — 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),
}
# ---------------------------------------------------------------------------
# Fill + validate + translate to a create_job spec
# ---------------------------------------------------------------------------
_TIME_RE = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
_DAY_TO_DOW = {
"sunday": "0", "monday": "1", "tuesday": "2", "wednesday": "3",
"thursday": "4", "friday": "5", "saturday": "6",
}
def _resolve_schedule(recipe: CronRecipe, values: Dict[str, Any]) -> str:
"""Fill the schedule_template placeholders from resolved slot values."""
sched = recipe.schedule_template
# A free-text `schedule` slot passes through verbatim (full flexibility).
if "schedule" in values and values["schedule"]:
return str(values["schedule"])
repl: Dict[str, str] = {}
# time -> minute/hour
time_val = values.get("time")
if "{minute}" in sched or "{hour}" in sched:
if not time_val:
raise RecipeFillError("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)")
repl["hour"] = str(int(m.group(1)))
repl["minute"] = str(int(m.group(2)))
# weekday set -> dow
if "{dow}" in sched:
if "recurrence" in values:
preset = str(values.get("recurrence", "everyday")).lower()
if preset not in WEEKDAY_PRESETS:
raise RecipeFillError(
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}")
repl["dow"] = _DAY_TO_DOW[day]
else:
repl["dow"] = "*"
# interval (minutes) for */N schedules
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")
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.
for name in re.findall(r"\{(\w+)\}", sched):
if name not in repl and name in values:
repl[name] = str(values[name])
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
def fill_recipe(
recipe: CronRecipe,
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
slot, so a form can show field errors and the agent knows what to ask.
Enum values are checked against their options. The result is passed
straight to ``create_job`` no second schema.
"""
resolved: Dict[str, Any] = {}
for s in recipe.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})")
if s.type == "enum" and s.options and str(raw) not in {str(o) for o in s.options}:
raise RecipeFillError(
f"{s.name}={raw!r} not allowed — one of {', '.join(map(str, s.options))}"
)
resolved[s.name] = raw
schedule = _resolve_schedule(recipe, resolved)
# Render the prompt with whatever slots it references.
try:
prompt = recipe.prompt_template.format(**resolved)
except KeyError as e:
raise RecipeFillError(f"recipe 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),
}
if recipe.skills:
spec["skills"] = list(recipe.skills)
if origin is not None:
spec["origin"] = origin
return spec

View file

@ -7174,6 +7174,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "suggestions":
return await self._handle_suggestions_command(event)
if canonical == "cron-recipe":
return await self._handle_cron_recipe_command(event)
if canonical == "retry":
return await self._handle_retry_command(event)
@ -9270,6 +9273,36 @@ 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) -> str:
"""Handle /cron-recipe in the gateway.
Delegates to the shared handler so CLI, TUI, and gateway never drift.
Origin is built from the event source so a created recipe job delivers
back to this chat/thread.
"""
args = (event.get_command_args() or "").strip()
source = event.source
origin = None
try:
platform = getattr(source.platform, "value", None) or str(getattr(source, "platform", "") or "")
chat_id = getattr(source, "chat_id", None)
if platform and chat_id:
origin = {
"platform": platform,
"chat_id": str(chat_id),
"chat_name": getattr(source, "chat_name", None),
"thread_id": getattr(source, "thread_id", None),
}
except Exception:
origin = None
try:
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
return handle_cron_recipe_command(args, origin=origin)
except Exception as e:
logger.debug("cron-recipe command failed: %s", e)
return f"Cron recipe command failed: {e}"
# ────────────────────────────────────────────────────────────────
# /goal — persistent cross-turn goals (Ralph-style loop)
# ────────────────────────────────────────────────────────────────

View file

@ -1255,6 +1255,49 @@ class CLICommandsMixin:
print(f"(._.) Unknown cron command: {subcommand}")
print(" Available: list, add, edit, pause, resume, run, remove")
def _handle_suggestions_command(self, cmd: str):
"""Handle /suggestions — review/accept/dismiss suggested automations.
Delegates to the shared handler so CLI and gateway never drift. CLI
origin is the local platform so an accepted job's "origin" delivery
resolves to a configured home channel.
"""
import shlex
try:
tokens = shlex.split(cmd)[1:] if cmd else []
except ValueError:
tokens = (cmd or "").split()[1:]
args = " ".join(tokens)
try:
from hermes_cli.suggestions_cmd import handle_suggestions_command
output = handle_suggestions_command(args)
except Exception as e:
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.
Delegates to the shared handler so CLI, TUI, and gateway never drift.
The user pastes a pre-filled command (from the docs/dashboard or a bare
``/cron-recipe`` listing), edits the slot values, and sends; the handler
validates and creates the cron job, or names the slot that's missing.
"""
import shlex
try:
tokens = shlex.split(cmd)[1:] if cmd else []
except ValueError:
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
output = handle_cron_recipe_command(args)
except Exception as e:
output = f"Cron recipe command failed: {e}"
self._console_print(output)
def _handle_curator_command(self, cmd: str):
"""Handle /curator slash command.

View file

@ -182,6 +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("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")),
@ -1028,6 +1030,15 @@ _SLACK_RESERVED_COMMANDS = frozenset({
"topic", "mute", "pro", "shortcuts",
})
# High-value aliases that must survive Slack's 50-slash cap even when the
# registry fills up. Without this, adding a new canonical command silently
# clamps off low-priority aliases (they're added in the second pass), so a
# long-standing native slash like /btw could disappear just because an
# unrelated command landed. These claim their slots right after /hermes,
# ahead of both canonical names and the rest of the aliases. Anything not
# listed here still degrades gracefully (reachable via /hermes <command>).
_SLACK_PRIORITY_ALIASES = ("btw", "bg", "reset")
def _sanitize_slack_name(raw: str) -> str:
"""Convert a command name to a valid Slack slash command name.
@ -1082,6 +1093,21 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
entries.append((slack_name, desc[:140], hint[:100]))
seen.add(slack_name)
# Priority pass: pin high-value aliases (e.g. /btw, /bg, /reset) ahead of
# everything except /hermes, so a new canonical command can never silently
# clamp them off the 50-slash cap. Each alias borrows its parent command's
# description and hint.
_alias_to_cmd = {
alias: cmd
for cmd in COMMAND_REGISTRY
if _is_gateway_available(cmd, overrides)
for alias in cmd.aliases
}
for alias in _SLACK_PRIORITY_ALIASES:
cmd = _alias_to_cmd.get(alias)
if cmd is not None:
_add(alias, f"Alias for /{cmd.name}{cmd.description}", cmd.args_hint or "")
# First pass: canonical names (so they win slots if we hit the cap).
for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides):

View file

@ -0,0 +1,147 @@
"""Shared ``/cron-recipe`` command logic for CLI, TUI, and gateway.
The conversational counterpart to the dashboard's Cron Recipes 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 pastes a pre-filled slash command and this handler
parses it; any missing or invalid slot is reported so the agent can ask.
Subcommand shapes:
/cron-recipe list the catalog (numbered + copy commands)
/cron-recipe <key> show that recipe's slots + a ready command
/cron-recipe <key> slot=val fill + create the cron job
Parsing is shlex-based so quoted free-text values (``criteria="from my boss"``)
survive. On a fill error the message names the slot, which is exactly what the
agent needs to ask a targeted follow-up rather than re-prompting everything.
"""
from __future__ import annotations
import logging
import shlex
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger(__name__)
def _resolve_origin(explicit: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if explicit is not None:
return explicit
try:
from gateway.session_context import get_session_env
platform = get_session_env("HERMES_SESSION_PLATFORM")
chat_id = get_session_env("HERMES_SESSION_CHAT_ID")
if platform and chat_id:
return {
"platform": platform,
"chat_id": chat_id,
"chat_name": get_session_env("HERMES_SESSION_CHAT_NAME") or None,
"thread_id": get_session_env("HERMES_SESSION_THREAD_ID") or None,
}
except Exception:
pass
return None
def _parse_kv(tokens) -> Tuple[Dict[str, str], list]:
"""Split ``slot=value`` tokens from bare tokens. Returns (values, leftovers)."""
values: Dict[str, str] = {}
leftovers = []
for tok in tokens:
if "=" in tok:
k, _, v = tok.partition("=")
k = k.strip()
if k:
values[k] = v.strip()
continue
leftovers.append(tok)
return values, leftovers
def _fmt_catalog() -> str:
from cron.recipe_catalog import CATALOG, recipe_slash_command
lines = ["Cron Recipes — `/cron-recipe <name>` to set one up:\n"]
for r in CATALOG:
lines.append(f"{r.key}{r.title}")
lines.append(f" {r.description}")
lines.append(f"{recipe_slash_command(r)}")
lines.append("\nEdit the values then send, or just send to use the defaults.")
return "\n".join(lines)
def _fmt_recipe(recipe) -> str:
from cron.recipe_catalog import recipe_slash_command
lines = [f"{recipe.title}{recipe.description}\n", "Fields:"]
for s in recipe.slots:
opts = f" (one of: {', '.join(map(str, s.options))})" if s.options else ""
dflt = f" [default: {s.default}]" if s.default not in (None, "") else ""
opt = " (optional)" if s.optional else ""
lines.append(f"{s.name}: {s.label}{opts}{dflt}{opt}")
lines.append("\nReady-to-edit command:")
lines.append(f" {recipe_slash_command(recipe)}")
return "\n".join(lines)
def handle_cron_recipe_command(
args: str,
*,
origin: Optional[Dict[str, Any]] = None,
) -> str:
"""Dispatch a ``/cron-recipe`` invocation. Returns text to show the user.
``args`` is everything after ``/cron-recipe``. ``origin`` lets an accepted
recipe's job deliver back to the chat it was created from; resolved from
session env when omitted.
"""
try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
except Exception as e: # pragma: no cover - import guard
logger.debug("recipe catalog import failed: %s", e)
return "Cron Recipes are unavailable in this build."
try:
tokens = shlex.split(args or "")
except ValueError:
tokens = (args or "").split()
# Bare -> list catalog.
if not tokens:
return _fmt_catalog()
key = tokens[0]
recipe = get_recipe(key)
if recipe is None:
return (
f"No cron recipe named '{key}'. Run /cron-recipe to see the catalog."
)
values, _leftover = _parse_kv(tokens[1:])
# `<key>` with no slot args -> show the recipe's fields + a ready command.
if not values:
return _fmt_recipe(recipe)
# `<key> slot=val …` -> fill + create.
try:
spec = fill_recipe(recipe, values, origin=_resolve_origin(origin))
except RecipeFillError as e:
return f"Can't set up '{recipe.title}': {e}\nRun /cron-recipe {key} to see its fields."
try:
from cron.jobs import create_job
job = create_job(**spec)
except Exception as e:
logger.debug("cron-recipe create_job failed: %s", e)
return f"Failed to create the job: {e}"
sched = job.get("schedule_display") or spec.get("schedule", "")
return (
f"Scheduled '{recipe.title}'"
+ (f" ({sched})" if sched else "")
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
)

View file

@ -6778,6 +6778,53 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
return {"ok": True}
# ---------------------------------------------------------------------------
# Cron Recipes — 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.
# ---------------------------------------------------------------------------
class CronRecipeInstantiate(BaseModel):
recipe: str # recipe 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."""
try:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]}
except Exception as e:
_log.exception("GET /api/cron/recipes 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)."""
try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
recipe = get_recipe(body.recipe)
if recipe is None:
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}")
try:
spec = fill_recipe(recipe, body.values)
except RecipeFillError 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
# 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")
raise HTTPException(status_code=400, detail=str(e))
# ---------------------------------------------------------------------------
# MCP server endpoints — list / add / remove / test.
#

View file

@ -0,0 +1,195 @@
"""Tests for Cron Recipes — 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
the docs generator. Uses an isolated HERMES_HOME for anything that touches the
cron job store.
"""
import importlib
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from cron.recipe_catalog import (
CATALOG,
RecipeFillError,
RecipeSlot,
fill_recipe,
get_recipe,
recipe_catalog_entry,
recipe_deeplink,
recipe_form_schema,
recipe_slash_command,
)
class TestCatalog:
def test_catalog_nonempty_and_keyed(self):
assert len(CATALOG) >= 1
for r in CATALOG:
assert get_recipe(r.key) is r
def test_every_slot_has_known_type(self):
for r in CATALOG:
for s in r.slots:
assert s.type in {"time", "enum", "text", "weekdays"}
def test_bad_slot_type_rejected(self):
with pytest.raises(ValueError):
RecipeSlot(name="x", type="bogus", label="X")
class TestScheduleResolution:
def test_time_to_cron(self):
spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:30"})
assert spec["schedule"] == "30 8 * * *"
def test_interval_schedule(self):
spec = fill_recipe(
get_recipe("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"),
{"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"),
{"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"), {})
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"})
def test_bad_enum_rejected_and_names_slot(self):
with pytest.raises(RecipeFillError, match="not allowed"):
fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "pigeon"})
def test_text_slot_renders_into_prompt(self):
spec = fill_recipe(
get_recipe("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"}
)
assert spec["origin"] == {"platform": "telegram", "chat_id": "9"}
class TestRenderers:
def test_form_schema_fields(self):
schema = recipe_form_schema(get_recipe("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")
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"}
)
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?")
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")
assert entry["appUrl"].startswith("hermes://")
assert entry["scheduleHuman"]
assert "fields" in entry
@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
import hermes_constants
importlib.reload(hermes_constants)
import cron.jobs as jobs
importlib.reload(jobs)
return jobs
class TestCommandHandler:
def test_bare_lists_catalog(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
out = handle_cron_recipe_command("")
assert "morning-brief" in out and "Cron Recipes" in out
def test_show_recipe_fields(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
out = handle_cron_recipe_command("morning-brief")
assert "Fields:" in out and "time" in out
def test_fill_creates_job(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
out = handle_cron_recipe_command("morning-brief time=07:30 deliver=telegram")
assert "Scheduled" in out
jobs = isolated_home.load_jobs()
assert len(jobs) == 1
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
out = handle_cron_recipe_command("does-not-exist")
assert "No cron recipe" in out
def test_bad_value_names_slot(self, isolated_home):
from hermes_cli.cron_recipe_cmd import handle_cron_recipe_command
out = handle_cron_recipe_command("morning-brief time=99:99")
assert "Can't set up" in out and "time" in out
class TestDocsGenerator:
def test_generator_emits_valid_index(self, tmp_path):
# The generator imports the catalog and writes a flat JSON array.
import importlib.util
script = (
Path(__file__).resolve().parents[2]
/ "website" / "scripts" / "extract-cron-recipes.py"
)
spec = importlib.util.spec_from_file_location("extract_cron_recipes", script)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
index = mod.build_index()
assert isinstance(index, list) and len(index) == len(CATALOG)
# Each entry must round-trip through json and carry the surfaces.
json.dumps(index)
assert all("command" in e and "appUrl" in e for e in index)

View file

@ -2360,6 +2360,42 @@ class TestNewEndpoints:
resp = self.client.get("/api/cron/jobs/nonexistent-id")
assert resp.status_code == 404
# --- Cron Recipes ---
def test_cron_recipes_list(self):
resp = self.client.get("/api/cron/recipes")
assert resp.status_code == 200
recipes = resp.json()["recipes"]
assert len(recipes) >= 1
first = recipes[0]
assert "fields" in first
assert first["command"].startswith("/cron-recipe")
assert first["appUrl"].startswith("hermes://")
def test_cron_recipe_instantiate_creates_job(self):
resp = self.client.post(
"/api/cron/recipes/instantiate",
json={"recipe": "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):
resp = self.client.post(
"/api/cron/recipes/instantiate",
json={"recipe": "does-not-exist", "values": {}},
)
assert resp.status_code == 404
def test_cron_recipe_instantiate_bad_value_422(self):
resp = self.client.post(
"/api/cron/recipes/instantiate",
json={"recipe": "morning-brief", "values": {"time": "99:99"}},
)
assert resp.status_code == 422
# --- Profiles ---
def test_profiles_list_includes_default(self):

View file

@ -0,0 +1,222 @@
import { useCallback, useEffect, useState } from "react";
import { Clock, Wand2 } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
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 { cn, themedBody } from "@/lib/utils";
interface CronRecipesProps {
profile: string;
/** Called after a recipe 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> {
const out: Record<string, string> = {};
for (const f of recipe.fields) out[f.name] = f.default ?? "";
return out;
}
function FieldInput({
field,
value,
onChange,
}: {
field: CronRecipeField;
value: string;
onChange: (v: string) => void;
}) {
if (field.type === "enum" || field.type === "weekdays") {
return (
<Select value={value} onValueChange={(v) => onChange(v)}>
{field.options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt}
</SelectOption>
))}
</Select>
);
}
if (field.type === "time") {
return (
<Input
type="time"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// text
return (
<Input
type="text"
value={value}
placeholder={field.help || field.label}
onChange={(e) => onChange(e.target.value)}
/>
);
}
function RecipeCard({
recipe,
profile,
showToast,
onCreated,
}: {
recipe: CronRecipe;
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 [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = useCallback(async () => {
setSubmitting(true);
setError(null);
try {
const job = await api.instantiateCronRecipe({ recipe: recipe.key, values }, profile);
const when = job.schedule_display ? `${job.schedule_display}` : "";
showToast(`${recipe.title} scheduled${when}`, "success");
setOpen(false);
setValues(initialValues(recipe));
onCreated?.();
} catch (e) {
// 422 from the API carries the slot-level validation message.
const msg = e instanceof Error ? e.message : String(e);
setError(msg.replace(/^\d+:\s*/, ""));
} finally {
setSubmitting(false);
}
}, [recipe, values, profile, showToast, onCreated]);
return (
<Card className={cn("overflow-hidden", themedBody)}>
<CardContent className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<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>
</div>
<p className="mt-1 text-sm opacity-70">{recipe.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{recipe.tags.map((t) => (
<Badge key={t} tone="secondary">
{t}
</Badge>
))}
</div>
</div>
<Button
ghost={open}
size="sm"
onClick={() => setOpen((o) => !o)}
>
{open ? "Cancel" : "Set up"}
</Button>
</div>
{open && (
<div className="space-y-3 border-t pt-3">
{recipe.fields.map((f) => (
<div key={f.name} className="space-y-1">
<Label htmlFor={`${recipe.key}-${f.name}`}>{f.label}</Label>
<FieldInput
field={f}
value={values[f.name] ?? ""}
onChange={(v) => setValues((prev) => ({ ...prev, [f.name]: v }))}
/>
{f.help && f.type !== "text" ? (
<p className="text-xs opacity-60">{f.help}</p>
) : null}
</div>
))}
{error ? (
<p className="text-sm text-red-500" role="alert">
{error}
</p>
) : null}
<div className="flex items-center gap-2">
<Button onClick={() => void submit()} disabled={submitting}>
{submitting ? <Spinner className="h-4 w-4" /> : <Clock className="h-4 w-4" />}
Schedule it
</Button>
</div>
</div>
)}
</CardContent>
</Card>
);
}
/**
* Cron Recipes gallery the form-where-there's-a-screen surface. Each recipe
* 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
* via the same create_job path as everything else.
*/
export function CronRecipes({ profile, onCreated }: CronRecipesProps) {
const { toast, showToast } = useToast();
const [recipes, setRecipes] = useState<CronRecipe[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.getCronRecipes()
.then((r) => {
if (!cancelled) setRecipes(r.recipes);
})
.catch((e) => {
if (!cancelled) setLoadError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, []);
if (loadError) {
return <p className="text-sm text-red-500">Couldn't load recipes: {loadError}</p>;
}
if (recipes === null) {
return (
<div className="flex items-center gap-2 opacity-70">
<Spinner className="h-4 w-4" /> Loading recipes
</div>
);
}
if (recipes.length === 0) {
return <p className="opacity-70">No cron recipes available.</p>;
}
return (
<>
<Toast toast={toast} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{recipes.map((r) => (
<RecipeCard
key={r.key}
recipe={r}
profile={profile}
showToast={showToast}
onCreated={onCreated}
/>
))}
</div>
</>
);
}
export default CronRecipes;

View file

@ -497,6 +497,19 @@ 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> },
profile = "default",
) =>
fetchJSON<CronJob>(`/api/cron/recipes/instantiate?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
// Profiles
getProfiles: () =>
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
@ -1825,6 +1838,27 @@ export interface CronDeliveryTarget {
home_env_var: string | null;
}
export interface CronRecipeField {
name: string;
type: "time" | "enum" | "text" | "weekdays";
label: string;
default: string | null;
options: string[];
optional: boolean;
help: string;
}
export interface CronRecipe {
key: string;
title: string;
description: string;
category: string;
tags: string[];
fields: CronRecipeField[];
command: string;
appUrl: string;
}
export interface SkillInfo {
name: string;
description: string;

View file

@ -29,6 +29,8 @@ import { Label } from "@nous-research/ui/ui/components/label";
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 { cn, themedBody } from "@/lib/utils";
function formatTime(iso?: string | null): string {
@ -176,6 +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 [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t, locale } = useI18n();
@ -507,6 +510,23 @@ export default function CronPage() {
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<Segmented
value={view}
onChange={(v) => setView(v as "jobs" | "recipes")}
options={[
{ value: "jobs", label: "Jobs" },
{ value: "recipes", label: "Recipes" },
]}
/>
{view === "recipes" && (
<CronRecipes
profile={selectedProfile === "all" ? "default" : selectedProfile}
onCreated={loadJobs}
/>
)}
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
@ -746,6 +766,7 @@ export default function CronPage() {
</div>
)}
{view === "jobs" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
@ -888,6 +909,7 @@ export default function CronPage() {
);
})}
</div>
)}
<PluginSlot name="cron:bottom" />
</div>

View file

@ -0,0 +1,34 @@
---
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** — copy a recipe's `/cron-recipe` command below,
edit the values, and send it. Hermes fills in anything you leave out and
asks if something's ambiguous.
- **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

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Generate the Cron Recipes 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
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
``website/scripts/prebuild.mjs`` before ``npm start`` / ``npm run build``.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
# Repo root = two levels up from website/scripts/.
REPO_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO_ROOT))
OUTPUT = REPO_ROOT / "website" / "static" / "api" / "cron-recipes-index.json"
def build_index() -> list:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
return [recipe_catalog_entry(r) for r in CATALOG]
def main() -> int:
try:
index = build_index()
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")
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")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -31,6 +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 outputFile = join(websiteDir, "static", "api", "skills.json");
const unifiedIndexFile = join(websiteDir, "static", "api", "skills-index.json");
const UNIFIED_INDEX_URL =
@ -138,3 +139,7 @@ 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
// renders an empty state if the generator can't run.
runPython(cronRecipesScript, "extract-cron-recipes.py");

View file

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

View file

@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import styles from "./styles.module.css";
interface RecipeField {
name: string;
type: string;
label: string;
default: string | null;
options: string[];
optional: boolean;
help: string;
}
interface Recipe {
key: string;
title: string;
description: string;
category: string;
tags: string[];
fields: RecipeField[];
scheduleHuman: string;
command: string;
appUrl: string;
}
const INDEX_URL = "/docs/api/cron-recipes-index.json";
function CopyButton({ text }: { text: string }): JSX.Element {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className={styles.copyBtn}
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
aria-label="Copy command"
>
{copied ? "Copied" : "Copy"}
</button>
);
}
function RecipeCard({ recipe }: { recipe: Recipe }): 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>
</div>
<p className={styles.desc}>{recipe.description}</p>
<div className={styles.tags}>
{recipe.tags.map((t) => (
<span key={t} className={styles.tag}>
{t}
</span>
))}
</div>
<div className={styles.cmdRow}>
<code className={styles.cmd}>{recipe.command}</code>
<CopyButton text={recipe.command} />
</div>
<div className={styles.actions}>
<a className={styles.appBtn} href={recipe.appUrl}>
Send to App
</a>
<span className={styles.hint}>
or paste the command into the CLI, TUI, or any messenger
</span>
</div>
</div>
);
}
export default function CronRecipesCatalog(): JSX.Element {
const [recipes, setRecipes] = useState<Recipe[] | 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);
})
.catch((e) => {
if (!cancelled) setError(String(e));
});
return () => {
cancelled = true;
};
}, []);
if (error) {
return <p>Couldn't load the recipe catalog: {error}</p>;
}
if (recipes === null) {
return <p>Loading recipes</p>;
}
if (recipes.length === 0) {
return <p>No cron recipes are available.</p>;
}
return (
<div className={styles.grid}>
{recipes.map((r) => (
<RecipeCard key={r.key} recipe={r} />
))}
</div>
);
}

View file

@ -0,0 +1,114 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.card {
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
padding: 1.1rem 1.2rem;
background: var(--ifm-card-background-color, var(--ifm-background-surface-color));
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.cardHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.title {
margin: 0;
font-size: 1.1rem;
}
.schedule {
font-size: 0.8rem;
color: var(--ifm-color-emphasis-700);
white-space: nowrap;
}
.desc {
margin: 0;
color: var(--ifm-color-emphasis-800);
font-size: 0.92rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
font-size: 0.72rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: var(--ifm-color-emphasis-200);
color: var(--ifm-color-emphasis-800);
}
.cmdRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cmd {
flex: 1;
overflow-x: auto;
white-space: nowrap;
padding: 0.45rem 0.6rem;
font-size: 0.82rem;
border-radius: 6px;
background: var(--ifm-color-emphasis-100);
}
.copyBtn {
flex-shrink: 0;
border: 1px solid var(--ifm-color-emphasis-300);
background: transparent;
color: var(--ifm-color-emphasis-800);
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.copyBtn:hover {
background: var(--ifm-color-emphasis-200);
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.appBtn {
display: inline-block;
padding: 0.4rem 0.85rem;
border-radius: 6px;
background: var(--ifm-color-primary);
color: var(--ifm-color-primary-contrast-background, #fff);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
}
.appBtn:hover {
background: var(--ifm-color-primary-dark);
text-decoration: none;
color: var(--ifm-color-primary-contrast-background, #fff);
}
.hint {
font-size: 0.78rem;
color: var(--ifm-color-emphasis-600);
}