From zombie-brains
Design and ship scheduled or event-driven agent routines — cron timers (daily pipeline checks, hourly catch-ups) and webhook triggers (fire on Fathom recording.completed, etc.). Use whenever the user wants an agent to run on its own — "every hour", "weekdays at 9am", "when a webhook arrives", "daily sweep", "monthly digest". Covers cron expression discipline, idempotent input_message design, channel posting + quiet_response_regex, webhook filters, the test-before-enable lifecycle, and debugging failed invocations.
How this skill is triggered — by the user, by Claude, or both
Slash command
/zombie-brains:zombie-build-routinesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
A routine is a named recurring invocation of an agent's chat-runtime. It fires (cron tick OR webhook event) → the agent receives an `input_message` → the agent runs its full tool loop → the response optionally posts to channels and writes memories.
A routine is a named recurring invocation of an agent's chat-runtime. It fires (cron tick OR webhook event) → the agent receives an input_message → the agent runs its full tool loop → the response optionally posts to channels and writes memories.
This is distinct from one-off tool execution. A tool returns data. A routine drives an agent.
| User intent | Build |
|---|---|
| "Every hour, check X" / "weekdays at 9am" / "monthly digest" | cron routine |
| "When X arrives, react" / "fire on every Fathom recording" | webhook routine |
| "Run this once, right now" | manage(fire_routine) on an existing routine, OR direct agent chat call |
| "On every email to support@" | Connector + inbound-events translator → may fire a routine, but the connector is the primary primitive |
If the user wants time-based catch-up, use cron. If they want event-driven reaction, use webhook. If they want both, you'll need two routines on the same agent.
Standard 5-field cron: minute hour day-of-month month day-of-week. Day-of-week is 0-6 (Sunday=0).
| Pattern | Cron |
|---|---|
| Every minute | * * * * * |
| Every hour (top of hour) | 0 * * * * |
| Every 15 minutes | */15 * * * * |
| Daily at midnight | 0 0 * * * |
| Daily at 9am | 0 9 * * * |
| Weekdays at 9am | 0 9 * * 1-5 |
| First of the month | 0 0 1 * * |
| Every Monday + Wednesday at 14:30 | 30 14 * * 1,3 |
Timezones use IANA names — UTC (default), America/Los_Angeles, America/New_York, Europe/London, Asia/Tokyo, etc. The cron is interpreted in that timezone, so "0 9 * * 1-5" with America/Los_Angeles fires at 9am Pacific each weekday — regardless of where the server runs.
Helpers:
manage(parse_schedule, {schedule_text: "every weekday at 9am"}) → returns cron expression + the next 5 fire times in your chosen timezone. Use when the user gave a plain-English schedule.manage(describe_cron, {cron_expression: "0 9 * * 1-5", timezone: "America/Los_Angeles"}) → round-trips back to English ("At 09:00 AM, Monday through Friday") + next 5 fires. Use to sanity-check a hand-written cron BEFORE create_routine.Cron gotcha: */N only behaves cleanly when N divides 60 (use */1, */5, */15, */30 for minutes; */2, */3, */4, */6, */8, */12 for hours). */7 * * * * will surprise you — cron resets on minute 0, so it fires at :00, :07, :14, :21, :28, :35, :42, :49, :56, then jumps to :00 of the next hour (a 4-minute gap, not 7).
The input_message is fed verbatim to the agent on every fire. There is no cross-fire state. Imagine firing it at 3am Sunday — does it still make sense?
Good patterns (idempotent, state-self-discovers, repeatable forever):
Anti-patterns that break re-runs:
The agent re-discovers state from the world on each fire (Fathom API, CRM, memories) — not from the previous fire's output.
After the agent responds, optionally fan out the response to channels (Discord, Slack, email, etc.).
Get channel IDs from manage(list_channels). Never invent channel UUIDs.
Side effects belong in the agent's tool grants. Channels carry the human-readable summary. The agent updates HubSpot, sends emails, writes memories via its own tools — channels are not how those happen.
Don't add the same channel ID twice — fan-out posts once per entry, you'll double-post.
A JavaScript regex pattern matched against the agent's response after each fire. On match: routine still succeeds, still logs, still runs the compliance classifier, still persists the full response on the invocation row — only the channel-write step is skipped.
Use case: Atlas's hourly Fathom catch-up. 95% of fires return "No new recordings to process". Discord doesn't need 23 such posts a day, but you still want the dashboard to show the routine ran cleanly.
Pattern is the regex source only — no slashes, no flags, always case-sensitive. JavaScript regex flavor (compiled with new RegExp(pattern)).
{
"quiet_response_regex": "^\\*?\\*?No new recordings to process|^\\*?\\*?No (new )?meetings (were )?completed"
}
The \\*?\\*? allows for the agent occasionally bolding the leading phrase. Test patterns at regex101.com (flavor: JavaScript).
Pass null or "" to clear. Bad regex doesn't fail the fire — it logs a warning and posts normally so you can fix it via another update_routine call.
JSON-escape backslashes. \\* in JSON becomes \* in the regex. \\\\ becomes \\ (literal backslash).
trigger_type: 'webhook' + webhook_connector_id (from manage(list_connectors) — must be a webhook-source connector).
The routine fires on every event the connector receives, unless you narrow it with routine_event_filter.
routine_event_filter supports simple equality on payload fields:
event.type=='recording.completed'
data.status=='completed'
event.kind!='heartbeat'
Supported operators: ==, !=. Literals can be quoted strings, unquoted numbers (42, -1.5), or bare true / false / null. Dotted paths walk into nested objects: data.recording.id.
Not a full expression language — no &&, ||, <, >. For complex routing, route via a webhook translator (V8 sandbox) that emits a clean event type, then filter on that.
Distinct from webhook_event_filter on list_webhook_events — that one is a status enum (processed, failed, etc.), this one is a payload field filter.
Payload templating: webhook routines can interpolate payload fields into input_message via {{path.to.field}}:
{
"input_message": "Recording {{data.id}} just completed (duration {{data.duration_min}} min). Pull the transcript with extract_fathom_recording and summarize."
}
Missing paths render as empty string. Use expand template-friendly inputs when authoring the prompt.
Webhook routines fire on EVERY matching event. A single 200-event webhook burst = 200 fires. Use routine_event_filter aggressively, and verify your input_message is idempotent (the same recording.completed event could deliver twice on retries).
RECOMMENDED PATTERN:
create_routine with enabled: false — schedule is stored, no autonomous fires yetfire_routine({routine_id}) — manually trigger oncelist_routine_invocations({routine_id}) and get_routine_invocation({invocation_id}) — read the full response, logs, error, classifier verdictupdate_routine({routine_id, enabled: true}) — start the scheduleinput_message / cron / channel ids → fire again → inspect → enableThe create_routine response will nudge you toward this. Don't ship an enabled routine on the first try unless the directive is trivial and the agent's tools are obvious.
Why? An enabled routine with a broken input_message will soft-fail every fire forever, polluting the brain with routine_failure_soft memories and burning Anthropic tokens. Test-first costs one extra fire_routine call.
create_routine (enabled=false) ──► fire_routine (test) ──► inspect invocation
│
├─► clean ─► update_routine(enabled=true) ──► routine fires on schedule
│ │
└─► broken ─► update_routine(input_message=…) ──► retry
│
update_routine(enabled=false) ◄┘ (pause without deleting)
│
delete_routine (terminal)
Pause vs delete: enabled=false keeps history (every past invocation row) and lets you flip back on. delete_routine cascades — invocation history goes with it.
| Action | What you get |
|---|---|
manage(fire_routine, {routine_id}) | Manual trigger. Returns invocation_id. Runs in background. |
manage(list_routine_invocations, {routine_id}) | Last 50 fires, with status + 200-char response preview. Pass failed_only: true to scope to failures. Pass no routine_id to scan all your routines (health audit). |
manage(get_routine_invocation, {invocation_id}) | Full row: complete response, error text, logs[] timeline, tool_call_count. |
Reading the invocation row:
status: queued → running → succeeded / failedfailure_kind: null (success), hard (chat-runtime threw — missing variable, deleted dependency), soft (agent gracefully refused — wrong tool in directive, capability mismatch). Hard fails restore by fixing the dependency. Soft fails restore by refining the directive.logs[]: timeline of {ts, level, message}. Search for Compliance classifier: to see the soft-fail verdict + raw model output.response: the agent's full reply. response_preview on the list view is truncated at 200 chars.tool_call_count: serverless V8 tools tracked in sandbox_executions only. MCP relay tools and built-in MCP tools are NOT counted here. Don't read tool_call_count=0 as "agent called no tools" if the agent has relay or built-in tools in scope.Compliance classifier: after every successful fire, a tiny Haiku call judges whether the agent actually performed the directive (vs. talking about it / refusing / asking for clarification). Verdict appears in logs[]. Noncompliant → routine flips to failed with failure_kind: soft and a routine_failure memory is written.
Every routine is pinned to a workspace via owner_user_id. When fire_routine runs (cron tick or manual), the agent runs in the routine's workspace context — not the caller's. A consultant managing a child workspace can edit, fire, and delete that child's routines through tree-walk access, but the runtime is always the child workspace (its memories, its tool grants, its channel posts).
Consultant access (humans): manage(list_routines) is tree-walked — it returns routines across every workspace the consultant can reach, with a workspace_user_id field per row so you can see which workspace each one belongs to. update_routine / fire_routine / delete_routine / list_routine_invocations / get_routine_invocation all honor the same scope.
Agent access: an agent calling these actions is strictly limited to its own workspace — no tree walk, no cross-workspace visibility. Routines an agent creates inherit the agent's workspace.
{
"action": "transfer",
"resource_type": "routine",
"resource_id": "<routine-id>",
"to_workspace_user_id": "<new-workspace-owner-user-id>"
}
This shifts agent_routines.owner_user_id to the new workspace owner. The routine still references its agent_id. If the agent stays in the old workspace, the routine will still resolve at fire time (as long as both routine and agent live in workspaces the firing caller can reach) — but at runtime, the agent's workspace boundary kicks in, so memories and tool calls land in the agent's workspace, not the routine's. Cleanest pattern: transfer the agent too if you're transferring its routines. Otherwise the routine's workspace and the runtime workspace diverge and the brain it writes into is no longer the brain it lives in.
input_message? Re-runs lose meaning. Relative time, one-shot directives, stateful continuation all break. Imagine the 3am Sunday fire.agent_capabilities.tool_names. Cross-check your directive against that list. If the agent has no tools, the directive can only ask for reasoning.*/N minutes with N not dividing 60? Use */1, */5, */15, */30. Skip */7 and */11 — they reset on the hour.America/Los_Angeles or wherever.target_channel_ids listing the same channel twice? Fan-out posts once per entry — duplicates → double-post.quiet_response_regex with unescaped backslashes in JSON? \* in the regex requires \\* in the JSON string. Test it via update_routine once — bad regex logs a warning but doesn't fail the fire.routine_event_filter? Every event from the connector fires the routine. For high-traffic connectors (Fathom recordings, Stripe webhooks), narrow aggressively.enabled: false, fire once, inspect, then flip on. The create_routine response nudges you to this.list_routine_invocations({failed_only: true}). Refine the input_message to match the agent's actual tool grants, or grant more tools via the permission set.{
"action": "create_routine",
"agent_id": "<oliver agent id>",
"name": "Oliver daily pipeline check",
"cron_expression": "0 7 * * 1-5",
"timezone": "America/Los_Angeles",
"input_message": "Review every open deal in HubSpot. For each: pull last contact date, last stage change, and any recent notes. Flag deals stalled >14 days. Write a daily digest memory tagged 'sales_digest' summarizing flagged deals, new wins, and recommended next actions. Post a concise summary to the channel.",
"target_channel_ids": ["<slack-sales-channel-id>"],
"enabled": false
}
Verify with fire_routine → get_routine_invocation → check response reads like a real digest → update_routine(enabled: true).
{
"action": "create_routine",
"agent_id": "<atlas agent id>",
"name": "Atlas hourly Fathom catch-up",
"cron_expression": "0 * * * *",
"timezone": "UTC",
"input_message": "Check Fathom for any recordings completed since the last run. For each new recording, call extract_fathom_recording to pull the transcript and summary, then save an insight memory tagged 'atlas_meeting_extract' with the key decisions, action items, and prospect signals.",
"target_channel_ids": ["<discord-atlas-channel-id>"],
"quiet_response_regex": "^\\*?\\*?No new recordings to process|^\\*?\\*?No (new )?meetings (were )?completed",
"enabled": false
}
Channels stay quiet 95% of the time but every fire is on the invocation history for auditability. Compliance classifier still runs.
{
"action": "create_routine",
"agent_id": "<atlas agent id>",
"name": "Atlas — react to fresh Fathom recording",
"trigger_type": "webhook",
"webhook_connector_id": "<fathom-webhook-connector-id>",
"routine_event_filter": "event.type=='recording.completed'",
"input_message": "Fathom recording {{data.recording.id}} just completed (duration {{data.recording.duration_min}} min, host {{data.host.name}}). Call extract_fathom_recording with that id, save the insights, and post a one-paragraph summary highlighting any prospect signals or follow-up actions.",
"target_channel_ids": ["<discord-atlas-channel-id>"],
"enabled": false
}
This replaces the hourly polling cron with real-time reaction. Test by firing the webhook (or fire_routine with a synthetic payload preview) and inspecting the invocation.
zombie-build-tools — if your routine fires a serverless V8 tool, that tool's authoring discipline matters (env vars, fetch patterns, errors)zombie-build-translators — for the webhook connector + translator pipeline that feeds webhook-triggered routineszombie-build-permissions — the agent must have a permission set granting the tools the input_message references; check agent_capabilities.tool_names in the create_routine responsezombie-create-agent — wizard for novices building the agent itself; come back here once the agent exists and the user wants to schedule itnpx claudepluginhub zombie-brains/zombie-brains --plugin zombie-brainsProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Searches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.