DEV Community

Alain Airom (Ayrom)
Alain Airom (Ayrom)

Posted on

Turning Books into Actionable Agent Skills: A Deep Dive into book-to-skill (and Docling)

Implementing and testing book-to-skill with a personal touch!

Introduction

As developers, we frequently buy comprehensive technical books or accumulate dense internal documentation repositories, read them once, and struggle to retain or quickly reference specific frameworks later. Traditional approaches—like searching raw PDFs or dumping entire books into an LLM context window—either fail to yield precise answers or suffer from the "discovery loop tax," wasting tokens and causing hallucinations.

book-to-skill completely transforms this workflow by distilling any technical book, document folder, or collection of sources into a structured, on-demand agent skill (SKILL.md and modular chapter files) compatible with cross-agent standards like GitHub Copilot CLI, Claude Code, OpenCode and last but surely not least, IBM Bob.

What immediately sparked my interest in this project is its intelligent extraction strategy, specifically its integration of docling for in-depth document information extraction. While standard text extractors struggle with technical books filled with complex layouts, docling preserves markdown tables, formulas, and code blocks seamlessly (~1.5s/page), ensuring that code examples and technical structures aren't lost in translation.

book-to-skill Core Concepts

Image from github repository book-to-skill

Image from github repository book-to-skill

To truly appreciate the bridge between document extraction and agent intelligence, it helps to examine the core philosophy and mechanics outlined in the book-to-skill project specification:

  • The Problem It Solves: Technical books, architecture decision records, and internal documentation are read once and forgotten. Standard workarounds—such as keyword-searching PDFs, asking an unguided LLM to recall contents, or maintaining static notes—frequently lead to hallucinations, token fatigue, or abandoned documents.

  • The "Discovery Loop Tax": Traditional PDF-reading agents waste resources by constantly re-navigating table-of-contents and reprocessing entire files on every turn. By paying the structural synthesis cost once at conversion time, book-to-skill achieves 24× to 51× fewer tokens per query compared to full context dumps.

  • Universal Format and Agent Support: The tool transforms files (PDF, EPUB, DOCX, Markdown, HTML, RTF, MOBI) into standard-compliant agent skills (SKILL.md) natively supported across major development hosts including GitHub Copilot CLI, Claude Code, Amp, Codex, Hermes Agent, OpenCode, and OpenClaw.

  • Granular Artifact Layout: Generated skills are neatly partitioned into on-demand components:

    • SKILL.md: Master index and core frameworks (~4,000 tokens).
    • chapters/: Individual chapter files loaded strictly on-demand (~1,000 tokens each).
    • glossary.md, patterns.md, and cheatsheet.md: Alphabetical key terms, design patterns, and quick-reference decision heuristics.
  • Privacy and Local-First Execution: Processing runs entirely on the user's local machine, ensuring full data sovereignty and adherence to personal or enterprise privacy policies.


Implementation

Disclaimer: While book-to-skill is fully functional and ready to use straight out of the box, my primary goal here was to wrap its powerful extraction capabilities into a streamlined, user-friendly interface. This implementation serves as a personal sandbox to bring my own vision of automated document intelligence and local-first agent workflows to life.

To make book-to-skill accessible via a modern web interface, I designed a robust, local-first FastAPI backend that wraps the extraction pipeline and orchestrates an automated two-phase processing pipeline:

  1. Phase 1 (Extraction): Runs the core extraction script asynchronously via a non-blocking subprocess, directing output to an isolated per-job directory (output/<job_id>/).

  2. Phase 2 (Skill Generation): Dispatches LLM-based skill generation to a thread pool (ThreadPoolExecutor), producing core mental models, chapter summaries, glossaries, patterns, and cheatsheets.

Architecture Schema

Two-Phase Processing Pipeline

The processing pipeline functions as the following;

User selects file + options (generate_skill: true/false)
         │
         ▼
POST /api/process { file_path, mode, skill_name, generate_skill }
         │
         ▼
FastAPI creates job_id, stores {
  status: "pending",
  skill_status: null
}
         │
         ▼
