How to Build Your Own AI Reading System with OpenClaw

By Sahand Sojoodi (@sojoodi) · February 19, 2026
A person sitting at a desk reading a book and taking notes in a contemplative, sketch-style illustration

Since my last post about using AI to finally read the classics, a few people asked how to set it up themselves. This is that guide.

What You're Building

The system has four parts:

The magic is in part 4. The script is simple. The cron job is what makes it automatic.

Total setup time: about 30 minutes if you're comfortable in a terminal working back and forth with your assistant!

Prerequisites

If you already have OpenClaw talking to you via Telegram, you're 95% of the way there. Again, you can just work with your assistant by treating it like a competent entity to set this up.

Something like: "I need you to set up a reading system to help me read old and dense books. For example, The Art of War. The system should chunk the book into bite-sized chunks so that I can read it on the go in 2–3 minutes. For each chunk, please offer a modern interpretation in an easy to read format. Each day, I'd like you to send me the chunk, my progress and the interpretation at 8am so I can read it on my commute to work" Then work with your assistant to set it up the best way for you.

Now that said, if you want to learn about the internals of how I got it set up for me, here's the deep dive.

Step 1: Set Up the Directory Structure

Create the folders:

cd ~/.openclaw/workspace
mkdir -p books/library books/reading books/finished books/summaries scripts

Your structure should look like this:

~/.openclaw/workspace/
├── books/
│   ├── library/          # Raw book .txt files
│   ├── reading/          # JSON state files (one per book)
│   ├── finished/         # Completed books (move here when done)
│   └── summaries/        # AI-generated summaries (optional)
├── scripts/
│   └── read-book.js      # Core reader script

Step 2: Get Your First Book from Project Gutenberg

Go to gutenberg.org and find a book. I started with The Art of War.

For example:

cd ~/.openclaw/workspace/books/library
curl -o art-of-war.txt "https://www.gutenberg.org/cache/epub/132/pg132.txt"

Use a simple filename — lowercase, hyphens, no spaces. This becomes your bookId.

Step 3: Create the Book State File

Each book needs a JSON file in books/reading/ that tracks your progress. Create one:

cat > ~/.openclaw/workspace/books/reading/art-of-war.json << 'EOF'
{
  "bookId": "art-of-war",
  "title": "The Art of War",
  "author": "Sun Tzu (translated by Lionel Giles)",
  "filePath": "books/library/art-of-war.txt",
  "startedAt": "2026-02-19T00:00:00.000Z",
  "status": "active",
  "progress": {
    "currentLine": 0,
    "totalLines": null,
    "lastChunkSent": null,
    "chunkSize": 800,
    "estimatedPosition": "0%"
  },
  "deliverySchedule": {
    "auto": true,
    "cronTime": "30 8 * * 1-5",
    "timezone": "America/Toronto"
  }
}
EOF

Adjust these fields:

The script will fill in totalLines and update currentLine as you read.

Step 4: Install the Reader Script

Create the script at scripts/read-book.js:

(disclaimer, please treat the below as pseudo-code! It may not work as-is!)

cat > ~/.openclaw/workspace/scripts/read-book.js << 'SCRIPT'
#!/usr/bin/env node

/**
 * Personal Librarian - Book Reader
 * Usage:
 *   node read-book.js next [bookId]     - Get next chunk
 *   node read-book.js recap [bookId]    - Summarize progress
 *   node read-book.js status            - Show all reading progress
 *   node read-book.js start <bookId>    - Start a new book
 */

const fs = require('fs');
const path = require('path');

const WORKSPACE = process.env.OPENCLAW_WORKSPACE || path.join(process.env.HOME, '.openclaw/workspace');
const BOOKS_DIR = path.join(WORKSPACE, 'books');

function loadBookState(bookId) {
  const statePath = path.join(BOOKS_DIR, 'reading', `${bookId}.json`);
  if (!fs.existsSync(statePath)) {
    throw new Error(`Book not found: ${bookId}`);
  }
  return JSON.parse(fs.readFileSync(statePath, 'utf8'));
}

function saveBookState(bookId, state) {
  const statePath = path.join(BOOKS_DIR, 'reading', `${bookId}.json`);
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
}

