Skip to content

Building a Reminder Plugin

This post explains how I built a reminder plugin for OpenClaw that understands Chinese time expressions, persists reminder state safely, and sends notifications or AI tasks back to the right chat channel at the right time.

Key Takeaways

If you only need the high-level design, these are the important points:

  1. OpenClaw already provides a capable cron scheduler. The plugin adds the natural-language layer, one-shot reminders, and one-shot AI tasks.
  2. The implementation uses the LLM for intent cleanup, then relies on deterministic parsing, a Chinese numeral tokenizer, and SQLite for predictable execution.
  3. The hardest engineering problems were not cron syntax. They were delivery route reconstruction, async task dispatch, and avoiding timeout-driven retry cascades.

Who This Is For

This write-up is most useful if one of these describes your problem:

  • You are building reminder, scheduling, or background-task features on top of OpenClaw or a similar agent gateway.
  • You want a chatbot to understand natural-language scheduling requests without forcing users to write cron expressions.
  • You need reminders to survive restarts and return results to channels like Telegram or LINE.

What Problem the Plugin Solves

I run an OpenClaw gateway as a family chat bot — connected to Telegram and LINE, backed by Azure AI Foundry (GPT). It's great at answering questions, but after living with it for a while, I realized that what I really wanted wasn't just "an AI that replies." I wanted an AI that does things at the right time.

"Remind me to pay the electricity bill tomorrow at 9am." "Every morning at 8:50, compile today's TSMC stock news and send it to me." "London time, every day at 7am, prepare an AI news digest."

These requests span a surprisingly wide range. Some are just reminders. Some require the AI to go do work, gather information, summarize it, and send it back. Some need to run once; others need to run every day. And because my family speaks Mandarin Chinese, the bot also has to understand natural time expressions like "明天下午三點半" (tomorrow 3:30pm) or "每週一早上九點" (every Monday at 9am).

OpenClaw already has a solid built-in cron system — you can set up recurring agent tasks via openclaw cron add with cron expressions, IANA timezones, and channel delivery. What it doesn't give you out of the box is the last mile: a natural language interface where someone can type "明天下午三點半提醒我開會" in chat and have the system handle everything automatically. Time parsing, one-shot reminders, task dispatch, delivery routing.

That's the gap this plugin closes.

Architecture Overview

The system has three reminder types, each serving a different use case:

Type What it does Example
remind Simple text notification at a scheduled time "5分鐘後提醒我倒垃圾"
task One-time agent job — AI executes and sends results "明天早上8:50幫我整理台積電新聞"
cron Recurring agent job on a cron schedule "每天早上8:50幫我整理台積電新聞"

One important nuance: OpenClaw's native cron is already quite capable. It supports cron expressions, IANA timezones, and agent turn delivery. For the cron type, this plugin simply calls openclaw cron add under the hood. I'm not replacing that system; I'm building on top of it.

What the plugin really adds is:

  1. Natural language time parsing — no need to write cron expressions yourself
  2. One-shot reminders (remind) — native cron only supports recurring tasks
  3. One-shot tasks (task) — have the AI do something once at a scheduled time
  4. Chinese time expressions — "明天下午三點半", "每週一早上九點"
  5. Automatic delivery routing — infers where to send replies from chat context

In other words: OpenClaw already knows how to schedule. This plugin teaches it how to understand the way real people ask for schedules in chat.

If you want a quick rule of thumb: use remind for simple notifications, task for one-time AI work, and cron for recurring workflows.

The architecture looks like this:

User (Telegram/LINE)
  │
  ▼
OpenClaw Gateway
  │
  ├─ Reminder Tool (LLM tool-call interface)
  │    ├─ Natural language parser (Chinese + English)
  │    ├─ Timezone resolver (IANA timezones)
  │    └─ SQLite persistence
  │
  ├─ Poller (every 15s)
  │    ├─ Due remind → openclaw message send
  │    └─ Due task → openclaw agent (executes + delivers)
  │
  └─ Cron integration
       └─ openclaw cron add (gateway-native scheduler)

Key Design Decisions

1. LLM as the Router, Plugin as the Executor

Instead of building a heavyweight NLU pipeline, I let the LLM do what it's good at: understanding messy human phrasing, inferring intent, and deciding when to invoke the reminder tool. The plugin then does the deterministic part: parsing, storing, scheduling, and delivering.

