name: alfred-chore-management description: Inspect, trigger, pause, and reason about Sir's scheduled chores. Chores are recurring Temporal workflows — some shipped as platform built-ins, some bespoke Python generated for Sir during onboarding. Use whenever Sir asks about what's running on a schedule, wants to manually trigger a chore, pause/unpause one, or check why a chore hasn't produced results. version: "1.1" metadata: openclaw: emoji: "⏰"
Alfred — Chore Management
Chores are Sir's recurring background workflows. They live in the vault as chore/ records and are paired with a Temporal schedule named chore-<slug>.
Two tiers — and why this matters before you diagnose
Every chore is one of two tiers. Telling them apart is the single most common place a fresh agent gets it wrong.
| Tier | Where the Python lives | Who authored it | /source endpoint returns |
|---|---|---|---|
| Standard library | Inside the alfred-learn Docker image at packages/learn/src/workflows/chores/<template>.py (in the alfred-platform repo) |
Platform team. Same code on every tenant. | {generated: false, tier: "standard-library", builtin_path: "..."} |
| Generated | Per-tenant file at /alfred-data/user-chores/<template>.py |
Opus, during onboarding, against Sir's specific matters | {generated: true, tier: "generated", source: "<full .py>"} |
Today's standard-library templates: daily_morning_briefing, daily_evening_digest, subscription_watcher, weekly_matter_digest. Anything else is generated.
The morning brief and evening digest form a continuous narrative loop. The brief reads last night's digest as its starting point; the digest reads this morning's brief. Both persist their output as
event/daily-brief-<date>.mdandevent/daily-digest-<date>.mdso each can find the other. If Sir asks why the brief feels disconnected from yesterday, check whether the digest landed (event/daily-digest-<yesterday>.mdshould exist).
Warning —
generated: truein frontmatter does NOT mean the chore is in the generated tier. When a template gets promoted from generated to standard-library, existing chore records keep their originalgenerated: trueflag. The/api/v1/chores/<slug>/sourceendpoint detects promoted templates and returns the right tier regardless of the flag. Trust thetierfield in the response, not the frontmatter flag.
Warning — a 404 from
/api/v1/chores/<slug>/sourcedoes NOT automatically mean the chore is broken. For standard-library chores the endpoint returns{generated: false, tier: "standard-library"}(200 OK) — the .py living outside/alfred-data/user-chores/is the correct, healthy state. If you do see a 404 withtier: "generated-orphaned", cross-check before declaring breakage: read/api/v1/schedules/chore-<slug>forActionCounts.Totaland read the chore body's## Run logsection. If both show recent activity, the chore is running fine via a built-in workflow class — the platform's promotion list just needs updating; the chore itself is healthy.
Two layers to a chore
- Vault record (
chore/<slug>.md) — the user-facing configuration: name, schedule, status, description, run log, quarantine state. - Temporal schedule — the actual recurring invocation, registered with Temporal. Its ID is
chore-<slug>.
Both layers must stay in sync. Use the self MCP tool to query both sides.
Endpoints for chore management
Read
self endpoint="/api/v1/chores"— list all chore records, with frontmatter (name, schedule, status, template, quarantine, last_run).self endpoint="/api/v1/chores/{slug}"— read the full chore body, including run log and generated description.self endpoint="/api/v1/chores/{slug}/source"— chore source + tier. For standard-library chores returns{tier: "standard-library", builtin_path, source: null}. For generated chores returns{tier: "generated", source: "<full .py>"}. For a missing-but-claimed-generated file returns 404 withtier: "generated-orphaned"and a hint about how to disambiguate.self endpoint="/api/v1/schedules"— list all Temporal schedules. Cross-reference with vault records to spot orphans.
Act
self endpoint="/api/v1/chores" method="POST"— create a new chore from scratch. See "Creating a chore" below.self endpoint="/api/v1/chores/{slug}/trigger" method="POST"— manually fire a chore once, out of cycle. Use when Sir says "run the cashflow forecast now".self endpoint="/api/v1/chores/{slug}/pause" method="POST"— pause a chore (both vault record and Temporal schedule).self endpoint="/api/v1/chores/{slug}/resume" method="POST"— resume a paused chore.self endpoint="/api/v1/chores/{slug}" method="DELETE"— remove a chore and its schedule.
Creating a chore
When Sir asks for a new recurring job ("every morning, tell me what happened overnight", "every Friday summarize the week"), you are expected to design and install it yourself via POST /api/v1/chores.
Body shape
{
"slug": "daily-morning-briefing", // kebab-case, unique
"workflow_class_name": "DailyBriefingWorkflow",
"python_source": "<full .py file>",
"schedule": "30 4 * * *", // 5-field cron
"name": "Daily morning briefing", // optional
"user_facing_description": "...", // optional, shown in dashboard
"params": { "preview_only": false }, // optional; chore_slug is auto-added
"tags": ["morning", "digest"], // optional
"overlap_policy": "Skip", // optional, default "Skip"
"restart_worker": true // optional, default true
}
The endpoint writes three things atomically: the .py file under /alfred-data/user-chores/, the chore/<slug>.md vault record, and the Temporal schedule chore-<slug>. On failure it rolls back so you never end up with partial state.
The three primitives — compose these, don't reinvent
A chore workflow is plain Python plus calls to three generic activities. You don't write new activities per chore; you compose from these.
call_self(endpoint, method="GET", body=None, query=None) -> dict
The same surface as the self MCP tool you use. List matters, read events, write vault records, trigger sessions, read schedules — anything ctrl-api does. method is "GET" by default; use "POST" / "PATCH" / "DELETE" for writes.
call_composio(action, arguments=None) -> dict
Execute any Composio action: GMAIL_SEND_EMAIL, GOOGLECALENDAR_CREATE_EVENT, NOTION_CREATE_PAGE, GITHUB_CREATE_ISSUE, SLACK_POST_MESSAGE, 1000+ more. Check the per-app SKILL.md for action names and argument schemas.
spawn_subagent(prompt, agent_id="learn-clerk", timeout_s=300) -> str
Fire-and-wait a subagent on openclaw-workers. Only use when the step genuinely needs LLM reasoning — filtering, formatting, aggregating, deduping all stay in the workflow's Python body where they're cheaper and deterministic. The default learn-clerk has self + composio tools in scope so the subagent can continue working autonomously within the one turn.
Rule of thumb: if the step's output can be described as "return this structured data", do it in plain Python with call_self / call_composio. If the step is "decide what matters" or "write this as a paragraph", use spawn_subagent. Put as little logic as possible inside subagents — they are 100× slower and 1000× more expensive than a ctrl-api call.
Legacy activities. The manifest also exposes fetch_financial_events, write_matter_digest_via_llm, ask_alfred_to_judge_anomalies, fetch_matter_events_last_week, save_digest_to_vault, send_chore_notification, and others. These are bespoke helpers kept around for existing generated chores. Do NOT use them for new chores — reach for the three primitives instead. Most bespoke LLM activities can be replaced by a well-prompted spawn_subagent; most bespoke data activities become a call_self + a filter loop. The exception is send_chore_notification, which is the sanctioned delivery path and should still be used for the final "deliver to Sir" step (it's also achievable via call_self to /api/v1/notifications, but the helper does the right formatting).
Fetch the full list with self endpoint="/api/v1/chore-actions" before writing Python. If you reference an activity not in the manifest, the validator will reject the source.
Writing the Python source
The validator enforces:
- Size < 100KB, valid Python syntax.
- Only imports from:
__future__,dataclasses,datetime,typing,json,temporalio.workflow,temporalio.common,src.workflows.chores._base,src.activities.chore_actions. - Module scope: only class defs, function defs, imports, one docstring,
with workflow.unsafe.imports_passed_through():, and literal constants. - Exactly one
@workflow.defnclass with exactly one@workflow.runmethod. - No forbidden names:
eval,exec,open,compile,__import__,getattr/setattr/delattr,globals/locals,vars,dir,breakpoint. - No non-deterministic calls at workflow scope:
datetime.now,random.*,uuid.*,time.time. - Every
workflow.execute_activity(<name>, ...)must reference a name imported from the two allowed modules.
Before submitting, sanity-check the chore_actions manifest: self endpoint="/api/v1/chore-actions" returns every activity name you're allowed to import plus its reads/writes/llm metadata. If the activity you need isn't in the manifest, you can't use it — fall back to a subagent step or propose a new activity to Sir for platform work.
Example: "every Friday, summarise my week and email me"
"""Every Friday 17:00 — weekly summary email."""
from __future__ import annotations
from datetime import timedelta
from temporalio import workflow
with workflow.unsafe.imports_passed_through():
from src.activities.chore_actions import call_self, call_composio, spawn_subagent
@workflow.defn
class WeeklySummaryEmailWorkflow:
@workflow.run
async def run(self, chore_slug: str) -> dict:
# 1. Structured pull — plain Python, cheap.
matters = await workflow.execute_activity(
call_self,
args=["/api/v1/vault/list/matter", "GET"],
start_to_close_timeout=timedelta(seconds=30),
)
events = await workflow.execute_activity(
call_self,
args=["/api/v1/streams/events", "GET", None, {"limit": "200"}],
start_to_close_timeout=timedelta(seconds=30),
)
# 2. LLM reasoning — single subagent call, returns prose.
body = await workflow.execute_activity(
spawn_subagent,
args=[
f"Summarise this week for Sir. Matters: {matters}. "
f"Recent events: {events}. Write 4-5 crisp bullets plus one "
f"headline sentence. Butler tone. Reply with email body only.",
"learn-clerk",
300,
],
start_to_close_timeout=timedelta(minutes=6),
)
# 3. Delivery — Composio for the actual email send.
await workflow.execute_activity(
call_composio,
args=[
"GMAIL_SEND_EMAIL",
{
"recipient_email": "sir@example.com",
"subject": "Weekly summary",
"body": body,
},
],
start_to_close_timeout=timedelta(seconds=60),
)
return {"delivered": True, "bytes": len(body)}
Three activity calls. All three are generic. No bespoke fetch_weekly_events or write_weekly_summary_via_llm needed.
After creation
restart_worker: true(the default) triggers an alfred-learn restart so the dynamic loader picks up the new template. The first scheduled run then fires normally. If the restart is rate-limited (429), the response includes arestart_errorand you must callself endpoint="/api/v1/admin/restart-learn" method="POST"manually before the first cron tick.- New chores start with
quarantine: false— they run live from the first tick. (Onboarding-generated chores go through 3 dry-runs because they're bulk-generated; chores you create on request have been consciously authored, so we skip quarantine.) - Fire one real run via
self endpoint="/api/v1/chores/{slug}/trigger" method="POST"to verify before the first scheduled tick.
Related
self endpoint="/api/v1/workflows"— find recently executed workflow runs, including chore runs.self endpoint="/api/v1/workflows/{wfId}"— inspect a specific workflow execution.
Chore anatomy (what to tell Sir if he asks)
Each chore record frontmatter has:
name— human-readable labeltemplate— the Python module name (e.g.tuesday_cash_flow_forecastordaily_morning_briefing). For generated chores it's the file stem under/alfred-data/user-chores/; for standard-library chores it's the file stem underpackages/learn/src/workflows/chores/in the platform image.workflow_class_name— the Python class (e.g.TuesdayCashFlowForecastWorkflow)schedule— cron expression (e.g.0 18 * * 2= every Tuesday 18:00)schedule_id— Temporal schedule id, prefixedchore-status—active|paused|completedgenerated: true | false— historically authoritative, currently advisory.truewas set at creation time when the template lived in the generated tier; it is not rewritten when a template is promoted to the standard library. Treat it as a hint, not as truth — call/api/v1/chores/<slug>/sourceand read itstierfield for the authoritative answer.quarantine: true, quarantine_remaining: N— new generated chores run in dry-run mode for 3 cycles before going livelast_run— ISO timestamp of the most recent run. Updated byrecord_chore_runafter every cycle. If you need a richer history, read the body's## Run logsection.last_result— short summary of the most recent run (≤200 chars), e.g."4 events from 9 matters, briefing sent"or"[dry-run] quarantine dry-run (remaining before this: 2)".user_facing_description— a plain-English paragraph describing what it does
The body of the record has a ## Run log section with entries like - 2026-04-09T18:00:00.000Z: [dry-run] 0 anomalies.
Quarantine explained
When onboarding generates a new chore template, the first 3 scheduled runs are marked [dry-run]:
- Dry-run: the workflow executes the detection phase but skips notifications + vault writes.
- If all 3 dry-runs complete without error, quarantine clears automatically and the chore goes live.
- If any dry-run errors, the chore is paused pending review.
- If Sir wants to shorten or skip quarantine for a known-good chore, update
quarantine_remainingviaself endpoint="/api/v1/vault/records/chore/<slug>.md" method="PATCH" body={"set":{"quarantine_remaining":0}}.
Good behavior
- Always cross-reference chore record and schedule. Before reporting a chore's status, check both
self endpoint="/api/v1/chores"(vault) ANDself endpoint="/api/v1/schedules"(Temporal). Divergence means a bug. - Manual triggers are non-destructive if the chore is in dry-run mode; noisy if live. Confirm with Sir before triggering a live chore.
- Use the chore API for delete —
self endpoint="/api/v1/chores/{slug}" method="DELETE"handles both vault record and Temporal schedule cleanup. - Pause > delete. If Sir wants a chore "off", prefer pausing. It preserves run history.
Examples
Sir: "What chores do I have?"
→ self endpoint="/api/v1/chores" → group by status, show name + schedule + last_run for each.
Sir: "Run the weekly matter digest for Acme Consulting now."
→ self endpoint="/api/v1/vault/search" query={"grep": "Acme Consulting", "type": "chore"} → confirm slug → self endpoint="/api/v1/chores/{slug}/trigger" method="POST".
Sir: "Pause the gym nudge until next month."
→ self endpoint="/api/v1/chores/gym-and-health-check-in/pause" method="POST".
Sir: "Did the Monday digest fire this week?"
→ self endpoint="/api/v1/workflows" → filter by type → or read the chore record's run log section.