function getActiveBook() {
  const readingDir = path.join(BOOKS_DIR, 'reading');
  if (!fs.existsSync(readingDir)) return null;
  const files = fs.readdirSync(readingDir).filter(f => f.endsWith('.json'));
  if (files.length === 0) return null;

  let newest = null;
  let newestTime = 0;

  for (const file of files) {
    const state = JSON.parse(fs.readFileSync(path.join(readingDir, file), 'utf8'));
    if (state.status === 'active') {
      const mtime = fs.statSync(path.join(readingDir, file)).mtimeMs;
      if (mtime > newestTime) {
        newestTime = mtime;
        newest = state;
      }
    }
  }
  return newest;
}

function readNextChunk(bookId) {
  const state = loadBookState(bookId);
  const bookPath = path.join(WORKSPACE, state.filePath);

  if (!fs.existsSync(bookPath)) {
    throw new Error(`Book file not found: ${bookPath}`);
  }

  const content = fs.readFileSync(bookPath, 'utf8');
  const lines = content.split('\n');

  // Initialize total lines on first read
  if (state.progress.totalLines === null) {
    state.progress.totalLines = lines.length;
  }

  const start = state.progress.currentLine;
  const chunkSize = state.progress.chunkSize || 800;

  let charCount = 0;
  let endLine = start;
  let chunk = '';

  // Collect lines until we hit the character limit
  while (endLine < lines.length && charCount < chunkSize) {
    const line = lines[endLine];
    chunk += line + '\n';
    charCount += line.length + 1;
    endLine++;
  }

  // Skip Gutenberg header on first chunk
  if (start === 0) {
    const startMarker = '*** START OF THE PROJECT GUTENBERG EBOOK';
    const startIdx = chunk.indexOf(startMarker);
    if (startIdx !== -1) {
      const afterMarker = chunk.substring(startIdx);
      const nextPara = afterMarker.indexOf('\n\n') + 2;
      chunk = chunk.substring(startIdx + nextPara);
    }
  }

  // Update state
  state.progress.currentLine = endLine;
  state.progress.lastChunkSent = new Date().toISOString();
  state.progress.estimatedPosition = `${Math.round((endLine / lines.length) * 100)}%`;

  saveBookState(bookId, state);

  return {
    chunk: chunk.trim(),
    progress: state.progress,
    title: state.title,
    author: state.author,
    finished: endLine >= lines.length
  };
}

function getRecap(bookId) {
  const state = loadBookState(bookId);
  const bookPath = path.join(WORKSPACE, state.filePath);
  const content = fs.readFileSync(bookPath, 'utf8');
  const lines = content.split('\n');

  const readSoFar = lines.slice(0, state.progress.currentLine).join('\n');

  return {
    title: state.title,
    author: state.author,
    progress: state.progress,
    contentSoFar: readSoFar.substring(0, 2000) + (readSoFar.length > 2000 ? '...' : '')
  };
}

function showStatus() {
  const readingDir = path.join(BOOKS_DIR, 'reading');
  if (!fs.existsSync(readingDir)) return [];

  const files = fs.readdirSync(readingDir).filter(f => f.endsWith('.json'));

  return files.map(f => {
    const state = JSON.parse(fs.readFileSync(path.join(readingDir, f), 'utf8'));
    return {
      bookId: state.bookId,
      title: state.title,
      author: state.author,
      status: state.status,
      progress: state.progress.estimatedPosition,
      lastRead: state.progress.lastChunkSent
    };
  });
}

// CLI handling
const command = process.argv[2];
const arg = process.argv[3];

try {
  switch (command) {
    case 'next': {
      const bookId = arg || getActiveBook()?.bookId;
      if (!bookId) {
        console.error('No active book. Specify a bookId or mark one as active.');
        process.exit(1);
      }
      console.log(JSON.stringify(readNextChunk(bookId), null, 2));
      break;
    }

    case 'recap': {
      const bookId = arg || getActiveBook()?.bookId;
      if (!bookId) {
        console.error('No active book. Specify a bookId or mark one as active.');
        process.exit(1);
      }
      console.log(JSON.stringify(getRecap(bookId), null, 2));
      break;
    }

    case 'status': {
      console.log(JSON.stringify(showStatus(), null, 2));
      break;
    }

    case 'start': {
      if (!arg) {
        console.error('Usage: read-book.js start <bookId>');
        process.exit(1);
      }
      const state = loadBookState(arg);
      console.log(`Started: ${state.title} by ${state.author}`);
      break;
    }

    default:
      console.error('Usage: read-book.js {next|recap|status|start} [bookId]');
      process.exit(1);
  }
} catch (err) {
  console.error('Error:', err.message);
  process.exit(1);
}
SCRIPT