The system prompt tells the model:

  • "remind" → user wants a notification (提醒我...)
  • "task" → user wants the AI to do something (幫我整理/查/分析...)
  • "cron" → user uses recurring keywords (每天/每週/每月)

The model's job is to turn messy natural language into structured tool calls. For example, "你以後每天早上在倫敦時間早上七點準備一份關於AI的新聞給我" gets stripped down and normalized into: type=cron, input="倫敦時間 每天 07:00 準備一份關於AI的新聞".

2. Chinese Time Expression Parser

At first I thought time parsing would just be a few regexes. It turned out to be much messier than that. Chinese time expressions are surprisingly rich. Consider:

  • "明天下午三點半" → tomorrow 3:30 PM
  • "後天凌晨兩點十五分" → day after tomorrow 2:15 AM
  • "倫敦時間今天下午6:42" → today 18:42 Europe/London

The parser handles: - Relative times: N分鐘/小時/天後 (in N minutes/hours/days) - Absolute times: YYYY-MM-DD HH:MM, M/D HH:MM, M月D日 HH:MM - Chinese day-relative: 今天/明天 + 凌晨/早上/上午/中午/下午/晚上/傍晚 + N點(半/M分) - Chinese numerals: 一二三...十 including compound forms (二十三 = 23) - Timezone prefixes: 台北時間, 倫敦時間 (mapped to IANA zones) - Recurring schedules: 每天/每週X/每月N號 + time

All of this is implemented without any NLP library — just carefully composed regex patterns and a Chinese numeral tokenizer. For a bounded domain like time expressions, that ended up being simpler and more reliable than pulling in something heavier.

3. SQLite for Persistence, Not Just Memory

One of the easiest ways to build a bad reminder system is to keep everything in memory and hope the process never restarts. I wanted the opposite: if the gateway restarts, the reminder should still exist.

So I used Node.js's built-in node:sqlite (DatabaseSync) for zero-dependency persistence:

const { DatabaseSync } = require("node:sqlite");

The schema tracks everything needed for reliable delivery:

CREATE TABLE reminders (
  id TEXT PRIMARY KEY,
  owner_sender_id TEXT NOT NULL,
  channel TEXT NOT NULL,
  session_key TEXT NOT NULL,
  outbound_target TEXT,
  reminder_text TEXT NOT NULL,
  due_at_utc TEXT NOT NULL,
  source_timezone TEXT NOT NULL,
  reminder_type TEXT NOT NULL DEFAULT 'remind',
  task_prompt TEXT,
  cron_expr TEXT,
  status TEXT NOT NULL,
  -- ... timestamps, error tracking
);

4. Delivery Route Resolution

This turned out to be the trickiest part of the whole system. When a user creates a reminder via Telegram or LINE, the plugin captures the full session context — channel, sender ID, group or DM info, thread IDs. Hours later, when that reminder finally fires, that original chat context is gone. The system has to reconstruct where the reply should go from stored metadata alone.

The parseReminderDeliveryRoute() function parses OpenClaw's session key format (agent:v1:telegram:direct:12345678) to extract: - Channel (telegram, line, whatsapp) - Target (user ID or group ID) - Thread ID (for topic-based groups) - Account ID (for multi-account setups)

5. Task Dispatch: The Async Challenge

For "task" type reminders, the plugin spawns an openclaw agent subprocess that: 1. Starts a new agent session 2. Sends the task prompt to the LLM 3. Waits for the LLM to finish (including any tool calls — web search, etc.) 4. Delivers the result to the user's channel

For more involved research tasks, this can easily take 5+ minutes. That means timeout values can't just be picked casually, because there are multiple layers of timeout involved. If the outer one is too short, it kills work that's still progressing normally. To untangle which layer is firing in production, I trace agent task dispatch with Azure AI Foundry.

The rough shape looks like this:

Gateway fallback timeout:     60s
Agent processing timeout:    300s
Buffer:                       60s
Total dispatch timeout:      420s (7 minutes)

One subtle bug I ran into: spawnSync blocks Node's event loop. When createCronRecord used spawnSync to call openclaw cron add, the gateway couldn't accept the WebSocket connection from its own child process. The result was a deadlock that was far from obvious when I first saw it.

