Repository navigation
MarkItDown Sidecar
An optional companion container that converts uploaded documents to Markdown before the Knowledge Base chunks and embeds them. It widens the set of formats Knowledge accepts, and makes the formats that already worked arrive as structured Markdown — headings and tables survive chunking instead of collapsing into flat text.
It wraps Microsoft MarkItDown 0.1.7 in a two-endpoint HTTP API.
MarkItDown is Python. The ChatHub runtime image is distroless — FROM scratch
plus busybox and a copied node binary, with no interpreter and no package
manager. MarkItDown's full converter set also needs lxml, pandas,
pdfminer, pdfplumber and Magika's ONNX model, plus ffmpeg and exiftool
as system binaries for audio transcription and image metadata.
| Image | Size (uncompressed virtual) |
|---|---|
lqdflying/chathub |
564 MB |
| Sidecar — Debian build (this page's Dockerfile, default args) | ~1.07 GB |
Sidecar — Debian build, INSTALL_OCR_PLUGIN=true
|
~1.15 GB |
| Sidecar — alternative recipe (Chainguard Wolfi base + multi-stage venv) | ~730 MB |
The size depends on the base image and on the optional OCR plugin. The Debian
slim base alone is ~380 MB versus ~17 MB for a Wolfi/apko base, and building
the Debian recipe with INSTALL_OCR_PLUGIN=true (which adds the
markitdown-ocr plugin, including its PyMuPDF rendering dependency) adds
~80 MB. INSTALL_OCR_PLUGIN=false is the default in the Debian Dockerfile
ARG and the Compose args:; the current prebuilt Wolfi image does not define
that arg at all, so it ships without the plugin too. See OCR for scanned
documents and pictures for when you
would turn it on.
Folding it in would roughly triple the ChatHub image for every user, including
those who never upload a spreadsheet, and would mean giving up distroless. So
it runs alongside instead, and deployments that do not set
MARKITDOWN_SERVICE_URL never build or pull it. This mirrors how ChatHub
already treats UNSTRUCTURED_SERVER_URL.
Formats the Knowledge Base accepts only when this service is configured:
| Kind | Formats |
|---|---|
| Spreadsheets |
.xlsx .xls — one Markdown table per sheet |
.msg (Outlook) |
|
| Notebooks |
.ipynb — cells as fenced code blocks |
| Archives |
.zip — converts each member and concatenates |
| Feeds |
.rss .atom .xml
|
| Images |
.png .jpg .jpeg — EXIF metadata, plus an LLM-generated description when configured; OCR is available separately through supported Azure paths |
| Audio |
.wav .mp3 .m4a .mp4 — metadata plus speech transcription |
| Other |
.htm .jsonl .text
|
Formats that already worked keep working, now via MarkItDown: PDF (with
geometric table detection), .docx (including equations, as LaTeX), .pptx
(per-slide headings, speaker notes, chart data), .epub, .csv, .html.
.doc and .docm remain unsupported — MarkItDown has no converter for legacy
Word either.
This table is the authority for which extensions ChatHub offers in its upload picker.
MARKITDOWN_EXTENSIONSinpackages/utils/src/isChunkableFile.tsmirrors it; if you change what the sidecar converts, update that set too.
Two ways to run the sidecar. Both use the same markitdown.env for runtime
configuration and the same ChatHub-side wiring. Pick one.
Every sidecar variable lives in this file beside the Compose file. Nothing here is mandatory from the image's perspective — it boots with built-in defaults. What becomes mandatory depends on your topology and which capabilities you turn on; see Configuration reference for the full breakdown.
# ===== MANDATORY — when the port is reachable beyond loopback =====
# Bearer token required on /convert. Must equal ChatHub's MARKITDOWN_API_KEY.
# Leave empty only when the sidecar is bound to 127.0.0.1 AND nothing else
# can route to it.
MARKITDOWN_API_KEY=
# ===== NON-MANDATORY — uncomment to override built-in defaults =====
# Cap upload size in bytes. Default 104857600 (100 MiB). Keep ChatHub's
# MARKITDOWN_MAX_FILE_SIZE less than or equal to this sidecar's, or ChatHub
# will ship files the sidecar rejects.
# MARKITDOWN_MAX_FILE_SIZE=104857600
# Per-conversion log level. Default INFO; DEBUG logs each conversion.
# MARKITDOWN_LOG_LEVEL=INFO
# Load third-party markitdown.plugin entry points. Default false.
# MARKITDOWN_ENABLE_PLUGINS=false
# Inline images as base64 data URIs. Default false — base64 wastes embedding
# tokens. Turn on only if you need rendered Markdown with visible images.
# MARKITDOWN_KEEP_DATA_URIS=false
# ===== OPTIONAL — image captioning + (with the OCR plugin) scanned-PDF OCR =====
# Set MARKITDOWN_LLM_API_KEY and MARKITDOWN_LLM_MODEL together.
# Set MARKITDOWN_LLM_BASE_URL only when using a custom or non-OpenAI endpoint
# that exposes an OpenAI-compatible API.
# Scanned-PDF OCR additionally requires the image built with
# INSTALL_OCR_PLUGIN=true AND MARKITDOWN_ENABLE_PLUGINS=true — see
# "Build-time only" under Configuration reference.
# MARKITDOWN_LLM_BASE_URL=
# MARKITDOWN_LLM_API_KEY=
# MARKITDOWN_LLM_MODEL=
# Override the captioning prompt. Always optional.
# MARKITDOWN_LLM_PROMPT=
# ===== OPTIONAL — Azure OCR (alternative to the LLM-OCR path) =====
# AZURE_API_KEY is shared between Doc Intel and Content Understanding. Set
# one of the endpoints (or both — MarkItDown picks one based on the format).
# AZURE_DOC_INTEL_ENDPOINT=
# AZURE_CU_ENDPOINT=
# AZURE_CU_ANALYZER_ID=
# AZURE_API_KEY=# markitdown.compose.yml — pull a prebuilt image. Env comes from
# markitdown.env beside this file so the compose carries no secrets.
services:
markitdown:
image: img.aksg.net/markitdown/markitdown:0.1.7 # or your own registry
container_name: chathub-markitdown
restart: always
env_file:
- markitdown.env
ports:
# Remove the 127.0.0.1: prefix only when unavoidable; pair it with
# MARKITDOWN_API_KEY in markitdown.env so the open port is not anonymous.
- '127.0.0.1:5000:5000'
security_opt:
- no-new-privileges:truedocker pull img.aksg.net/markitdown/markitdown:0.1.7
docker compose -f markitdown.compose.yml up -dIf ChatHub already runs under Compose, copy the markitdown service into
ChatHub's compose (alongside chathub / chathub-db) and put both containers
on one network — then ChatHub reaches the sidecar at http://markitdown:5000
and you can drop the ports: publish entirely. Keep env_file: [markitdown.env]
when you copy the block in: component-scoped env stays per-service even inside
a shared compose file.
Create a directory with these four files. Nothing else is needed, and it does not have to live inside the ChatHub checkout.
# MarkItDown conversion sidecar for the ChatHub Knowledge Base.
#
# Build from the directory holding these files:
# docker build -t chathub-markitdown .
FROM python:3.12-slim-bookworm
ARG USE_CN_MIRROR
# `exiftool` supplies image and audio metadata; `ffmpeg` lets pydub transcode
# mp3/mp4 to wav for speech transcription. Without them those converters still
# run, they just have far less to report.
RUN \
if [ "${USE_CN_MIRROR:-false}" = "true" ]; then \
sed -i "s/deb.debian.org/mirrors.ustc.edu.cn/g" /etc/apt/sources.list.d/debian.sources; \
fi \
&& apt-get update \
&& apt-get install --no-install-recommends -y exiftool ffmpeg \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
ENV EXIFTOOL_PATH="/usr/bin/exiftool" \
PIP_DISABLE_PIP_VERSION_CHECK="1" \
PIP_NO_CACHE_DIR="1" \
PYTHONDONTWRITEBYTECODE="1" \
PYTHONUNBUFFERED="1"
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# LLM-vision OCR for scanned PDFs and pictures embedded in Office documents.
# Off by default: it is installed from a git subdirectory and adds the
# markitdown-ocr plugin (which pulls PyMuPDF as a page-rendering dep), so
# it should not be able to break the base build.
ARG INSTALL_OCR_PLUGIN=false
RUN if [ "${INSTALL_OCR_PLUGIN}" = "true" ]; then \
pip install --no-cache-dir \
"markitdown-ocr @ git+https://github.com/microsoft/markitdown.git@v0.1.7#subdirectory=packages/markitdown-ocr"; \
fi
COPY server.py ./
# Conversion reads whatever the process can reach, so drop privileges and keep
# no writable application directory.
USER nobody:nogroup
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=4).status == 200 else 1)"
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5000"]# Pinned so the converter behaviour matches what this page documents.
markitdown[all]==0.1.7
fastapi==0.115.12
uvicorn[standard]==0.34.0
python-multipart==0.0.20
# Used only when MARKITDOWN_LLM_* is configured, for image captioning and — with
# the markitdown-ocr plugin — OCR of scanned pages.
openai==1.68.2
The HTTP wire format (multipart POST /convert, JSON {markdown,title,…}
response, the status codes in the HTTP contract) that
ChatHub depends on is the stable surface; the dependency versions are not part
of that contract. Dependency upgrades for CVE remediation are supported, but
the resulting image must be validated with HTTP-contract tests, representative
conversion fixtures, and a fresh vulnerability scan — FastAPI, Starlette,
Pydantic, OpenAI, MarkItDown and their transitive dependencies may change
behaviour between minor versions, so identical conversion output cannot be
assumed. Top-level version pins alone do not guarantee byte-for-byte
reproducibility either; that requires a complete dependency lock, a pinned
base-image digest, a pinned upstream source revision, and a controlled build
environment.
"""HTTP wrapper around Microsoft MarkItDown for the ChatHub Knowledge Base.
MarkItDown is a Python library and the ChatHub runtime image is distroless
(node only), so conversion runs here, out of process. ChatHub POSTs a document
to /convert and embeds the Markdown that comes back.
Configuration (all optional except where noted):
MARKITDOWN_API_KEY Bearer token required on /convert when set.
MARKITDOWN_MAX_FILE_SIZE Reject larger uploads, in bytes. Default 100 MiB.
MARKITDOWN_ENABLE_PLUGINS Load third-party `markitdown.plugin` entry points.
MARKITDOWN_KEEP_DATA_URIS Inline images as base64 data URIs. Off by default:
base64 blobs waste embedding tokens.
MARKITDOWN_LOG_LEVEL Server log level. Default INFO.
MARKITDOWN_LLM_BASE_URL OpenAI-compatible endpoint used to caption images
MARKITDOWN_LLM_API_KEY and, with the markitdown-ocr plugin installed, to
MARKITDOWN_LLM_MODEL OCR scanned PDFs and embedded pictures.
MARKITDOWN_LLM_PROMPT Override the captioning prompt.
AZURE_DOC_INTEL_ENDPOINT Route supported formats through Azure Document
Intelligence (prebuilt-layout) instead.
AZURE_CU_ENDPOINT Azure Content Understanding endpoint, and
AZURE_CU_ANALYZER_ID an optional custom analyzer id.
"""
from __future__ import annotations
import io
import logging
import os
import secrets
import threading
import time
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.responses import JSONResponse
from markitdown import (
FileConversionException,
MarkItDown,
MissingDependencyException,
StreamInfo,
UnsupportedFormatException,
__version__ as markitdown_version,
)
logging.basicConfig(level=os.environ.get("MARKITDOWN_LOG_LEVEL") or "INFO")
logger = logging.getLogger("markitdown-server")
def _flag(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
API_KEY = os.environ.get("MARKITDOWN_API_KEY") or None
MAX_FILE_SIZE = int(os.environ.get("MARKITDOWN_MAX_FILE_SIZE") or 100 * 1024 * 1024)
ENABLE_PLUGINS = _flag("MARKITDOWN_ENABLE_PLUGINS")
KEEP_DATA_URIS = _flag("MARKITDOWN_KEEP_DATA_URIS")
app = FastAPI(
description="Converts documents to Markdown for the ChatHub Knowledge Base.",
title="MarkItDown service",
)
# Refuse a body whose declared size is already over the cap before the form
# parser consumes it — this is what keeps an oversized upload from being spooled
# to disk at all. Multipart framing adds a little on top of the file itself,
# hence the slack; the exact cap is enforced on the file bytes in `convert`,
# which also catches chunked bodies that carry no Content-Length.
@app.middleware("http")
async def reject_declared_oversize(request: Request, call_next):
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > MAX_FILE_SIZE + 1_048_576:
return JSONResponse(
{"detail": f"{declared} bytes exceeds MARKITDOWN_MAX_FILE_SIZE ({MAX_FILE_SIZE})"},
status_code=413,
)
return await call_next(request)
# One MarkItDown per worker thread. Each instance loads a Magika ONNX model, so
# it is too expensive to build per request, and sharing one across threads would
# mean sharing its detector. FastAPI reuses threadpool threads, so this warms up
# once per thread and then costs nothing.
_local = threading.local()
def _build_llm_client():
"""An OpenAI-compatible client for image captioning, if one is configured."""
base_url = os.environ.get("MARKITDOWN_LLM_BASE_URL")
api_key = os.environ.get("MARKITDOWN_LLM_API_KEY")
model = os.environ.get("MARKITDOWN_LLM_MODEL")
if not (api_key and model):
return None, None
try:
from openai import OpenAI
except ImportError:
logger.warning("MARKITDOWN_LLM_* is set but the openai package is missing")
return None, None
return OpenAI(api_key=api_key, base_url=base_url or None), model
def _converter() -> MarkItDown:
existing = getattr(_local, "markitdown", None)
if existing is not None:
return existing
kwargs: dict[str, object] = {"enable_plugins": ENABLE_PLUGINS}
llm_client, llm_model = _build_llm_client()
if llm_client is not None:
kwargs["llm_client"] = llm_client
kwargs["llm_model"] = llm_model
prompt = os.environ.get("MARKITDOWN_LLM_PROMPT")
if prompt:
kwargs["llm_prompt"] = prompt
docintel_endpoint = os.environ.get("AZURE_DOC_INTEL_ENDPOINT")
if docintel_endpoint:
kwargs["docintel_endpoint"] = docintel_endpoint
cu_endpoint = os.environ.get("AZURE_CU_ENDPOINT")
if cu_endpoint:
kwargs["cu_endpoint"] = cu_endpoint
analyzer_id = os.environ.get("AZURE_CU_ANALYZER_ID")
if analyzer_id:
kwargs["cu_analyzer_id"] = analyzer_id
instance = MarkItDown(**kwargs)
_local.markitdown = instance
logger.info(
"MarkItDown ready (plugins=%s, llm=%s, docintel=%s, content_understanding=%s)",
ENABLE_PLUGINS,
bool(llm_client),
bool(docintel_endpoint),
bool(cu_endpoint),
)
return instance
def require_api_key(authorization: str | None = Header(default=None)) -> None:
if not API_KEY:
return
expected = f"Bearer {API_KEY}"
# Compare as bytes: only compare_digest gives the constant-time path, and its
# str form raises on non-ASCII input, which a caller controls.
if not (
authorization
and secrets.compare_digest(authorization.encode("utf-8"), expected.encode("utf-8"))
):
raise HTTPException(status_code=401, detail="invalid or missing bearer token")
@app.get("/health")
def health() -> dict[str, object]:
return {
"keepDataUris": KEEP_DATA_URIS,
"maxFileSize": MAX_FILE_SIZE,
"plugins": ENABLE_PLUGINS,
"status": "ok",
"version": markitdown_version,
}
# Deliberately a `def`, not `async def`: MarkItDown blocks, and FastAPI runs sync
# endpoints in its threadpool so one slow PDF cannot stall the event loop.
@app.post("/convert", dependencies=[Depends(require_api_key)])
def convert(
file: UploadFile = File(...),
filename: str | None = Form(default=None),
mime_type: str | None = Form(default=None),
) -> dict[str, object]:
# The framework has already spooled the body (memory below ~1 MiB, disk
# above), so read it back in bounded pieces: the cap is enforced as bytes
# accumulate, and an over-cap upload — or one whose Content-Length lied past
# the middleware — is refused without ever sitting in RAM in one piece.
pieces: list[bytes] = []
received = 0
while chunk := file.file.read(1024 * 1024):
received += len(chunk)
if received > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"file exceeds MARKITDOWN_MAX_FILE_SIZE ({MAX_FILE_SIZE})",
)
pieces.append(chunk)
data = b"".join(pieces)
if not data:
raise HTTPException(status_code=400, detail="empty upload")
name = filename or file.filename or "document"
# StreamInfo wants the extension with its leading dot, and an empty string
# would be treated as a real hint, so normalise to None instead.
_, _, tail = name.rpartition(".")
extension = f".{tail.lower()}" if tail and tail != name else None
mimetype = (mime_type or file.content_type or "").split(";")[0].strip() or None
stream_info = StreamInfo(
extension=extension,
filename=name,
mimetype=mimetype,
)
started = time.monotonic()
try:
result = _converter().convert_stream(
io.BytesIO(data),
keep_data_uris=KEEP_DATA_URIS,
stream_info=stream_info,
)
except UnsupportedFormatException as error:
logger.info("unsupported format for %s: %s", name, error)
raise HTTPException(status_code=415, detail=f"unsupported format: {error}") from error
except MissingDependencyException as error:
logger.error("missing dependency converting %s: %s", name, error)
raise HTTPException(status_code=501, detail=str(error)) from error
except FileConversionException as error:
logger.error("failed to convert %s: %s", name, error)
raise HTTPException(status_code=422, detail=str(error)) from error
markdown = result.markdown or ""
duration_ms = int((time.monotonic() - started) * 1000)
logger.info(
"converted %s (%s bytes, %s) to %s chars in %sms",
name,
len(data),
mimetype or extension or "unknown type",
len(markdown),
duration_ms,
)
return {
"chars": len(markdown),
"durationMs": duration_ms,
"markdown": markdown,
"title": result.title,
}Runtime configuration comes from markitdown.env (shared with Option A). The
build arg INSTALL_OCR_PLUGIN stays here because it is a build-time concern,
not a runtime env.
# Build and run from source. Runtime env comes from markitdown.env.
#
# docker compose up -d --build
#
# If ChatHub already runs under Compose, copy the `markitdown` service into
# that file instead so both containers share a network, and use the service
# name in the URL (MARKITDOWN_SERVICE_URL=http://markitdown:5000).
services:
markitdown:
build:
context: .
dockerfile: Dockerfile
args:
# Set to "true" to bake in LLM-vision OCR (needs MARKITDOWN_ENABLE_PLUGINS=true
# and MARKITDOWN_LLM_* at runtime — see OCR for scanned documents and pictures).
INSTALL_OCR_PLUGIN: ${INSTALL_OCR_PLUGIN:-false}
container_name: chathub-markitdown
restart: always
env_file:
- markitdown.env
ports:
# Bound to loopback: the service has no authentication unless
# MARKITDOWN_API_KEY is set, and it converts whatever it is given.
- '127.0.0.1:5000:5000'
security_opt:
- no-new-privileges:truedocker compose up -d --buildOnce the sidecar is running, point ChatHub at it and restart ChatHub.
The URL depends on where ChatHub itself runs, because the Compose files above bind the sidecar port to the host's loopback interface only.
| ChatHub runs… | MARKITDOWN_SERVICE_URL |
|---|---|
in a container on the same Docker network (recommended — copy the markitdown service into ChatHub's compose) |
http://markitdown:5000 |
directly on the host (pnpm dev, or node outside Docker) |
http://127.0.0.1:5000 |
| in a container but you cannot join networks |
http://<host-LAN-IP>:5000 — must drop the 127.0.0.1: port binding AND set MARKITDOWN_API_KEY
|
Do not use http://127.0.0.1:5000 from a containerized ChatHub: inside a
container that address is the container itself, so the request never reaches
the sidecar. The Docker bridge gateway (172.17.0.1) does not work either
while the port is bound to 127.0.0.1 — put both containers on one network
instead, or change the binding and set MARKITDOWN_API_KEY.
Add to ChatHub's own env — the .env next to ChatHub's compose, distinct from
the sidecar's markitdown.env:
# Required — switches MarkItDown on as the preferred converter with the
# built-in loaders kept as the fallback. This also flips enabledMarkItDown
# in GlobalServerConfig, which is what makes the upload picker offer the
# extra formats.
MARKITDOWN_SERVICE_URL=http://markitdown:5000
# Required only when markitdown.env set MARKITDOWN_API_KEY. Must match exactly.
MARKITDOWN_API_KEY=<same-value-as-in-markitdown.env>
# Both default to sensible values; uncomment to override.
# MARKITDOWN_MAX_FILE_SIZE=104857600 # bytes (100 MiB); keep ≤ the sidecar's cap
# MARKITDOWN_TIMEOUT=180000 # ms (3 min)See Knowledge Base and RAG for
FILE_TYPE_CHUNKING_RULES to override the default routing per format — e.g.
pin pdf=unstructured,default, or xlsx=markitdown to skip the fallback for
spreadsheets. A FILE_TYPE_CHUNKING_RULES entry that pins a format to
markitdown alone opts out of the fallback, so its errors surface directly
rather than degrading to the built-in loaders.
docker compose up -d --force-recreate chathub # or your equivalent restartServer config is read at process start, so a plain restart is enough on
ChatHub's side. The sidecar itself only needs --force-recreate when its own
markitdown.env changes (MARKITDOWN_ENABLE_PLUGINS,
MARKITDOWN_KEEP_DATA_URIS, any MARKITDOWN_LLM_* or AZURE_* value — these
are read at container start, not per request).
Verify the sidecar is alive and converting:
curl -s http://127.0.0.1:5000/health # should print "version":"0.1.7"
curl -s -F file=@some.xlsx http://127.0.0.1:5000/convert | head -c 400Then in ChatHub, upload a .xlsx / .msg / .ipynb into a Knowledge Base
file. The picker now accepts formats the built-in loaders rejected.
The Knowledge Base shows what the LLM actually sees — the converted/chunked content — not the original document:
- MarkItDown icon (a document glyph, shown on any MarkItDown-convertible file when the sidecar is configured): re-parses the file through MarkItDown and opens the converted content. Use it to (re)convert a specific file via MarkItDown.
- Filename or card in the Knowledge file manager: opens the chunk drawer for every file, including HTML, text, Markdown, and Office files. The drawer keeps a compact Basic Information section above search and chunk pages, with file dates, format, size, chunk count, embedding status, and a download action. The Chunks tag opens the same drawer.
- The drawer displays one Markdown-rendered chunk per page, with a compact
numbered pager and first/last jumpers. Headings, emphasis, lists, and
well-formed GFM tables render as document content instead of literal Markdown
punctuation. Each page has a
stable size target: a large spreadsheet table is split into multiple valid
Markdown pages, each no longer than 1,000 characters, with its header and
delimiter repeated. A single giant cell becomes bounded, column-labeled
continuation content so embedding limits are not exceeded. Each page has a
provenance badge (
MarkItDownwhen it came from the sidecar,LangChainfor the built-in loader), and its detail button opens the full chunk text plus metadata —converted_byreadsmarkitdownandtypeisMarkItDownElement. - The chunk drawer is a content-only viewer — it shows chunked / converted text, not the original file. This shared local path also avoids Microsoft Office Online, which needs a publicly reachable file URL and fails on a private host. If a file has not been parsed yet, the viewer presents a Parse action only when the current deployment supports that format. DOCX/PPTX work without the sidecar; XLS/XLSX need the sidecar; legacy DOC/ODT/PPT and sidecar-dependent files without the sidecar show an explanatory state instead of a failing action.
Existing documents do not auto-migrate. To re-chunk an already-uploaded file through MarkItDown, click its MarkItDown icon, or open the file row's actions menu (⋯) and choose Re-parse chunks — the file is re-parsed via the configured routing (MarkItDown first, with the built-in loaders as fallback) and its existing chunks are replaced. Re-parse files whose stored tables show literal pipes or a detached separator row: the table-preserving split and bounded page rules apply to newly parsed Markdown and cannot repair already-stored chunks.
The Debian Dockerfile above is one recipe. Prebuilt or Chainguard-Wolfi /
multi-stage variants (e.g. img.aksg.net/markitdown/markitdown:0.1.7) produce
a smaller, often 0-CVE image with the same HTTP contract and are a valid
drop-in replacement.
The INSTALL_OCR_PLUGIN build arg is defined only by the Debian Dockerfile;
the current prebuilt Wolfi image does not contain the OCR plugin, and its Wolfi
build recipe must be extended before that build arg can be used.
| Component | Debian default build | Current Wolfi prebuilt image |
|---|---|---|
| All text-extraction converters (xlsx/xls, docx, pptx, pdf, epub, html, msg, ipynb, zip, rss/atom, csv, code) | ✓ present | ✓ present |
| Audio transcription + image/audio metadata | ✓ present | ✓ present |
Image captioning via OpenAI-compatible API (openai + MARKITDOWN_LLM_*) |
✓ present (runtime opt-in) | ✓ present (runtime opt-in) |
Azure OCR (azure/msal + AZURE_*) |
✓ present (runtime opt-in) | ✓ present (runtime opt-in) |
Scanned-PDF / embedded-raster OCR (markitdown-ocr plugin) |
opt-in via INSTALL_OCR_PLUGIN=true (off by default) |
not installed; Wolfi recipe must be extended first |
A default build supports the same native-text input formats as a plugin-enabled build. Enabling the plugin adds raster/OCR processing; use representative conversion fixtures when exact output equivalence matters. Picture/raster handling is a purely optional capability; see OCR for scanned documents and pictures for the only inputs it affects.
To probe an existing image for the plugin without converting a file, check for the plugin's own module (not just PyMuPDF, which is only a rendering dependency):
docker run --rm img.aksg.net/markitdown/markitdown:0.1.7 \
/opt/venv/bin/python -c "import markitdown_ocr" 2>&1 | head -1
# ImportError: No module named 'markitdown_ocr' → plugin not baked in
# (silent) → plugin present/health does not surface the plugin's presence — it only reports the
MarkItDown library version — so a missing plugin is otherwise invisible until a
scanned PDF is uploaded.
All sidecar vars live in markitdown.env (see Quick start).
Every variable is optional from the image's perspective — it boots with
built-in defaults — but your topology and which capabilities you turn on make
some mandatory.
| Variable | When mandatory | Purpose |
|---|---|---|
MARKITDOWN_API_KEY |
When the port is reachable by anything other than ChatHub alone — i.e. you dropped the 127.0.0.1: port prefix, or another host can route to it |
Bearer token required on /convert. Must equal ChatHub's MARKITDOWN_API_KEY. |
If the sidecar is bound to loopback and only ChatHub (on the same host) reaches it, nothing here is mandatory — the image's built-in defaults are sufficient and the sidecar refuses nothing on its own.
Set each group fully, or not at all.
| Group | Variables | What it enables |
|---|---|---|
| Auth |
MARKITDOWN_API_KEY (alone) |
Bearer-protected /convert. |
| Standalone image captioning |
MARKITDOWN_LLM_API_KEY + MARKITDOWN_LLM_MODEL (+ MARKITDOWN_LLM_BASE_URL only for a custom or non-OpenAI endpoint that exposes an OpenAI-compatible API). MARKITDOWN_LLM_PROMPT is always optional. |
Captions standalone PNG/JPEG via MarkItDown's built-in ImageConverter. No plugin or rebuild needed. |
| OCR plugin (scanned PDFs + embedded pictures) | Same LLM vars as standalone captioning, plus MARKITDOWN_ENABLE_PLUGINS=true, and the image must have been built with INSTALL_OCR_PLUGIN=true. |
OCRs scanned PDFs and pictures embedded in PDF/DOCX/PPTX/XLSX via the markitdown-ocr plugin. Does not affect standalone images. |
| Azure Doc Intel |
AZURE_DOC_INTEL_ENDPOINT + AZURE_API_KEY
|
Azure Document Intelligence prebuilt-layout for PDFs/images. Alternative to the LLM-OCR path. |
| Azure Content Understanding |
AZURE_CU_ENDPOINT + AZURE_API_KEY. AZURE_CU_ANALYZER_ID is optional even when CU is configured. |
Azure's multimodal analyzer. |
The LLM and Azure paths are mutually independent — run both, either, or neither.
| Variable | Default | Purpose |
|---|---|---|
MARKITDOWN_MAX_FILE_SIZE |
104857600 (100 MiB) |
Reject larger uploads, in bytes. Enforced twice — once by a content-length middleware, once by a byte-count read. Keep ChatHub's MARKITDOWN_MAX_FILE_SIZE ≤ this sidecar's, or ChatHub will ship files the sidecar refuses. |
MARKITDOWN_ENABLE_PLUGINS |
false |
Load third-party markitdown.plugin entry points. Required true for the markitdown-ocr plugin to register. |
MARKITDOWN_KEEP_DATA_URIS |
false |
Inline images as base64 data URIs in the Markdown. Off: base64 blobs waste embedding tokens. Turn on only if you need to render the converted Markdown with images intact. |
MARKITDOWN_LOG_LEVEL |
INFO |
Python log level (DEBUG / INFO / WARNING / ERROR). DEBUG logs each conversion with duration and size — useful when diagnosing. |
AZURE_CU_ANALYZER_ID |
(Azure-supplied default) | Custom Content Understanding analyzer. Only meaningful when AZURE_CU_ENDPOINT is set. |
INSTALL_OCR_PLUGIN is a Docker build arg, not a runtime env. It is defined by
the Debian Dockerfile (default false); the current prebuilt Wolfi image does
not define it, so passing --build-arg INSTALL_OCR_PLUGIN=true to that recipe
has no effect until the Wolfi Dockerfile is extended.
Installing the plugin alone is not enough — it is discovered via the
markitdown.plugin entry point, and MarkItDown only loads entry points when
enable_plugins=True. The sidecar sets that from MARKITDOWN_ENABLE_PLUGINS,
which defaults to false. The plugin's OCR engine is an OpenAI-compatible
vision LLM (configured at runtime via MARKITDOWN_LLM_*); PyMuPDF is only a
page-rendering dependency, not the OCR engine itself. The full scanned-PDF OCR
path therefore needs all three:
# Build time (Debian recipe)
INSTALL_OCR_PLUGIN=true
# Runtime (markitdown.env)
MARKITDOWN_ENABLE_PLUGINS=true
MARKITDOWN_LLM_API_KEY=...
MARKITDOWN_LLM_MODEL=...
# Required only for a custom or non-OpenAI endpoint that exposes an
# OpenAI-compatible API:
# MARKITDOWN_LLM_BASE_URL=...Without MARKITDOWN_ENABLE_PLUGINS=true, the installed markitdown-ocr entry
point is never registered, so scanned-PDF OCR does not run. Building with the
plugin but leaving MARKITDOWN_LLM_* unset also produces no OCR — there is no
engine to call. Conversely, setting MARKITDOWN_LLM_* at runtime without the
plugin still captions standalone images (.png/.jpg) via the LLM, but cannot
get text out of scanned PDFs because the page-rendering pipeline is absent.
ChatHub reads four vars, set in ChatHub's own env — the .env next to ChatHub's
compose, distinct from the sidecar's markitdown.env:
| Variable | Required? | Purpose |
|---|---|---|
MARKITDOWN_SERVICE_URL |
Yes, to switch MarkItDown on | Where ChatHub POSTs conversion requests. Switches enabledMarkItDown in GlobalServerConfig, which is what makes the upload picker offer the extra formats. |
MARKITDOWN_API_KEY |
Yes when the sidecar sets it | Bearer token; must match the sidecar's MARKITDOWN_API_KEY exactly. |
MARKITDOWN_MAX_FILE_SIZE |
No (default 104857600) |
Skip conversion above this size. Keep ≤ the sidecar's cap. |
MARKITDOWN_TIMEOUT |
No (default 180000) |
Give up after this many ms. |
exiftool and ffmpeg are installed in the image, so image/audio metadata and
speech transcription work without further setup.
OCR of scanned PDFs and raster pictures embedded in Office documents is the only input class that any default build does not handle. The behaviour without OCR differs by input:
- A scanned PDF (image-only pages) typically produces empty Markdown — the
text extractors (pdfminer/pdfplumber/pypdfium2) find no text layer. Verified
output of the current production image for an image-only PDF:
{"chars": 0, "markdown": "", "title": null}. ChatHub reportsNoChunkErroron the empty result. - A standalone PNG or JPEG may return only EXIF or image-size metadata
(via the
exiftoolconverter), since there is no text layer to extract.
EXIF metadata is an image-input concept, not a PDF one — do not expect it from a scanned PDF. This whole section is an optional capability: if your deployment never ingests scanned or picture-only input, you can ignore it entirely and the sidecar is complete as built.
Two independent paths exist, with different coverage:
Requires all three: build with INSTALL_OCR_PLUGIN=true (Debian recipe),
runtime MARKITDOWN_ENABLE_PLUGINS=true, and runtime MARKITDOWN_LLM_*. The
plugin registers enhanced converters for PDF, DOCX, PPTX, and XLSX only,
so it OCRs scanned PDFs and pictures embedded in those document types. It
does not register a standalone PNG/JPEG converter — standalone images are
handled separately by MarkItDown's built-in ImageConverter when an LLM
client and model are configured (see the "Standalone image captioning" row
under Conditional feature groups). The
distinction matters: standalone image output is an LLM-generated description
from the built-in converter, while document-image OCR comes from
markitdown-ocr.
Set AZURE_DOC_INTEL_ENDPOINT + AZURE_API_KEY in markitdown.env. No
rebuild. This OCRs scanned PDFs and supported standalone images
(PDF/JPEG/PNG/BMP/TIFF), but it is not equivalent to markitdown-ocr for
pictures embedded in Office documents — in MarkItDown v0.1.7 the
DocumentIntelligenceFileType enum runs DOCX, PPTX, XLSX and HTML through
Azure with OCR explicitly disabled (the enum marks them # No OCR), so
embedded Office images are not OCR'd via this path.
Azure Content Understanding (AZURE_CU_ENDPOINT) is a separate multimodal
path and should not be described as identical to Document Intelligence; treat
it as its own capability with its own coverage.
ChatHub's client (src/libs/markitdown/index.ts) depends on this wire format.
It is not covered by any test in the ChatHub repository — the client's tests
mock fetch, and the chunking tests mock the client — so changing either
side silently breaks conversion while every test still passes. Treat it as
frozen unless you change both.
No authentication — safe as a container healthcheck.
{
"keepDataUris": false,
"maxFileSize": 104857600,
"plugins": false,
"status": "ok",
"version": "0.1.7"
}Multipart request:
| Field | Required | Purpose |
|---|---|---|
file |
yes | The document bytes |
filename |
no | Extension hint; improves format detection |
mime_type |
no | MIME hint; improves format detection |
Send Authorization: Bearer $MARKITDOWN_API_KEY when that variable is set.
Response:
{
"chars": 808,
"durationMs": 78,
"markdown": "## Sheet1\n| Alpha | Beta |\n| --- | --- |\n...",
"title": null
}| Status | Meaning |
|---|---|
400 |
Empty upload |
401 |
Missing or wrong bearer token |
413 |
Larger than MARKITDOWN_MAX_FILE_SIZE
|
415 |
No converter for this format |
422 |
Conversion failed |
501 |
A converter's optional dependency is missing |
With the default routing, ChatHub treats every one of them as "fall back to the
built-in loaders"; when that fallback also fails, the sidecar's reason is
carried into the surfaced error, so a sidecar that is down reports as
unreachable rather than as an unsupported file type. A
FILE_TYPE_CHUNKING_RULES entry that pins a format to markitdown alone opts
out of the fallback — its errors surface directly.
MarkItDown is pinned in requirements.txt so conversion output does not change
under you. To move to a newer release:
-
Check what changed upstream in the MarkItDown releases — converter output can shift between versions, which changes the chunks that get embedded.
-
Bump the pin, e.g.
markitdown[all]==0.1.8. If you build withINSTALL_OCR_PLUGIN=true, bump the@v0.1.7git ref in theDockerfileto match, or the plugin and the library will disagree. -
Rebuild without the layer cache, so pip actually re-resolves:
docker compose build --no-cache docker compose up -d
-
Confirm the running version:
curl -s http://127.0.0.1:5000/health # "version" must show the new release -
Re-convert a document of each format you rely on, and reindex anything whose output you want re-embedded.
Configuration is read at container start, not per request, so changing
markitdown.env values needs docker compose up -d --force-recreate, not just
editing the file.
To stop the sidecar — note that a container started from a Compose file you have since moved or deleted must be removed by name:
docker compose down # from the directory with docker-compose.yml
docker rm -f chathub-markitdown # if the Compose file is no longer thereConversion reads whatever the process can reach, so treat this as an internal service:
- The Compose files bind the port to
127.0.0.1and the Debian image drops privileges tonobody:nogroup. Do not expose it publicly. - Set
MARKITDOWN_API_KEYif anything other than ChatHub can route to it — the service is unauthenticated otherwise. - Audio transcription sends audio to Google's free Web Speech endpoint, and the YouTube converter fetches transcripts, so those paths leave your network. Everything else converts locally.
- Home
- Quick Start
- Docker Deployment and Upgrades
- Authentication
- Model Providers
- Knowledge Base and RAG
- MarkItDown Sidecar
- Sandbox (OpenSandbox)
- OpenAI Compatible Cache Matrix
- Model Extension Options
- Memory & Context Compaction
- Background Conversation Generation
- Pasted Text
- Context Export
- Data Backup and Restore
- Image Generation
- Artifacts
- Inline SVG Diagrams
- Tools Hub
- MCP OAuth
- MCP Management
- Agent Skills
- Database
- Development
- Debug and Diagnostics
- Architecture Overview
- Docker Release
- Release Workflow