All plugins

hermes-jobs

Google Calendar is the one supported backend today (scope calendar.events, one bound calendar). Bidirectional sync between Hermes cron (system of record) and that job board. discover/status are read-only; sync plans both directions and is dry-run unless --confirm on the CLI. Read-only /jobs slash command shows the 7-day agenda and pending changes; each job's last run time and status (ok/error) is written to its event as a colour and one description line, never the title. Disclosure — syncs Hermes cron job names, next-run times and last-run status (never prompts; only a prompt sha256) to one Google Calendar you bind, over Hermes plugin OAuth (`hermes auth add hermes-jobs:google`, scope calendar.events, host www.googleapis.com). Reads `~/.hermes/cron/jobs.json` read-only, and the first 8 KB of each no-agent cron job's script under `~/.hermes/scripts/` only to recognise its own auto-sync job. Calendar edits change a cron job's schedule or name only when you run `hermes hermes-jobs sync --confirm`. The opt-in experimental skip writes `~/.hermes/scripts/hermes-jobs-skip-tick.py` and creates two one-shot no-agent cron jobs. The opt-in `mcp-serve` push mode polls Calendar every 60 s and POSTs signed event ids to your own gateway webhook URL.

Benjamin Crow (CrowBe) / Hermes Plugin BuilderAutomationv0.3.2Updated 1 hour ago

About

Google Calendar is the one supported backend today (scope calendar.events, one bound calendar). Bidirectional sync between Hermes cron (system of record) and that job board. discover/status are read-only; sync plans both directions and is dry-run unless --confirm on the CLI. Read-only /jobs slash command shows the 7-day agenda and pending changes; each job's last run time and status (ok/error) is written to its event as a colour and one description line, never the title. Disclosure — syncs Hermes cron job names, next-run times and last-run status (never prompts; only a prompt sha256) to one Google Calendar you bind, over Hermes plugin OAuth (`hermes auth add hermes-jobs:google`, scope calendar.events, host www.googleapis.com). Reads `~/.hermes/cron/jobs.json` read-only, and the first 8 KB of each no-agent cron job's script under `~/.hermes/scripts/` only to recognise its own auto-sync job. Calendar edits change a cron job's schedule or name only when you run `hermes hermes-jobs sync --confirm`. The opt-in experimental skip writes `~/.hermes/scripts/hermes-jobs-skip-tick.py` and creates two one-shot no-agent cron jobs. The opt-in `mcp-serve` push mode polls Calendar every 60 s and POSTs signed event ids to your own gateway webhook URL.

hermes-jobs (automation)

hermes-jobs: Calendar sync for Hermes cron

Google Calendar is the one supported backend today: the plugin binds to one dedicated calendar, with OAuth scope calendar.events only.

Demo (85.8s): https://x.com/BenCrow95406267/status/2120070542216871938

Your Hermes agent's scheduled work shows up on the calendar you already live in, and dragging it
there safely reschedules the real cron job.

Hermes cron is the system of record. That one Google Calendar is the job board. Every change, in either
direction, is planned as a dry run first and only applied when you run --confirm (or, for cron's own
changes only, the guarded --auto).

hermes-jobs: dry run, --confirm creates the recurring event, re-sync shows 0 ops

Happy path

hermes hermes-jobs discover          # read-only: your cron jobs, oauth status, bound calendar
hermes hermes-jobs sync              # dry run: the exact ops in both directions, nothing written
hermes hermes-jobs sync --confirm    # apply the plan as it stands now

--confirm recalculates the plan at that moment; it does not replay a stored dry run. If anything
changed in between, it applies the new plan, so re-run the dry run when in doubt.

In a Hermes chat, /jobs prints the next 7 days of Hermes jobs in agenda order, what is waiting on
you (pending ops from a dry-run plan, deferred "this and following" changes, unfinished moves), and the
exact confirm command. It never writes. Without Google sign-in it still shows the cron-only agenda.