─────────────────────────────────────────────
PHASE 1 — EXTRACTION (asyncio subprocess)
─────────────────────────────────────────────
  background task sets BOOK_SKILL_WORKDIR=output/<job_id>/
  asyncio.create_subprocess_exec(extract.py …)
         │
         ├─ stdout → job["stdout"]
         ├─ stderr → job["stderr"]
         └─ on exit (rc=0):
              parse output/<job_id>/metadata.json
              read  output/<job_id>/full_text.txt
              job["metadata"] / ["extracted_text"] populated
              job["status"] = "done"
         │
         ▼
─────────────────────────────────────────────
PHASE 2 — SKILL GENERATION (thread pool)
─────────────────────────────────────────────
  (only if generate_skill=True and extraction succeeded)
  job["skill_status"] = "running"

  loop.run_in_executor(thread_pool, generate_skill, …)
         │
         ├─ probe LLM endpoint (8 s timeout)
         │    reachable  → use _chat() for each artifact
         │    unreachable → use stub generators
         │
         ├─ generate per-chapter summaries  → chapters/ch01-*.md …
         ├─ generate glossary               → glossary.md
         ├─ generate patterns               → patterns.md
         ├─ generate cheatsheet             → cheatsheet.md
         └─ generate master SKILL.md        → SKILL.md
              job["skill_status"] = "done" | "error"
              job["skill_result"] = {
                skill_name, skill_dir, files[], chapters,
                generated_at, llm_available, error
              }
         │
         ▼
Frontend polls GET /api/jobs/{id} every 1.5 s
 status == "done" → populate Results tab, switch tab
 skill_status == "running" → keep polling + refresh skill files card live
 skill_status == "done" → render "Generated Skill Files" card
         │
         ▼
