Replacing a $7.88/mo n8n on Zeabur with a 178-line Python script on OpenClaw
Migrating a family-schedule alert off hosted n8n into a local OpenClaw cron job — and the latent bug I noticed along the way.
Background
For the past year I had a single n8n workflow running on Zeabur: family_schedule_alert. Every morning at 6:00 it queried Google Calendar for that day’s events, filtered to anything involving a family member’s name, formatted the matched events into a readable block, and pushed the summary into a LINE group used by the people who coordinate her care.
Picking n8n was the path of least resistance: visual editor, built-in Google Calendar OAuth, a Schedule trigger, an HTTP node for LINE Push. Ten nodes, done. Zeabur ran the container, billed me monthly, and the bot showed up at 6 AM. The bill peaked at USD $7.88.
The trigger
By mid-2026 I had been running OpenClaw on my home machine for long enough to trust it with real work — and long enough to notice that the single n8n workflow still sitting on Zeabur was doing nothing OpenClaw couldn’t already do locally.
One hosted workflow doesn’t justify a $7.88 monthly line item once the alternative is sitting on the same desk. Migrate it.
The case study: a daily family-schedule alert
The original n8n graph had ten nodes:
Schedule Trigger— fire at 06:00Google Calendar— get all events between$todayand$today.plus(1, "day")If_keyword_in_summary— filter bysummary contains <name>daily_schedule_list— Set node, project fieldsif_location_undefined+add_location+Merge— fill missinglocationwith “未標示”Aggregate— collect items into a single payloadCode— Python code node, format into one LINE-friendly stringline_push— HTTP POST to LINE Push API
Visual graphs read well as screenshots, but every “node” here is a thin wrapper around two or three lines of real logic, plus n8n-specific expression syntax sprinkled across half a dozen JSON panels. The real code lives inside the Python Code node, where n8n stops helping and you write text-templated Python by hand anyway.
The OpenClaw rewrite
One file, 178 lines, pure Python stdlib — no n8n, no node_modules, no third-party deps. The three real things it does are token refresh, calendar fetch, and LINE push:
def get_access_token():
with open(TOKEN_PATH) as f:
creds = json.load(f)
body = urllib.parse.urlencode({
"client_id": creds["client_id"],
"client_secret": creds["client_secret"],
"refresh_token": creds["refresh_token"],
"grant_type": "refresh_token",
}).encode("utf-8")
req = urllib.request.Request(
creds["token_uri"], data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))["access_token"]
def fetch_today_events(access_token):
now_tw = dt.datetime.now(TZ)
today_start = now_tw.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + dt.timedelta(days=1)
params = {
"timeMin": today_start.isoformat(),
"timeMax": today_end.isoformat(),
"q": KEYWORD, # server-side prefilter
"singleEvents": "true",
"orderBy": "startTime",
}
url = (f"https://www.googleapis.com/calendar/v3/calendars/"
f"{urllib.parse.quote(CALENDAR_ID)}/events"
f"?{urllib.parse.urlencode(params)}")
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {access_token}"}
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8")).get("items", [])
~/.openclaw/scripts/family_schedule_alert.py — key paths
OAuth is split into two scripts. setup_google_oauth.py runs once: opens a browser, captures the auth code on a localhost loopback, exchanges it for a refresh token, writes the result to ~/.openclaw/credentials/google_oauth_token.json with mode 0600. After that, the daily script only needs the refresh token — which doesn’t expire — and asks for a fresh access token each run.
A latent bug, fixed in transit
The n8n Set node was projecting fields like this:
All-day events in Google Calendar return start.date, not start.dateTime. The n8n version would have thrown the first time someone added an all-day matching entry to the calendar. It never threw because, in practice, nobody had — yet. The rewrite fixed it as a side effect, which is the best kind of fix: noticed while reading, not while debugging at 06:01.
OpenClaw cron + agentTurn
OpenClaw’s cron isn’t a thin crontab wrapper. Each job’s payload is an agentTurn — the scheduler wakes an agent, hands it a prompt, lets the agent run, and uses the agent’s final reply as the run summary. For this job the prompt is deliberately blunt:
{
"id": "86ce0520-afc8-47c7-a47f-7681fc6cba6f",
"name": "每日行程提醒",
"schedule": { "kind": "cron", "expr": "0 6 * * *", "tz": "Asia/Taipei" },
"agentId": "main",
"payload": {
"kind": "agentTurn",
"message": "Run this bash command and report nothing else: /usr/bin/python3 /Users/fish/.openclaw/scripts/family_schedule_alert.py",
"timeoutSeconds": 60
},
"delivery": { "mode": "announce", "channel": "telegram", "to": "971742907" }
}
cron job 86ce0520-…-6cba6f payload
Using an LLM agent to fire a one-line bash command does feel like overkill — and at 290 input / 145 output tokens per fire, it isn’t free. But the same scheduling primitive that runs this dumb-cron job also runs jobs whose payload is “summarize today’s Threads comments and tell me if anything needs my attention”. One cron surface, one delivery layer, one history viewer. The cost of using the agent for the trivial case is the price of not running a second scheduler for the smart ones.
What else OpenClaw is doing on this box
Migrating one workflow isn’t really worth $7.88 in itself; the value is amortizing it across everything else already on the same machine. For context, the same OpenClaw cron registry runs:
- Scheduled content pipelines — multi-stage flows across a handful of topic verticals, each on its own cadence.
- Social-platform operations — scheduled distillation, reporting, and unattended credential refresh.
- Market / research — recurring scans and periodic research reports on different cadences.
- Self-care infra — gateway repair every 30 minutes, memory backup every hour, a 3 AM memory-promote step, monthly Daily Log archive.
- And, now, the family schedule alert.
All running on the same machine, the same scheduler, the same Telegram operations chat. The marginal cost of replacing the n8n workflow with a same-shape local job was essentially zero.
Takeaways
$7.88/month is the kind of number that doesn’t trigger alarms — three coffees, a streaming subscription. But the discipline is to keep asking “what is this paying for, and do I still need it?” Once the answer is “one workflow I could rewrite in an afternoon”, the decision is made for you.
Ten n8n nodes look like a lot of structure on a canvas. They are not a lot of code. The moment a Code node appears — the universal “I gave up on the visual abstraction” signal — most of the workflow’s real logic has already moved out of the graph. The graph is the wrapper; the wrapper is what you’re paying for.
A single cron job doesn’t justify a scheduler. Twenty cron jobs do. The right time to migrate is when you’ve already paid the framework cost for unrelated reasons, and you’re left maintaining a hosted tool that only justifies itself with one workflow.