Drag a recurring job's series in Calendar ("all events") and the next sync proposes the matching cron
change; edit the job in Hermes and the event follows (cron wins). All four of Google's recurring edits
("this event", "all events", "this and following", delete one instance) were tested live on Google
Calendar on 2026-11-09/10; what each one does is under Recurring Google events.

Run outcome. Once a job has run, the dry run shows a mark_run op and the next confirmed sync colours its event from Hermes' own
last_run_at / last_status (Basil green = ok, Tomato red = error, Banana yellow = anything else,
e.g. delivery_failed) and keeps exactly one last run: <local time> <status> line in the description.
The line is replaced, never accumulated; the title is never touched; an unchanged outcome plans nothing;
a job that has never run gets no colour and no line. The error text itself never leaves the machine.

Auto-sync (opt-in recipe, once you trust the plan)

The plugin never schedules itself and registers no hooks. If you want the board kept current
without typing, create your own Hermes no_agent cron job with a fixed argv (no LLM, no shell
expansion of event text). The default recipe runs the guarded sync --auto: it pushes cron's own
changes to the calendar and holds every calendar edit for you to review and --confirm (see
Push sync for exactly what is held):

cat > ~/.hermes/scripts/hermes-jobs-sync.sh <<'SH'
#!/bin/sh
exec "$HOME/.local/bin/hermes" hermes-jobs sync --auto   # use your `command -v hermes`
SH
hermes cron create --no-agent --script hermes-jobs-sync.sh --deliver local \
  --name "hermes-jobs auto-sync" "0 * * * *"

Unattended bidirectional variant (pre-approves calendar edits). Putting sync --confirm in that
script instead means every future calendar-originated change (renames, reschedules, a deleted event
pausing its job) is applied hourly without anyone reviewing it. Only do that for a calendar you alone
edit. Even then --confirm refuses a moved occurrence of a recurring job unless you pass
--allow-additional-run by hand, so it never accepts a duplicate run for you; /jobs lists it as
waiting on you. Runs never overlap: applying commands hold a per-profile lock, and a second one
returns "already running" without writing. The sync job itself is
kept off the board: a no_agent job whose script is hermes-jobs-auto.sh or hermes-jobs-sync.sh, or
whose script runs hermes-jobs sync, never gets an event (a leftover one is deleted). Stop it with
hermes cron pause <id> or hermes cron remove <id>.

Push sync (experimental, MCP Events)