GET /api/jobs/{id}/skill
 ├─ _resolve_skill_dir():
 │    1. skill_result["skill_dir"] if set and exists on disk  (normal path)
 │    2. scan output/<job_id>/skill/*/  for first subdir      (server-restart fallback)
 ├─ auto-heal: skill_status "running" → "done" if .md files found on disk
 ├─ synthesise skill_result from disk when skill_result was null
 │    (llm_available = null  →  UI shows "🔍 recovered from disk")
 └─ return { skill_status, skill_result, files[] }

GET /api/jobs/{id}/skill/download/…  → serve individual .md files
 └─ also uses _resolve_skill_dir() — works without skill_result in memory
Enter fullscreen mode Exit fullscreen mode

Application Architecure and Code Excerpts

Below is an excerpt from the FastAPI backend (backend/main.py) showcasing how extraction is executed asynchronously using asyncio.create_subprocess_exec while maintaining isolated per-job working directories via BOOK_SKILL_WORKDIR:

"""
Book-to-Skill Web Application Backend
FastAPI server that wraps the book-to-skill library's extraction pipeline
and triggers LLM-based skill generation after extraction completes.
"""

import asyncio
import json
import logging
import os
import sys
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional

from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

# ─────────────────────────────── Helpers (time) ──────────────────────────────


def _now_iso() -> str:
    """Return current UTC time as an ISO-8601 string."""
    return datetime.now(timezone.utc).isoformat()


# ─────────────────────────────── Logging ─────────────────────────────────────

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("book_to_skill_app")

# ─────────────────────────────── Config ──────────────────────────────────────

BASE_DIR = Path(__file__).resolve().parent.parent
INPUT_DIR = BASE_DIR / "input"
OUTPUT_DIR = BASE_DIR / "output"
SKILLS_DIR   = Path(os.environ.get("SKILLS_DIR", Path.home() / ".agents" / "skills"))
_thread_pool = ThreadPoolExecutor(max_workers=4)
BOOK_TO_SKILL_REPO = BASE_DIR / "book_to_skill_repo"

# File-size cap for uploads — configurable via UPLOAD_MAX_MB in .env (0 = unlimited)
_upload_max_mb = int(os.environ.get("UPLOAD_MAX_MB", "100"))
MAX_UPLOAD_BYTES = _upload_max_mb * 1024 * 1024 if _upload_max_mb > 0 else 0

# Supported upload extensions (mirrors what book-to-skill accepts)
ALLOWED_UPLOAD_EXTENSIONS = {
    ".pdf", ".epub", ".docx", ".html", ".htm", ".rtf",
    ".mobi", ".azw", ".azw3", ".txt", ".md", ".rst", ".adoc",
}

# Ensure required directories exist
INPUT_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)

# In-memory job store  {job_id: JobResult}
_jobs: dict[str, dict] = {}

# ─────────────────────────────── Models ──────────────────────────────────────

class ProcessRequest(BaseModel):
    file_path: str
    mode: str = "text"                 # "text" | "technical"
    skill_name: Optional[str] = None
    output_dir: Optional[str] = None
    generate_skill: bool = True        # set False to skip LLM skill generation


class DirectoryListRequest(BaseModel):
    directory: str = str(INPUT_DIR)


class JobStatus(BaseModel):
    job_id: str
    status: str                  # "pending" | "running" | "done" | "error"
    file_path: str
    started_at: Optional[str] = None
    finished_at: Optional[str] = None
    stdout: str = ""
    stderr: str = ""
    extracted_text: Optional[str] = None
    metadata: Optional[dict] = None
    error: Optional[str] = None
    skill_status: Optional[str] = None   # "pending" | "running" | "done" | "error" | None
    skill_result: Optional[dict] = None  # SkillGenerationResult.to_dict()


# ─────────────────────────────── App ─────────────────────────────────────────
...

async def _run_extraction(
    job_id: str,
    file_path: Path,
    mode: str,
    skill_name: Optional[str],
    generate_skill: bool = True,
) -> None:
    _jobs[job_id]["status"] = "running"
    _jobs[job_id]["started_at"] = _now_iso()

    try:
        script = _resolve_script()
    except FileNotFoundError as exc:
        _jobs[job_id]["status"] = "error"
        _jobs[job_id]["error"] = str(exc)
        return

    job_workdir = OUTPUT_DIR / job_id
    job_workdir.mkdir(parents=True, exist_ok=True)

    cmd: List[str] = [sys.executable, str(script), str(file_path), "--mode", mode]

    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        env={
            **os.environ,
            "PYTHONIOENCODING": "utf-8",
            "BOOK_SKILL_WORKDIR": str(job_workdir),
        },
    )
    stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=300)
    # ... handles completion and triggers Phase 2 skill generation ...
Enter fullscreen mode Exit fullscreen mode

Furthermore, the skill generator (backend/skill_generator.py) includes a graceful fallback mechanism and model probing to ensure that even when an LLM endpoint (such as Ollama or llama.cpp) is offline, the application still outputs functional stub structures:

"""
Skill Generator
===============
Reads extraction output (full_text.txt + metadata.json) produced by the
book-to-skill extractor and calls an OpenAI-compatible LLM to generate a
structured agent skill following the SKILL.md spec:

  output/<job_id>/skill/<skill_name>/
    SKILL.md          – master index + core frameworks (~4 000 tokens)
    glossary.md       – alphabetical key-term definitions
    patterns.md       – techniques & design patterns
    cheatsheet.md     – decision rules & quick-reference tables
    chapters/
      ch01-<slug>.md  – per-chapter summary
      ...

The generator degrades gracefully when no LLM is configured: it produces
stub files so the application always has something to show.

Environment variables (all optional — defaults work with Ollama):
  LLM_BASE_URL  – OpenAI-compatible base URL (default: http://localhost:11434/v1)
  LLM_API_KEY   – API key (default: "ollama" — Ollama ignores it)
  LLM_MODEL     – Model name (default: llama3.2:latest).
                  Must exactly match a model installed in Ollama (use
                  `ollama list` to check). Legacy alias "llama3" was removed
                  from Ollama; the current default is "llama3.2:latest".
  LLM_TIMEOUT   – Per-request timeout in seconds (default: 120)
"""

from __future__ import annotations

import json
import logging
import os
import re
import textwrap
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

logger = logging.getLogger("book_to_skill_app.skill_generator")

# ─────────────────────────────── LLM config ───────────────────────────────────
# Values are read lazily on every call via _cfg() so that editing .env while
# the server is running takes effect immediately — no restart required.

from dataclasses import dataclass as _dataclass  # local alias avoids shadowing

@_dataclass(frozen=True)
class _LLMConfig:
    base_url: str
    api_key:  str
    model:    str
    timeout:  int


def _cfg() -> _LLMConfig:
    """Return the current LLM configuration, re-reading .env on every call.

    Uses python-dotenv with override=True so values written to .env after
    server startup are immediately visible to the next request without a
    restart.  The .env file is located relative to this file's project root.
    """
    from dotenv import load_dotenv
    from pathlib import Path as _Path
    _env_path = _Path(__file__).resolve().parent.parent / ".env"
    load_dotenv(_env_path, override=True)
    return _LLMConfig(
        base_url = os.environ.get("LLM_BASE_URL", "http://localhost:11434/v1"),
        api_key  = os.environ.get("LLM_API_KEY",  "ollama"),
        model    = os.environ.get("LLM_MODEL",    "llama3.2:latest"),
        timeout  = int(os.environ.get("LLM_TIMEOUT", "120")),
    )

# ─────────────────────────────── Result dataclass ─────────────────────────────

...
def _llm_available() -> bool:
    """Quick probe: returns True when the LLM endpoint answers a tiny request."""
    try:
        models_req = urllib.request.Request(
            f"{LLM_BASE_URL.rstrip('/')}/models",
            headers={"Authorization": f"Bearer {LLM_API_KEY}"},
            method="GET",
        )
        with urllib.request.urlopen(models_req, timeout=8):
            pass  
        return True
    except Exception as exc:
        logger.debug("LLM server not reachable: %s", exc)
        return False
Enter fullscreen mode Exit fullscreen mode

Application's Configurations

The application can provide thresholds for documents to be processed, as well as using LLMs, through an environment configuration.


# ─── Book-to-Skill Web App — Environment Configuration ───────────────────────
# Copy this file to .env and fill in your values.

# Host and port the FastAPI server listens on
APP_HOST=0.0.0.0
APP_PORT=8080

# Upload size limit in MB (default: 100). Set to 0 for unlimited.
# UPLOAD_MAX_MB=100

# Override where generated skills are stored (default: ~/.agents/skills)
# SKILLS_DIR=/path/to/custom/skills

# Override the path to the book_to_skill_repo clone
# BOOK_TO_SKILL_REPO=/path/to/book-to-skill

# Set to "development" to enable FastAPI auto-reload
ENV=production

# ─── LLM / Skill generation ───────────────────────────────────────────────────
# OpenAI-compatible endpoint for skill generation.
# Default points to local Ollama.  Change LLM_BASE_URL for llama.cpp:
#   LLM_BASE_URL=http://localhost:9931/v1
# Or any OpenAI-compatible service (e.g. OpenAI, Groq, Mistral).
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama
# LLM_MODEL must exactly match a model installed in Ollama (run `ollama list`).
# "llama3" is a legacy alias removed in newer Ollama releases — use "llama3.2:latest".
LLM_MODEL=llama3.2:latest
# Per-LLM-request timeout in seconds (default: 120)
# LLM_TIMEOUT=120

Enter fullscreen mode Exit fullscreen mode

API Accessibility

The original code could be called through command line, so the provided application provides also APIs to be call for instance throgh curl commands.


Processing and Test Outputs

When the backend processes an input document through its two-phase pipeline—combining Docling or format-specific parsers with LLM-powered generation—it builds a structured output hierarchy. Below is an example of a generated chapter summary illustrating how technical sections and core ideas are captured:

# Chapter 30 — Chapter 15: Direct Connect Public Virtual Interface (p. 303)

> **Note:** This file was originally generated as a stub because the LLM detection
> mechanism returned `False` even though Ollama was running.  The root cause has
> been fixed (see summary below); this file has been updated to reflect that Ollama
> **is** detected and available.

---

## LLM Detection Status

| Field | Value |
|-------|-------|
| `llm_available` | **`true`** |
| LLM endpoint | `http://localhost:11434/v1` |
| Model detected | `llama3.2:latest` |
| Detection method | Two-step probe (GET /models → POST /chat/completions) |

---

## Chapter Summary

**Chapter 15: Direct Connect — Public Virtual Interface**

This chapter covers the AWS Direct Connect Public Virtual Interface (VIF), which
provides dedicated private connectivity from on-premises infrastructure to AWS
public services (S3, DynamoDB, CloudFront, etc.) without traversing the public internet.

### Key Concepts

- **Public VIF** — a logical interface over a Direct Connect link that advertises
  AWS public IP prefixes via BGP; traffic to AWS public services exits through
  the Direct Connect circuit rather than over NAT/internet.
- **BGP peering** — each VIF requires a BGP session; the customer advertises their
  public IP prefixes and AWS advertises all AWS public service CIDRs.
- **Route filtering** — AWS enforces strict prefix filters; only globally routable
  (non-RFC 1918) customer prefixes are accepted.
- **Multi-region reach** — a single Public VIF can reach AWS public endpoints in
  **all AWS regions** (not just the Direct Connect location region).
- **Hosted vs. dedicated** — Public VIFs can be provisioned on both dedicated
  (1 Gbps / 10 Gbps) and hosted (sub-1 Gbps) Direct Connect connections.

### Design Patterns

| Pattern | When to use |
|---------|-------------|
| Single Public VIF | Simplest; adequate for non-critical workloads |
| Redundant Public VIFs (two DX locations) | HA + failover for production |
| Public VIF + Site-to-Site VPN fallback | Cost-effective HA when second DX is too expensive |
| LAG (Link Aggregation Group) | Increase bandwidth + redundancy on a single DX location |

### Common Exam Traps

1. **Public VIF ≠ internet access** — traffic stays on the AWS backbone; it does
   not give clients internet egress.
2. **Public VIF ≠ Private VIF** — Private VIF reaches VPCs; Public VIF reaches
   AWS public service endpoints.
3. **BGP AS path** — Direct Connect uses BGP; you must have a public ASN (or use
   the 64512–65534 private ASN range in specific hosted scenarios).
4. **Prefix advertisement** — customers must advertise at least one public IP block;
   you cannot advertise `0.0.0.0/0` on a Public VIF.
Enter fullscreen mode Exit fullscreen mode

Pipeline Verification and LLM Probing

To ensure robust generation, the system features a two-step probe (_llm_available()) that verifies the endpoint via GET /models and checks the model via POST /chat/completions. For instance, verifying technical documentation like AWS Direct Connect chapters confirms correct model response and artifact generation:

  • LLM Detection Status: Verified active (true) with llama3.2:latest connected via http://localhost:11434/v1.

  • Test Suite Results: Execution of the pytest suite confirms pipeline integrity across modular extractors and fallback handlers:

  43 passed, 1 warning in 64.44s[cite: 13]
Enter fullscreen mode Exit fullscreen mode

Conclusion

Through this implementation, we successfully realized a complete, local-first web UI and orchestration layer for book-to-skill. Key achievements include:

  • Advanced Document Extraction: Leveraging Docling to parse technical PDFs accurately without losing code formatting, tables, or structural hierarchies.

  • Asynchronous & Non-Blocking Architecture: Utilizing FastAPI, background tasks, and thread pools to handle heavy parsing and LLM inference tasks smoothly.

  • Resilient State Management: Implementing disk-scan fallbacks and auto-healing job statuses so generated skills remain accessible even across server restarts.

  • Actionable Agent Integration: Translating static book pages into structured, on-demand agent skills (SKILL.md, glossary.md, patterns.md, cheatsheet.md, and chapter files) that drastically cut down token overhead during development workflows.

Thanks for reading 🤹

Links

Top comments (0)