chmod +x ~/.openclaw/workspace/scripts/read-book.js

Step 5: Test It Manually

Run the script to make sure everything works:

cd ~/.openclaw/workspace
node scripts/read-book.js status

You should see your book listed with 0% progress.

Now get the first chunk:

node scripts/read-book.js next

You'll see JSON output with the first ~800 characters of actual book content (the script automatically skips the Gutenberg header).

Run it again — you'll get the next chunk. The state file updates automatically.

If you want to reset, just set currentLine back to 0 in the JSON file.

Step 6: Set Up Automated Delivery via OpenClaw Cron

This is where the magic happens. You're going to tell OpenClaw to run the script every morning and deliver the chunk to you.

Open the OpenClaw CLI:

openclaw cron create

Configure it with:

For the message payload, use something like this:

Send the next book chunk for morning reading:

  1. Run: node scripts/read-book.js next
  2. Extract the chunk, title, author, and progress from the JSON output
  3. Send me the chunk with:
    • The book title and author
    • Progress indicator (e.g., "24% complete")
    • The VERBATIM text from the book (this is important — show the actual text first)
    • Your interpretation: what it means, why it matters, modern context
  4. Keep it concise — this should take 2–3 minutes to read

Format it nicely for [Telegram/email].

If you're using Telegram, that's it. The message goes straight to your chat.

Alternative: Telegram Commands

If you don't want scheduled delivery, you can just tell your AI these commands:

Add these to your TOOLS.md so the AI knows what they mean:

### /read-next
Get next chunk of current book.
- **Script:** `scripts/read-book.js next`
- **Output:** ~800 character chunk with progress indicator
- **Format:** Show verbatim text first, then add interpretation

### /read-recap
Summarize what's happened in the book so far.
- **Script:** `scripts/read-book.js recap`
- **Output:** AI-generated summary of content read to date

### /read-status
Show all books and reading progress.
- **Script:** `scripts/read-book.js status`
- **Output:** List of books with progress percentages

Step 7: Configure Email Delivery (Optional)

If you prefer email over Telegram, you'll need himalaya (or gog or another email integration) set up with your email account.

Update the cron message payload to include email formatting:

Send next book chunk for morning reading:

  1. Run 'node scripts/read-book.js next' to get raw chunk JSON
  2. Extract: bookId, title, author, chunk (verbatim text), progress
  3. Format as HTML email with:
    • Subject: 📖 Reading: [Title] — [Today's Date]
    • Header with date and "Morning Reading"
    • The verbatim book text in a styled block
    • Your interpretation in a separate styled block
    • Footer with progress (e.g., "Line 1734 of 7138")
  4. Send via: himalaya message send -a [account-name]

The AI handles the HTML formatting. You can provide a CSS file in config/email-style.css if you want consistent styling.

What the Output Looks Like

Every morning, you get something like this:

───────────────────────────────── 📖 The Art of War — February 18, 2026 Sun Tzu (translated by Lionel Giles) · 24% complete Original Text: Hence, when able to attack, we must seem unable; when using our forces, we must seem inactive; when we are near, we must make the enemy believe we are far away; when far away, we must make him believe we are near. Interpretation: This is Sun Tzu's core principle of strategic deception. In modern terms: don't broadcast your capabilities or intentions. A startup shouldn't announce its roadmap to competitors. A negotiator shouldn't reveal their BATNA. The goal isn't dishonesty — it's maintaining optionality by controlling what others know about your position. ─────────────────────────────────

The verbatim text grounds you in the source. The interpretation connects it to things you actually deal with.

Adding More Books

To add another book:

The script picks the most recently modified active book by default. Or specify explicitly: node scripts/read-book.js next meditations.

When you finish a book, move its state file to books/finished/ and start the next one.

Ideas to Extend This

The reading system is just one application of the pattern. The same approach works for:

The common thread: consistency beats intensity. And a well-configured cron job maintains consistency better than willpower.


That's it. A personal librarian that never forgets to show up.

The individual pieces are simple: a script, a JSON file, a cron job. The compound effect is what matters: showing up every day, automatically, with the next piece of whatever you're learning.