Calendar edits can trigger a sync the moment they land, through Hermes' experimental MCP Events client
(tools/mcp_tool_events.py, the events/* extension from the MCP Triggers & Events WG). Three parts:

  1. hermes hermes-jobs sync --auto, the guarded sync. It applies only cron-originated ops
    (insert/patch/delete/convert of events, run-outcome mark_run) and holds every calendar-originated
    op
    (update_job, adopt_split, apply_split, drop_move, pause_job, create_once,
    finish_move, deleted-instance skips). Held ops are listed under held exactly as /jobs shows them
    under "Waiting on you", and their jobs keep their last baseline, so the edit keeps waiting until you
    run hermes hermes-jobs sync --confirm. --auto never calls cronjob_manage.
  2. hermes hermes-jobs mcp-serve, a stdio MCP server with no tools and one event type,
    calendar.event_changed (no arguments, webhook delivery only). While a subscription is live it polls
    the bound calendar every 60s (--interval, min 30) with Google incremental sync (syncToken; a 410
    resyncs and says so) and POSTs one Standard Webhooks-signed occurrence per batch of changes. The
    payload is {changes: [{eventId, etag, status, changedAt}], more?, resync?}: ids, etags and times
    only, never titles or descriptions. It answers events/list, events/subscribe (also the refresh;
    grants up to 24h) and events/unsubscribe, sends the signed verification challenge before the first
    delivery to a URL, uses the sync token as the replay cursor, and only delivers under this gateway's
    own platforms.webhook.extra.public_url + /webhooks/<route> (no other URL, no redirects). Google
    access is Hermes' plugin oauth, as for sync.
  3. A webhook route whose cron_job fires a no_agent job that runs sync --auto. Event data never
    reaches a model: the job is a script.

Push is optional: hermes hermes-jobs sync works without it, run by hand or from a scheduled job.
Push needs an https address where the Hermes gateway can be reached, such as tailscale serve on your
tailnet; Tailscale Funnel or another public https front is only needed for the Apps Script fallback, and the
gateway port (8644) should be bound to localhost or firewalled to the tailnet.

Recipe (needs hermes gateway run: the gateway hosts the webhook listener, keeps the subscription
refreshed, and runs the cron job):

cat > ~/.hermes/scripts/hermes-jobs-auto.sh <<'SH'
#!/bin/sh
exec "$HOME/.local/bin/hermes" hermes-jobs sync --auto   # use your `command -v hermes`
SH
hermes cron create --no-agent --script hermes-jobs-auto.sh --deliver local \
  --name "hermes-jobs auto-sync" "0 */6 * * *"     # a slow backstop; events do the real work
# config.yaml
platforms:
  webhook:
    enabled: true
    extra:
      public_url: "https://<host>.<tailnet>.ts.net"   # any https origin that reaches the listener (:8644)
mcp_servers:
  hermes-jobs:
    command: "/home/<you>/.local/bin/hermes"
    args: ["hermes-jobs", "mcp-serve"]
    events: true

Then, in a Hermes chat, ask it to call the hermes-jobs server's subscribe_event tool with
name: calendar.event_changed, arguments: {} and deliver: local (Hermes asks you to approve the
subscription). That writes a dynamic route mcp-hermes-jobs-<hash> to webhook_subscriptions.json.
Point it at the job by adding one key to that route, "cron_job": "<auto-sync job id>"
(hermes cron list shows the id), and check it with hermes webhook list. Re-running subscribe_event
rewrites the route, so add cron_job again if you do. Every event then fires the auto-sync job.

Live-proven, Tue 10 Nov 2026 (omarchy, Hermes v0.23.0, tailscale serve, tailnet only): Hermes' MCP Events
client subscribed to calendar.event_changed, and the gateway answered our signed verification challenge.
Renaming probe-a in Google Calendar produced one signed POST about 32s later (60s poll). The route's cron_job fired
the no_agent auto-sync job, and sync --auto held the rename as update_job under held. jobs.json was
unchanged apart from that job's own last_run_at/last_status. After the rename was reverted, the next
delivery's sync held nothing. Still unproven: a subscription owned by the gateway itself. At gateway startup,
the MCP refresh ran before the webhook listener was up and failed verification (502). Hermes then waits until
the stored grant's refresh time instead of retrying after 30s, so the live test held the subscription from a
separate Hermes session. Also unproven: 24h refresh, cursor replay after downtime, and dedupe across two pollers.

Notes and limits:

  • public_url must be https:// (Hermes' client refuses anything else). It does not need to be public.
    tailscale serve on this machine is enough, since the server runs here.
  • Fixed (found live): the auto-sync job used to be synced to the calendar, so each run's mark_run on its
    own event triggered the next run, about once a minute. It is now kept off the board (identified by its
    no_agent script, never its name; a leftover event is deleted once), so a run produces no calendar write
    of its own. Other writes by sync --auto still come back as one more event, whose sync plans nothing.
  • Every Hermes process that connects the server (the gateway, a chat session) runs its own poller.
    Identical changes carry the same eventId, and the gateway dedupes on it.
  • The subscription lives in memory and lasts up to 24h. Hermes refreshes it at 80% and on every
    reconnect, passing its cursor, so changes made while the gateway was down are replayed as one event.

The fallback, if MCP Events changes: a Google Apps Script installable calendar trigger that signs a
generic HMAC V2 POST to the same route. Google events.watch cannot be used directly, because it
authenticates only with X-Goog-Channel-Token.

Setup

  1. Create a Google Cloud Desktop OAuth client; enable Google Calendar API.
  2. Set HERMES_JOBS_GOOGLE_CLIENT_ID and HERMES_JOBS_GOOGLE_CLIENT_SECRET in the Hermes profile env.
  3. Sign in:
hermes auth add hermes-jobs:google
  1. Enable / doctor:
hermes plugins enable hermes-jobs
hermes plugins doctor hermes-jobs --ci
hermes hermes-jobs discover   # read-only
hermes hermes-jobs status
hermes hermes-jobs sync       # dry-run by default

Disclosure: network / credentials (catalog rule 13)

  • Calls https://www.googleapis.com (Calendar API v3) only after Hermes oauth sign-in.

  • Scope: https://www.googleapis.com/auth/calendar.events only (no Gmail/Drive/full calendar).

  • Declare client under oauth: in plugin.yaml; sign in with hermes auth add hermes-jobs:google.

  • Plugin uses credentialed_provider_request("google", …) — never reads google_token.json, .env, browser profiles, or google-workspace skill tokens.

  • Full detail of what leaves the machine, local files and revocation: SECURITY.md.

What leaves the machine

  • Network: Google Calendar API v3 on the ONE bound calendar only, via Hermes oauth. Every sync
    (dry-runs too) reads events; only sync --confirm and sync --auto (cron-originated ops only) write. Sent: job name, schedule/RRULE, job id,
    prompt sha256 digest, private tags, and once a job has run its last-run time and status word (ok,
    error, ...) as an event colour + one description line. Never the prompt text, execution config or
    error text.
  • Reads outside plugin data (read-only): ~/.hermes/cron/jobs.json, Hermes config (time zone),
    and the script file (first 8 KB) of each no_agent cron job in ~/.hermes/scripts/, only to recognise
    an auto-sync job (one that runs hermes-jobs sync) and keep it off the board. Script text is never
    sent anywhere or executed.
  • Cron changes: only through Hermes' cronjob_manage tool, only on --confirm (never --auto).
  • Push sync only (opt-in, mcp-serve): while subscribed, an incremental Calendar read every 60s, and
    signed POSTs of event ids/etags/times to this Hermes gateway's own public_url webhook route.
  • Experimental skip only (off by default): writes ~/.hermes/scripts/hermes-jobs-skip-tick.py and
    creates two one-shot no_agent cron jobs that run hermes hermes-jobs skip-tick --quiet (fixed argv,
    no shell). No other background processes; no telemetry; no stored credentials.

Never

  • Event text never grants tools, permissions, or prompts. A calendar title can only become a job name
    (sanitised: NFKC, no control/format/bidi/zero-width characters, single line, max 120 chars); a
    calendar time can only become a validated schedule. Descriptions are never parsed (the plugin only
    recognises its own last run: line, to replace it).
  • No scopes beyond calendar.events.
  • No writes without explicit --confirm, except --auto, which only pushes cron's own state to the
    calendar and never changes cron. Discover never creates calendars or cron jobs.

Sync (0.3.2)

hermes hermes-jobs bind primary          # choose the ONE calendar (local state only)
hermes hermes-jobs sync --offline        # dry-run against an empty calendar, no Google calls
hermes hermes-jobs sync                  # dry-run against the bound calendar
hermes hermes-jobs sync --confirm        # apply (human CLI step only; the agent tool can only plan)
hermes hermes-jobs sync --auto           # guarded: cron->calendar ops only; calendar edits held (see Push sync)
hermes hermes-jobs mcp-serve             # stdio MCP server: calendar.event_changed events (see Push sync)
hermes hermes-jobs sync --skip-original=experimental            # dry-run incl. the experimental skip steps
hermes hermes-jobs skip-tick             # advance pending experimental skips (run by their wake-up jobs)
hermes hermes-jobs mode [recurring|single]   # show/set event mode (recurring = default; single = fallback)
hermes hermes-jobs capture-fixtures <job_id> # read-only GETs -> sanitised tests/fixtures/*.json
  • cron -> calendar: one 15-min event per job at its next run, tagged extendedProperties.private.hermes_job_id;
    title = job name ([paused] prefix when paused); description = job id + prompt sha256 digest only,
    plus one last run: line once the job has run (see Run outcome).
  • calendar -> cron (tagged events only, via Hermes' cronjob_manage): retitle -> name update;
    deleting an event pauses, never removes, its job. Untagged events are ignored and event text
    never grants tools or edits prompts.
  • Conflicts: a cron edit wins and resets the event. A move never rewrites a recurring schedule.
  • Tests: hermes --run-module unittest discover -s tests (planner, mocked runtime, security/injection
    tests, recurring-edit edge cases, and fixtures in tests/fixtures: captured_* are sanitised read-only GETs of the live probe,
    documented_* are built from Google's documented payload shapes and are marked as such). No Google
    calls or real cron writes.

Recurring Google events (event mode recurring, the default)

A cron job whose expression converts exactly to an RRULE is shown as ONE real Google recurring
event (a master with recurrence: [RRULE...] and start.timeZone = the job's IANA zone). It is tagged
like every other event (hermes_job_id, full private prop set), its id is the derived occurrence key
of its first slot, and it is not patched forward (its DTSTART stays fixed; Google expands it).
Everything else keeps the single patch-forward event above: one-shots, interval jobs, and any cron
that does not convert.

| cron (job zone Australia/Sydney) | RRULE | back to cron |
| --- | --- | --- |
| 0 9 * * * | FREQ=DAILY | 0 9 * * * |
| 0 18 * * 1-5 | FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR | 0 18 * * 1,2,3,4,5 |
| 30 7 * * MON,WED,FRI | FREQ=WEEKLY;BYDAY=MO,WE,FR | 30 7 * * 1,3,5 |
| 0 9 1,15 * * | FREQ=MONTHLY;BYMONTHDAY=1,15 | 0 9 1,15 * * |
| 0 9 25 12 * | FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25 | 0 9 25 12 * |
| 0 9 * 6-8 * | FREQ=DAILY;BYMONTH=6,7,8 | 0 9 * 6,7,8 * |
| 0 9 * * 0,7 | FREQ=WEEKLY;BYDAY=SU | 0 9 * * 0 |
| 0 2 * * * | refused: 02:xx is skipped/repeated at a Sydney DST change | single event |
| */5 * * * *, 0 9,17 * * *, 0 */2 * * * | refused: more than one time per day | single event |
| 0 9 1 * 1 | refused: cron ORs day-of-month with day-of-week; RRULE would AND them | single event |
| Calendar INTERVAL=2, COUNT, BYDAY=1MO, BYMONTHDAY=-1, BYHOUR | no cron equivalent | refused, nothing changes |

"Back to cron" is canonical numeric form (Sun = 0). A converted job's minute and hour must be single
values, and the wall time must exist exactly once on every day for the next ~800 days in its zone.
A FREQ=WEEKLY with no BYDAY takes its weekday from the event start.

Migration. A job that already has a single event gets convert_to_recurring: the SAME event is
patched (summary, description, tags, start = next run + timeZone, recurrence). No delete, no new
id, no orphan. Like every write it appears in the dry-run first and needs --confirm.

Reading. Events are listed with singleEvents=false&showDeleted=true (masters, singles, moved and
cancelled instances), and each current master's /instances?showDeleted=true is read for the next 60
days. Moved/cancelled instances are matched by recurringEventId + originalStartTime. Instances
before the job's next run are history and ignored. An event turned into an all-day event has no timed
start: a single event is patched back to its cron time, a master or split series is reported and refused,
and a moved all-day instance is ignored.

What each Google edit does on the next sync (dry-run shows it; --confirm applies it):

  1. "This event" (move one instance) -> a one-shot copy at the new time, exactly as for single
    events: --confirm refuses without --allow-additional-run (the original slot ALSO runs), or use
    --skip-original=experimental. The series is unchanged. The moved instance itself is the copy's
    calendar entry (no extra event, not retagged). Moving it again moves the copy; deleting it pauses the
    copy (soft pause, as for any deleted event). An instance edited without changing its time (e.g.
    retitled) is not a move and plans nothing.
  2. "All events" (change the series' time or rule) -> the cron schedule follows only if the new
    RRULE converts back cleanly (e.g. 09:00 -> 10:00 gives 0 10 * * *; daily -> Mon/Wed gives
    0 9 * * 1,3) and the new wall time is DST-safe in the job's zone (e.g. 02:30 Sydney is refused,
    as in the cron -> RRULE direction). Otherwise (or if the series' time zone changed) the sync reports
    it, refuses, and changes nothing. Undo it in Calendar or edit the job in Hermes; cron wins on the next sync.
  3. "This and following" -> Google ends the old master with UNTIL and creates a new series, usually
    with id <old id>_R<date>, sometimes without our tags. The plugin treats it as a series reschedule:
    it adopts the new series (adopt_split: tags it, makes it the job's event), the cron takes the new
    series' rule, and the old master stays untouched as history. It is never deleted, never becomes a
    second job, and is never orphaned (state keeps it under history_ids). Only a series tied to ours is
    accepted: tagged with this job id, or untagged with id prefix <our master id>_R. Untagged events are
    otherwise still ignored. If the old series still has runs left (its UNTIL is after the job's next run),
    the cron change is deferred (split_pending) until the first confirmed sync after the old series
    ends, so the remaining old-rule runs still fire. A split that starts at the original start (only the
    rule changed) is handled the same way, with an immediate cron change. Cron cannot start on a date, so
    the new series is adopted only if its first event is exactly where cron would next run after the old
    series ends; a gap (e.g. the continuation moved to next month) or a changed start date is reported and
    refused. If the new rule does not convert (including a DST-unsafe time), nothing is adopted and
    nothing changes. An UNTIL with no follow-on series is reported (cron jobs
    cannot end on a date).
  4. Delete one instance -> Hermes cannot skip a single run. Without the flag the sync reports can't skip a single run ... nothing changed -- that run WILL still happen under info (informational, not
    a pending op: ops stays empty). A confirmed sync records it in state and it is not shown again. With
    --skip-original=experimental it uses the experimental skip below (the next occurrence only; books
    one "missed" ledger row). No copy is created.

Cron wins. Editing the job in Hermes rewrites the master's start/RRULE (and drops pending move or
skip work, as before). Deleting the whole series is the usual soft-delete pause; after
hermes cron resume <id> the next confirmed sync recreates the master. A removed job's current master
is deleted; history masters never are. If a cron edit makes the job non-convertible, its master is
converted back to a single patch-forward event (convert_to_single).

Fallback (single events). hermes hermes-jobs mode single switches the board back to single events
for everything: the next confirmed sync converts each master into a single event in place (PATCH
recurrence: [], start = next run; same id), and from then on the original single-event path runs
unchanged (tests assert it plans identically to it). mode recurring switches back. Moved instances
that already became one-shot copies stay as they are.

Deleted events: pause and recovery

Every deletion reports job <id> paused because its calendar event was deleted; resume with hermes cron resume <id>.
The CLI JSON ops/messages and status show this information. In a dry-run it is a proposed action,
not a claim that the job is already paused. Applied sync state keeps paused_by_delete and the reason,
including for a job that was already paused. Its deleted event is not recreated while paused.
Resume explicitly in Hermes, for example hermes cron resume 28c2169b1b51; the next confirmed sync
recreates its event and clears the tombstone. Calendar cannot resume it because its event is gone.

Moving one occurrence (important limitation)

Moving the board's next occurrence of a recurring cron/interval job plans an absolute ISO one-shot
with repeat=1, named <name> (moved <date>). It copies the source's prompt, delivery, skills,
script/monitor, tool allowlist (including []), context, workdir and other supported execution fields.
Only trusted cron configuration is copied; the Calendar description is never used as instructions.
The moved event is retagged to the new job; source_job_id links it locally. On the following sync
the source gets a fresh event previewing its following recurring slot. Completion deletes the copy's
event (not its cron history); deleting that event before it fires pauses just the copy. Completed
copies do not respawn. Rearming/resuming remains an explicit Hermes action.

Hermes currently has no public skip-occurrence action. The original occurrence WILL still run.
Creating a copy is therefore an additional run, not an exact reschedule. Both the plan and CLI
return this warning. Ordinary --confirm refuses this case. After reviewing the dry-run, choose
one of two options:
hermes hermes-jobs sync --confirm --allow-additional-run explicitly accepts both executions (the
default and recommended choice), or --skip-original=experimental (below). Without the experimental
flag, the repeating schedule is never rewritten and the source is never paused. The source calendar
event's preview does not mean the still-pending original Hermes slot was suppressed. If duplicate
execution is unacceptable and you do not opt in to the experiment, cancel the move in Calendar instead.

Series edit while a move is pending: if the source's schedule, prompt or copied configuration
changes before the copy fires, the next confirmed sync drops the move. It removes the copy job and
its event, undoes any experimental skip, and re-plans the source from cron (cron wins). Renaming the
job is not a series edit.

EXPERIMENTAL: --skip-original=experimental (true skip, off by default)

Hermes has no skip action, so this combines only public cronjob_manage actions. Slot S is the
original occurrence:

  1. At --confirm (S must be at least 5 min away, the schedule must be a cron expression, and
    the job must be enabled with next_run_at == S), in this order:

    • Journal the job's original grace_minutes/catch_up in state.
    • Pause the job with reason hermes-jobs skip {"k":<key>,"slot":...,"grace":...,"catch_up":...}.
    • Set grace_minutes=0 (Hermes floors this at 120 s). Set catch_up=false only if it was
      explicitly true; an absent value is left absent, because an explicit grace already disables
      catch-up, and cronjob_manage cannot clear it back to absent.
    • Create two one-shot no_agent script jobs (no LLM, deliver: local):
      hermes-jobs skip-tick <key8> #1 at S+10 min and #2 at S+25 min. Both run the installed
      ~/.hermes/scripts/hermes-jobs-skip-tick.py, which runs hermes hermes-jobs skip-tick --quiet
      and prints nothing on success. These jobs never appear on the board.
    • Create the one-shot copy at the moved time, as in the default mode.

    We pause right away instead of just before S, because S is the board's next occurrence, so
    no other slot falls in that window. The job shows as paused in Hermes until S+10 min, but stays
    active on the board.

  2. S+10 min (wake-up #1, or any later sync/skip-tick): resume. Hermes keeps an elapsed slot
    due on resume (#113603). It is past the 120 s grace and catch-up is off, so the next due scan
    skips S, books one missed ×1 row in the cron execution ledger ("skipped, catch-up off"),
    and emits a missed metric. It sends no chat notification.

  3. S+25 min (wake-up #2, or any later sync/skip-tick): once next_run_at is past S, restore
    the original grace_minutes (cleared if it was absent) and catch_up.

Before acting, every step checks that the job is unchanged (same fingerprint), is still paused by
this marker
, and still has the same slot. Otherwise it aborts with a warning and restores the
grace/catch_up settings:

  • A job that was paused manually is left paused.
  • A job that was resumed manually is not touched; its original slot may run.
  • An edited job is resumed (the pause was ours).
  • An interrupted arming is undone.
  • A slot that ran anyway is reported (skip failed).
    The copy is independent of the skip and runs regardless. If sync state is lost while the job is
    paused, the skip is rebuilt from the pause marker. If state is lost after the resume, nothing marks
    the job anymore. The grace_minutes=0 override (and catch_up=false, if it was set) then stays and
    has to be restored by hand with hermes cron edit.

Limitations: it is experimental, cron expressions only, and depends on the gateway ticker scanning
within ~15 min of S+10. The skip shows up in Hermes run history as a miss, not as a deliberate
skip. A clean fix needs a Hermes skip action (see below).

Installed-source verification: tools/cronjob_tools.py defines actions create/list/update/pause/resume/
remove/run/cancel, but no skip. _HANDLER_FORWARDED_ARGS is the copy-field contract; the handler also
accepts monitor_script/monitor_url. cron/jobs.py:parse_schedule accepts offset-aware ISO timestamps
as kind=once. Its advance_next_run(s) is ticker bookkeeping calculated from now, not a future
slot-specific skip (calling it before a future slot leaves that slot unchanged), so it is not used.
hermes cron resume --at only re-arms one-shots; it cannot skip one recurring occurrence.

Pinned inference settings/interpreter overrides that dispatch cannot preserve cause the move to be
refused, not silently dropped. Source origin delivery is resolved from its recorded origin, not the
current CLI session; unresolved origin targets are refused. Pending ownership transfers are journaled
and retried without another create. After an uncertain create result, the retry first adopts an
existing copy with the same name and one-shot instant, and only creates one if none is found.
Sync is not a transaction across cron and Google; run a fresh dry-run after any partial error.

Offline mode projects against an empty calendar and empty sync history, so it never interprets
unfetched events as deletions. It does not bind a calendar, call Google, or change cron/state.

Derived ids (reference stability)

Every scheduled occurrence has a stable occurrence key:
uuid5(3ac1ead0-9909-5599-8d64-da65c43647dc, "<job_id>@<instant as ISO UTC>"), written as 32 lowercase
hex. The namespace is a fixed literal and must never change. The instant is canonicalised to UTC, so
09:00+11:00 and 22:00Z give the same key. Hex is a subset of Google's event-id alphabet (base32hex
a-v0-9, 5-1024 chars), so the key doubles as a client-chosen Google event id.

  • Inserts use the key of the occurrence the event shows as its Google event id. A retried insert
    gets 409, and that means "already exists, adopt it": the plugin reads the event and patches it to
    the desired body. status: confirmed brings back a cancelled event that reused the id. An id owned
    by a different job's tag is refused, never taken over.
  • A recurring job's event shows its next occurrence and is patched forward, not recreated. The id
    is fixed when the event is inserted. Each advance patches start and the private
    hermes_occurrence tag (the key of the occurrence it now shows) on the same event. That is one
    write per advance, never a delete plus insert, and Google keeps no trail of dead event ids. Insert
    retries stay idempotent through the derived id. Finding "what is tied to slot X" means matching
    hermes_occurrence, or the copy's hermes_original_slot, not the event id.
  • Every write sends the full private tag set (hermes_job_id, hermes_occurrence, and for
    copies hermes_source_job_id + hermes_original_slot), so a PATCH can never strip the job tag.
  • One-shot copies and skips are recorded in sync state under occurrences[<key of the original slot>]. Each record holds the source job, child job, event, mode, status, and the source's
    fingerprint: a digest of schedule + copied execution config, without name, enabled or grace.
  • Rebuilding after a lost state file: calendar events are found by hermes_job_id and adopted,
    never deleted or recreated. Copy links come back from the event tags. A pause caused by deleting
    its event is recognised from Hermes' own paused_reason (hermes-jobs: calendar event deleted),
    so the deleted event is still not recreated. An armed experimental skip comes back from its pause
    marker (see below); its wake-up jobs are matched by name AND the installed script, never by name alone. A copy is owned by provenance, never by name: before creating it,
    the occurrence record journals the ids of any one-shot jobs that already have the copy's name and
    instant (they are never ours). If the create's result is lost, the retry adopts only the one job that
    appeared since that journal with exactly the copied execution config; anything ambiguous or different
    is refused, nothing is created twice, and the copy never gets its own duplicate event. Dropping a move
    removes only a copy recorded as created by the plugin (child_owned); any other job is left in place
    with a message.
  • Existing events keep their Google-assigned ids (the two live probe events). They are adopted
    by tag, and they pick up hermes_occurrence on their next patch-forward. Nothing is deleted or
    recreated to migrate them.

Repo

https://github.com/CrowBe/hermes-jobs

README from the reviewed commit 34c0490; it updates when the author re-pins.

Developer Mode

Explore built-in Hermes toolsets and actions.

HermesAgent

Developer?

Create a Hermes extension for Desktop or agent workflows.

Developer Section