Back to blog
2026年4月24日

Your Database Is a Markdown File

Your Database Is a Markdown File

Most task apps want you to live inside them. They lock your data behind a proprietary format, sync it to their cloud, and render it in their UI. I wanted the opposite: a system where the source of truth is a

.md
file I can read with
cat
.

So I built a Telegram bot that manages tasks in a Markdown table, parses natural language with LLMs, and syncs deadlines to Google Calendar. The file lives in a GitHub repo you own, and you can edit it by hand whenever you want. The bot doesn't care who made the last change.

Try it: @LazyMdTaskBot

Text a Bot, Update a File

Markdown Task Manager: adding a task via natural language

The interaction is

/add
, then a single message in plain English:

"prep slides for the tuesday meeting at 9am, high priority"

The bot sends that to an LLM, gets back structured data (task name, date, time, priority, duration, recurrence rule), downloads your Markdown file from GitHub, appends a row, commits it back, and optionally creates a Google Calendar event.

That's the entire interface: a chat window.

The Format

The entire data model is one Markdown file with YAML frontmatter:

---
last_synced: 2026-01-07T09:00:00+08:00
total_tasks: 6
timezone: Asia/Taipei
tags: ["work", "personal", "shopping"]
---

# Task Table

| Completed | Task           | Date       | Time  | Duration | Priority | Tags   | Description        | Link                        | CalendarEventId | Log |
| :-------- | :------------- | :--------- | :---- | :------- | :------- | :----- | :----------------- | :-------------------------- | :-------------- | :-- |
| [ ]       | Buy groceries  | 2026-01-09 | 14:00 | 1:30     | medium   | #food  | Weekly shop        |                             |                 |     |
| [x]       | Review PR #123 | 2026-01-08 | 10:00 | 2:00     | high     | #work  | Auth changes       | https://github.com/user/pr  | evt_abc123      |     |

That's the database: a checkbox is a boolean, a row is a record, frontmatter is metadata. It's version-controlled, diffable, and you can fix a typo with a GitHub edit.

The parser walks the file line by line: YAML frontmatter first, then column headers to build an index map, then each row into a typed

Task
object. No markdown AST library. String splitting, regex, and a state machine. It handles escaped pipes, optional columns, and both
[x]
and
[X]
as completion markers.

The

Description
column holds an AI-generated insight about the task (max 15 words, separate from tags). The
Log
column gets a timestamp when you complete it:
Completed 2026-01-08 10:00:00 (Asia/Taipei)
. Every mutation is a git commit.

The task table rendered in GitHub, with the bot's latest commit visible at the top

Browsing and Managing Tasks

Once tasks are in the file,

/list
replies with a formatted breakdown of everything pending: name, date, time, and the AI-generated description for each task.
/today
filters to what's due today.
/search
does keyword lookup across all rows. Completing, editing, or removing a task without naming one (just
/complete
on its own, say) brings up an inline keyboard to pick from instead of making you retype the task name.

The /list command showing pending tasks in Telegram

Swappable AI

The LLM layer is provider-agnostic. Switching from Gemini to Claude to GPT is one environment variable:

const getModel = async () => {
  switch (process.env.AI_PROVIDER) {
    case "gemini": {
      const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
      const google = createGoogleGenerativeAI({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY });
      return google(process.env.AI_MODEL);
    }
    case "anthropic": {
      const { createAnthropic } = await import("@ai-sdk/anthropic");
      const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
      return anthropic(process.env.AI_MODEL);
    }
    case "openai": {
      const { createOpenAI } = await import("@ai-sdk/openai");
      const openai = createOpenAI({ baseURL: process.env.OPENAI_BASE_URL, apiKey: process.env.OPENAI_API_KEY });
      return openai.chat(process.env.AI_MODEL);
    }
  }
};

Dynamic imports keep unused SDKs out of the bundle. The output validates against a Zod schema:

name
,
date
,
time
,
duration
,
description
,
link
, and an RFC 5545
recurrenceRule
for repeating tasks. If the LLM hallucinates a field, Zod catches it.

The system prompt is timezone-aware. When you type "tomorrow at 3pm," the bot knows what that means in

Asia/Taipei
vs
America/New_York
. It also resolves brand links: type "Shopee" with a Taipei timezone and it fills in
https://shopee.tw
.

Recurring Tasks That Roll Forward

When you complete a recurring task, the bot calculates the next occurrence and creates a fresh row:

export const rollForwardRecurringTask = async (
  task: Task,
  timezone?: string,
): Promise<Task | null> => {
  if (!task.recurrenceRule) return null;

  const { RRule } = await import("rrule");
  const dtstart = task.date
    ? new Date(`${task.date}T${task.time || "00:00"}:00Z`)
    : new Date();

  const rule = RRule.fromString(
    `DTSTART:${dtstart.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}\nRRULE:${task.recurrenceRule}`
  );

  const nextDate = rule.after(dtstart, false);
  if (!nextDate) return null;

  return {
    ...task,
    date: format(nextDate, "yyyy-MM-dd", { timeZone: "UTC" }),
    completed: false,
    calendarEventId: undefined,
    log: `Completed ${format(new Date(), "yyyy-MM-dd HH:mm:ss", { timeZone: timezone })} (${timezone})`,
  };
};

The old task gets marked done (with a log timestamp). The new one appears with a fresh date and no calendar event ID, which triggers a new Google Calendar event on the next sync. The

log
field on the original keeps a permanent breadcrumb.

Google Calendar Sync (The Hard Part)

