---
title: "Ingest DOCX into CrewAI with EnConvert"
description: "Use EnConvert to ingest DOCX files into CrewAI as heading-aware chunks with section metadata your agent can retrieve and cite by section in one call"
canonical: "https://www.enconvert.com/solutions/ingest-docx-into-crewai-with-enconvert"
locale: "en"
---

# How to Ingest DOCX into CrewAI with EnConvert

A developer building a CrewAI agent that reads DOCX files needs heading-aware chunks with section metadata the agent can retrieve and cite, not flat paragraph text that loses the document’s heading structure. A library like python-docx extracts content paragraph by paragraph, and heading levels appear only as style names you have to traverse and group yourself, with no chunk boundaries and no metadata a CrewAI tool can filter on. For an agent reading a product specification, an internal policy document, or a technical brief, flat paragraph text produces results the agent cannot locate within the document or filter by section. EnConvert’s Ingest endpoint takes the uploaded DOCX and returns JSONL chunks split at H1 to H3 heading boundaries, each carrying its full headings_path breadcrumb, section name, and chunk index in metadata, ready to register as a CrewAI BaseTool. The agent can retrieve, reason over, and cite the content by section in one call.

## Example

```python
import json
import os
import re
import time
from typing import Optional, Type

import requests
from crewai import Agent, Crew, Task
from crewai.tools import BaseTool
from pydantic import BaseModel, Field

ENCONVERT_API_KEY = os.environ["ENCONVERT_API_KEY"]
# CrewAI's default LLM reads OPENAI_API_KEY from the environment.
# Pass llm=... to Agent to use another model.
HEADERS = {"X-API-Key": ENCONVERT_API_KEY}

# Step 1: Upload the DOCX (multipart form, the field is named "files")
with open("/path/to/your/product-specification.docx", "rb") as f:
    upload = requests.post(
        "https://api.enconvert.com/v2/ingest/files",
        headers=HEADERS,
        files={
            "files": (
                "product-specification.docx",
                f,
                "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            )
        },
        timeout=60,
    )
upload.raise_for_status()
job_id = upload.json()["job_id"]

# Step 2: Poll until the job completes
while True:
    status_response = requests.get(
        f"https://api.enconvert.com/v2/ingest/{job_id}",
        headers=HEADERS,
        timeout=60,
    )
    status_response.raise_for_status()
    job = status_response.json()
    if job["status"] == "completed":
        output_url = job["output_url"]
        break
    if job["status"] in ("failed", "canceled"):
        raise RuntimeError(job.get("error_message") or f"Ingest job {job['status']}")
    time.sleep(2)

# Step 3: Download the JSONL output
# The signed URL expires after 15 minutes and is fetched with a plain GET, no API key.
jsonl = requests.get(output_url, timeout=60)
jsonl.raise_for_status()

# Decode as UTF-8 and split on "\n" only: chunk text can contain Unicode line separators.
chunks = [
    json.loads(line)
    for line in jsonl.content.decode("utf-8").split("\n")
    if line.strip()
]
print(f"Loaded {len(chunks)} chunks ready for retrieval")


# Step 4: Define the input schema and BaseTool
class DocSearchInput(BaseModel):
    query: str = Field(
        description="What to look up, such as supported operating systems or access control requirements"
    )
    section: Optional[str] = Field(
        default=None,
        # Replace with a heading from your own document
        description="Optional section name to restrict the search, such as Security",
    )
    limit: int = Field(
        default=3,
        ge=1,
        le=5,
        description="Maximum number of passages to return",
    )


class SearchDocTool(BaseTool):
    name: str = "search_document"
    description: str = (
        "Search the ingested DOCX and return the most relevant passages, "
        "each labelled with its heading path and section so answers can cite where they came from."
    )
    args_schema: Type[BaseModel] = DocSearchInput

    def _run(self, query: str, section: Optional[str] = None, limit: int = 3) -> str:
        terms = set(re.findall(r"\w+", query.casefold()))
        section_filter = section.casefold().strip() if section else ""
        ranked = []

        for chunk in chunks:
            content = chunk["content"]
            metadata = chunk["metadata"]
            # headings_path is a list of heading strings and can be empty
            breadcrumb = " > ".join(metadata.get("headings_path", []))
            section_name = metadata.get("section", "")

            if section_filter and section_filter not in f"{breadcrumb} {section_name}".casefold():
                continue

            searchable = f"{section_name} {breadcrumb} {content}".casefold()
            score = len(terms & set(re.findall(r"\w+", searchable)))

            if score or section_filter:
                ranked.append((score, chunk))

        ranked.sort(key=lambda item: item[0], reverse=True)

        results = [
            f"[Path: {' > '.join(c['metadata'].get('headings_path', [])) or 'no heading'} "
            f"| Section: {c['metadata'].get('section') or 'none'}]\n"
            f"{c['content']}"
            for _, c in ranked[:limit]
        ]
        return "\n\n---\n\n".join(results) if results else "No matching passages found."

# Keyword overlap keeps this example dependency-free. Use embedding search for a larger document.

# Step 5: Set up the agent
analyst = Agent(
    role="Product Specification Analyst",
    goal="Answer questions about the specification using only its text and cite the section for every statement",
    backstory="You read product documents closely and never state a requirement you cannot trace to a section.",
    tools=[SearchDocTool()],
    verbose=True,
)

# Step 6: Create a task and run the crew
task = Task(
    description=(
        "Find the security requirements and supported operating systems in the document "
        "and cite the section each came from. "
        "Do not invent requirements not present in retrieved passages."
    ),
    expected_output="A short summary of security requirements and supported systems, each with its section citation.",
    agent=analyst,
)

crew = Crew(agents=[analyst], tasks=[task], verbose=True)
print(crew.kickoff())
```

## Output

Ingest returns the DOCX as JSONL chunks split at H1 to H3 heading boundaries, each carrying its headings_path breadcrumb, section name, and chunk index, so the CrewAI tool can retrieve and cite specific sections by name. Extracting content with python-docx gives flat paragraph text, with heading levels left as style names you must group yourself and no metadata the tool can filter on.

## Get started

[Get free API key — 500 ops per month, no card required.](https://www.enconvert.com/auth?mode=signup)

## Related solutions

- [How to Ingest Markdown Files into LlamaIndex with EnConvert](https://www.enconvert.com/solutions/ingest-markdown-files-into-llamaindex-with-enconvert.md)
- [How to Ingest Web Pages into Vertex AI with EnConvert](https://www.enconvert.com/solutions/ingest-web-pages-into-vertex-ai-with-enconvert.md)
- [How to Ingest Markdown into OpenAI File Search with EnConvert](https://www.enconvert.com/solutions/ingest-markdown-into-openai-file-search-enconvert.md)
- [How to Ingest HTML Files into LlamaIndex with EnConvert](https://www.enconvert.com/solutions/ingest-html-files-into-llamaindex-with-enconvert.md)
- [Best Browserless Alternative for Document Parsing](https://www.enconvert.com/solutions/browserless-alternative-for-document-parsing-enconvert.md)
- [How to Ingest a Research Paper into LlamaIndex with EnConvert](https://www.enconvert.com/solutions/ingest-a-research-paper-into-llamaindex-enconvert.md)