The fix was to switch to an async spawn wrapper:

function runOpenClawAsync(args, timeoutMs) {
  return new Promise((resolve, reject) => {
    const child = spawn("openclaw", args);
    // ... collect stdout/stderr, handle timeout
  });
}

6. Cascade Prevention

During testing, I hit the scariest failure mode in the project: if a task dispatch takes longer than the "stale" threshold, the poller assumes it's stuck and dispatches it again. That spawns another agent process. If that one also takes a while, the cycle repeats. Very quickly, one bug turns into a small process explosion.

The fix was two-fold: - Set stale threshold = dispatch timeout + 30s buffer - Never reset failed dispatches back to "pending" — leave them as "dispatching" so the stale timeout governs retry

function dueReminders(state) {
  const staleBefore = new Date(
    Date.now() - TASK_DISPATCH_TIMEOUT_MS - 30000
  ).toISOString();
  // Only pick up 'pending' or stale 'dispatching' reminders
}

The exec-sleep Guard

LLMs are creative problem-solvers, sometimes a little too creative. When asked to set a reminder, the model might decide that instead of using the reminder plugin, it can just run sleep 300 && echo "reminder" via the exec tool.

That looks clever for about five seconds. Then you remember it blocks an agent process for the whole sleep, doesn't survive restarts, and can cascade into resource exhaustion.

So the plugin registers a before_tool_call hook that blocks any exec call that's basically just a sleep command:

function shouldBlockToolCall(event) {
  if (event?.toolName?.toLowerCase() !== "exec") return null;
  if (!isSleepOnlyExecCommand(event?.params)) return null;
  return {
    block: true,
    blockReason: "Use the reminder tool instead of exec sleep.",
  };
}

Modular Structure

The plugin started life as a single 1,645-line file, which is exactly the kind of thing prototypes tend to become when they're proving themselves useful faster than they're being cleaned up. Once the behavior stabilized, I split it into 11 focused modules:

extensions/reminder/
├── index.js          (124 lines — thin orchestrator)
└── lib/
    ├── constants.js   — Configuration values
    ├── utils.js       — Core utilities
    ├── timezone.js    — Timezone/date handling
    ├── parsing.js     — Natural language parsing
    ├── context.js     — Session/channel extraction
    ├── validation.js  — Tool call guards
    ├── openclaw.js    — CLI wrappers & dispatch
    ├── database.js    — SQLite operations
    ├── dispatch.js    — Polling loop
    ├── formatting.js  — User-facing messages
    └── tool.js        — Tool definition & commands

After the split, each module had a much clearer role. The 33-test suite still runs against _internals exported from the main entry point, which made the refactor much less stressful. I wasn't rewriting blind; I had a safety net. I've taken the same modular approach with another OpenClaw plugin: voice transcription.

What I Learned

  1. Let the LLM handle ambiguity; let code handle precision. The model is great at understanding something like "你以後每天早上在倫敦時間早上七點準備一份新聞給我" and reducing it into a cleaner representation. The parser is great at turning that cleaned-up input into deterministic scheduling data.

  2. Async matters more than you think. A single spawnSync in the wrong place cost me hours of debugging. In a gateway that depends on WebSocket coordination, every synchronous blocking call deserves suspicion.

  3. Design for cascading failures early. The worst bugs aren't always the ones that throw loud errors. Sometimes they're the ones that quietly multiply. In this case, one timeout mismatch was enough to start spawning duplicate agent processes until the VM fell over.

  4. Chinese NLP doesn't always need ML. For a bounded domain like time expressions, regex plus a numeral tokenizer gets surprisingly far. The remaining ambiguity can often be handled by the LLM before the parser ever sees the input.

  5. SQLite is underrated for plugins. Zero dependencies, built into Node.js 22+, survives restarts, and more than fast enough for this kind of workload. Not every feature needs a separate service.

What's Next

  • English time expression support — currently the parser is Chinese-first; English support is minimal
  • Snooze / reschedule — "延後30分鐘" (snooze 30 minutes)
  • Multi-user recurring tasks — different family members get different daily digests
  • Delivery confirmation — track whether the user actually saw the reminder

This plugin runs on a single Azure VM (Standard B2s, about $15/month) serving my family's Telegram and LINE groups. The full source is in the extensions/reminder/ directory of the project.