Calendar sync sounds simple until you need OAuth token management for multiple concurrent users. The strategy is three tiers:

  1. Redis first: cached access tokens with a 55-minute TTL (Google tokens expire in 60 minutes)
  2. Database fallback: AES-256-GCM encrypted tokens, decrypted on cache miss
  3. Distributed lock: when a token needs refreshing, a Redis lock prevents concurrent refresh races
export const getValidAccessToken = async (
  telegramId: number,
  record: CalendarProviderRecord,
): Promise<string> => {
  // 1. Cache hit → return immediately
  const cached = await getCachedGoogleToken(telegramId);
  if (cached) return cached;

  // 2. DB token still valid → decrypt, cache, return
  if (record.access_token_encrypted && record.token_expires_at) {
    const expiresAt = new Date(record.token_expires_at).getTime();
    if (Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) {
      const token = decrypt(record.access_token_encrypted, record.encryption_key_version);
      await setCachedGoogleToken(telegramId, token);
      return token;
    }
  }

  // 3. Token expired → acquire lock, refresh, or wait for another request's refresh
  const lockKey = `lock:token_refresh:${telegramId}`;
  const acquired = await acquireLock(lockKey);

  if (!acquired) {
    for (let i = 0; i < 5; i++) {
      await new Promise((r) => setTimeout(r, 500));
      const token = await getCachedGoogleToken(telegramId);
      if (token) return token;
    }
    throw new Error("Token refresh timed out. Please try again.");
  }

  try {
    return await refreshAndCache(telegramId, record);
  } finally {
    await releaseLock(lockKey);
  }
};

Cache hit costs microseconds. The lock prevents two simultaneous requests from both hitting Google's token endpoint when a token expires.

Encrypted tokens are stored in

iv:authTag:ciphertext
format (base64). AES-256-GCM gives authenticated encryption: you can't tamper with the ciphertext without breaking the auth tag. Key versioning is built in via
encryption_key_version
on the record, so rotation is possible without a full re-encrypt sweep.

Edit Directly in GitHub, Get Notified in Telegram

Pro registers a GitHub App and sets a webhook. When you edit the Markdown file directly in GitHub (fix a typo, rearrange rows, add a task from a laptop), the bot receives the push event, diffs the old and new versions of the file, and sends you a summary:

📝 Tasks Updated on GitHub

👤 Author: lancehsu
💬 Commit: Reschedule groceries run
🔗 View Commit (a3f9c21)

✏️ Modified (1)
  • Buy groceries
    - date: 2026-01-09 → 2026-01-10

The diff analyzer walks both parsed versions of the file, compares tasks by name, and reports additions, removals, completions, and field-level modifications separately. A commit filter drops the bot's own commits from this comparison, so you only get notified about edits you made directly on GitHub.

Six Redis Layers

Redis plays six roles here, well beyond caching:

| Layer | TTL | Purpose | | :---------------- | :----- | :-------------------------------------------- | | Task data | 5 min | Skip GitHub/DB on every interaction | | Google tokens | 55 min | OAuth access tokens (expire in 60 min) | | Distributed locks | 10 sec | Prevent concurrent token refresh races | | Undo snapshots | 5 min | Pre-mutation state for single-level undo | | Rate limiting | 60 sec | 10 requests per user per window | | Session state | 10 min | Pending calendar ops, GitHub setup flow state |

Everything is fail-open. If Redis goes down, the bot still works, just slower. A Redis outage should never block someone from adding a task.

OSS Core, Pro Shell

The project is a Bun monorepo with two packages:

  • packages/oss/
    : the standalone single-user bot. AI client, Markdown parser, types, utilities. Published to a public repo via
    git subtree push
    .
  • packages/pro/
    : the multi-tenant extension. GitHub App auth, Neon PostgreSQL, Redis, encryption, rate limiting, daily reminders, and the GitHub webhook sync.

Pro imports shared types and logic from OSS as a workspace dependency. The storage layer is abstracted behind a three-method interface:

export interface IStorageProvider {
  queryTasks(telegramId: number): Promise<{ metadata: Metadata; taskData: TaskData }>;
  saveTasks(tasks: TaskData, metadata: Metadata, telegramId: number): Promise<boolean>;
  initTasks(telegramId: number): Promise<string | void>;
}

Implementations are

GitHubStorageProvider
(reads and commits via GitHub App installation) and a PostgreSQL provider for users who don't want to connect a repo. Switching between them is a single database flag per user.

I chose

git subtree
over submodules because subtrees keep the full file history in the monorepo. Pushing to the public repo is one command:

git subtree push --prefix="packages/oss" oss main

What I'd Do Differently

The Markdown parser is fragile. It works, but a hand-rolled state machine breaks if someone adds a column in the wrong order or uses unusual characters. A proper migration story, or just exporting to Markdown from a real database, would be more robust.

The monorepo sync is manual.

git subtree push
works but requires a clean working tree and a deliberate step I forget. Automating it in CI on every merge that touches
packages/oss/
would remove the overhead.

Key rotation is untested in production. The encryption layer supports key versioning, but I haven't rotated keys on a live deployment. The path exists; I haven't walked it.

The Stack

| Layer | Choice | | :--------- | :---------------------------------------------------------------------------- | | Runtime | Bun | | Framework | Hono | | Bot SDK | grammY | | AI | Vercel AI SDK + Zod | | Database | Neon (PostgreSQL) | | Cache | Upstash Redis | | Deployment | Vercel (serverless) | | CI/CD | GitHub Actions + Release Please |

The whole thing runs on Vercel's serverless tier. No long-running processes. The Telegram webhook hits a Hono route, does its work, and returns. Daily reminders run via a cron-triggered endpoint that queries which users need notification based on their configured time and timezone.


The OSS version is at github.com/klhq/md-task-manager. Single-user, no external services needed beyond a GitHub token and an LLM API key.