By Thales & Claude -- CEO & AI CTO, ZeroSuite, Inc.
A chartered accountant in Abidjan types: "Generate me a balance sheet for SARL Kouame & Fils, fiscal year 2025, SYSCOHADA format." Thirty seconds later, she downloads a formatted Excel workbook with three sheets -- assets, liabilities, and equity -- each with proper column headers, currency formatting, and subtotals. She did not open Excel. She did not configure a template. She typed a sentence and received a professional document.
This is file generation in Deblo. Six tools that turn natural language into real, downloadable documents: Excel spreadsheets, PDFs, PowerPoint presentations, Word documents, HTML pages, and Markdown files. The system handles everything from a two-page homework summary for a CM2 student in Dakar to a 50-page audit program for a professional in Douala.
Building it required solving three distinct problems: getting the AI to produce structured document specifications instead of raw content, transforming those specifications into properly formatted files using Python libraries, and delivering the files to users through a streaming interface that shows progress in real time.
---
Why Structured JSON, Not Raw Bytes
The naive approach to file generation would be: ask the LLM to produce the file content directly. Give it a PDF library, let it write Python code, execute the code, return the file. This is what many AI coding assistants do.
We rejected this approach for three reasons.
First, LLMs are unreliable code generators for library-specific APIs. Ask DeepSeek V3 to write openpyxl code and it will hallucinate method names, invent parameters, and produce code that fails at runtime 40% of the time. The error rate is even worse for less common libraries like python-pptx and python-docx. Every failed generation wastes credits and frustrates the user.
Second, executing arbitrary LLM-generated code on the server is a security nightmare. Even with sandboxing, the attack surface is enormous. We would need to validate every import, restrict filesystem access, limit execution time, and handle crashes gracefully. The complexity is not worth it.
Third, structured JSON is deterministic. If the LLM produces a valid JSON object matching our schema, the file generation succeeds 100% of the time. The LLM's job is to think about content and structure -- what goes in each sheet, what sections a report needs, what slides to include. The server's job is to transform that structure into a formatted file. Clean separation of concerns.
The LLM produces a document specification. The server produces the document.
---
The Tool Schemas
Each of the six generation tools follows the same pattern: the LLM calls the tool with a JSON object describing the document's structure, and the server generates the file. Here is the schema for generate_xlsx, the most frequently used tool:
{
"type": "function",
"function": {
"name": "generate_xlsx",
"description": "Generate an Excel spreadsheet (.xlsx) file with one or more sheets. Each sheet has a name, column headers, and rows of data. Use this for tables, accounting entries, budgets, financial statements, student grade sheets, and any structured data the user requests.",
"parameters": {
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "The filename without extension (e.g. 'bilan-syscohada-2025')"
},
"title": {
"type": "string",
"description": "A human-readable title for the document"
},
"sheets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"headers": {
"type": "array",
"items": { "type": "string" }
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {}
}
}
},
"required": ["name", "headers", "rows"]
}
}
},
"required": ["filename", "title", "sheets"]
}
}
}The schema is deliberately simple. sheets is an array of objects, each with a name, headers (column names), and rows (arrays of cell values). The LLM does not need to know about cell formatting, column widths, or font sizes. It just needs to know what data goes where.
The PDF tool follows a different structure suited to narrative documents:
{
"filename": "rapport-annuel-2025",
"title": "Rapport Annuel -- SARL Kouame & Fils",
"sections": [
{
"heading": "1. Contexte et activites",
"content": "La societe SARL Kouame & Fils..."
},
{
"heading": "2. Performance financiere",
"content": "Le chiffre d'affaires de l'exercice..."
}
]
}The PowerPoint tool uses a slides array with title, content, and optional layout (title_slide, content, two_column). The Word document tool mirrors the PDF structure with sections. HTML uses a Jinja2 template reference plus data. Markdown is the simplest -- just filename, title, and content as a raw markdown string.
---
The Generation Pipeline
When the LLM calls a generation tool, the server receives structured JSON and transforms it into a file. Here is the Excel generation function, which handles the most complex case -- multi-sheet workbooks with typed cell values:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
import ioasync def generate_xlsx_file( filename: str, title: str, sheets: list[dict], ) -> tuple[bytes, str]: """Generate an XLSX file from structured sheet data.
Returns (file_bytes, content_type). """ wb = Workbook()
# Remove default sheet wb.remove(wb.active)
header_font = Font(bold=True, size=11, color="FFFFFF") header_fill = PatternFill(start_color="2B5797", end_color="2B5797", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), )
for sheet_data in sheets: ws = wb.create_sheet(title=sheet_data["name"][:31]) # Excel 31-char limit
# Write headers for col_idx, header in enumerate(sheet_data["headers"], 1): cell = ws.cell(row=1, column=col_idx, value=header) cell.font = header_font cell.fill = header_fill cell.alignment = header_alignment cell.border = thin_border
# Write data rows for row_idx, row in enumerate(sheet_data["rows"], 2): for col_idx, value in enumerate(row, 1): cell = ws.cell(row=row_idx, column=col_idx, value=value) cell.border = thin_border # Auto-detect numeric values for right-alignment if isinstance(value, (int, float)): cell.alignment = Alignment(horizontal="right") cell.number_format = "#,##0" if isinstance(value, int) else "#,##0.00"
# Auto-fit column widths for col_idx in range(1, len(sheet_data["headers"]) + 1): max_length = max( len(str(ws.cell(row=r, column=col_idx).value or "")) for r in range(1, ws.max_row + 1) ) ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 4, 50)
buffer = io.BytesIO() wb.save(buffer) buffer.seek(0) return buffer.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ```
The function is straightforward because the hard work -- deciding what data to include, how to organize sheets, what headers to use -- was already done by the LLM. The server applies consistent formatting: blue headers with white text, thin borders, auto-fitted column widths, right-aligned numbers with thousand separators. Every Excel file from Deblo looks professional, regardless of whether it was generated for a student tracking their grades or an accountant preparing a trial balance.
PDF generation uses WeasyPrint for complex layouts and ReportLab for simpler documents. PowerPoint uses python-pptx with a base template that includes the Deblo branding. Word documents use python-docx with styled headings and paragraphs. The pattern is identical across all six formats: receive structured JSON, apply formatting, return bytes.
---
S3 Upload and URL Delivery
Generated files are uploaded to Hetzner Object Storage, an S3-compatible service hosted in European data centres. We chose Hetzner over AWS S3 for two reasons: significantly lower costs (relevant when serving African students on tight margins) and GDPR-friendly European hosting.
The upload function uses boto3, the standard AWS SDK, which works with any S3-compatible API:
import boto3
from uuid import uuid4
from datetime import datetimes3_client = boto3.client( "s3", endpoint_url=settings.S3_ENDPOINT_URL, # https://fsn1.your-objectstorage.com aws_access_key_id=settings.S3_ACCESS_KEY, aws_secret_access_key=settings.S3_SECRET_KEY, region_name=settings.S3_REGION, # fsn1 )
async def upload_generated_file( file_bytes: bytes, filename: str, content_type: str, user_id: str, ) -> str: """Upload a generated file to S3 and return the public URL."""
date_prefix = datetime.utcnow().strftime("%Y/%m/%d") unique_id = uuid4().hex[:12] s3_key = f"generated/{date_prefix}/{user_id}/{unique_id}/{filename}"
s3_client.put_object( Bucket=settings.S3_BUCKET_NAME, Key=s3_key, Body=file_bytes, ContentType=content_type, ContentDisposition=f'attachment; filename="{filename}"', CacheControl="public, max-age=86400", )
public_url = f"{settings.S3_PUBLIC_URL}/{s3_key}" return public_url ```
The key structure -- generated/{date}/{user_id}/{unique_id}/{filename} -- serves multiple purposes. The date prefix makes it easy to implement lifecycle policies (auto-delete files older than 90 days for free-tier users). The user ID enables per-user storage accounting. The unique ID prevents filename collisions when a user generates multiple files with the same name in one day.
ContentDisposition: attachment ensures that clicking the URL triggers a download rather than displaying the file in the browser. This matters for Excel and PowerPoint files, which browsers cannot render natively.
---
SSE Streaming: Progress Events and File Delivery
File generation is not instant. A multi-sheet Excel workbook takes 2-5 seconds. A 20-page PDF takes 5-15 seconds. A background job producing multiple files can take minutes. Users need feedback.
Deblo's SSE stream sends tool_progress events during generation and a file event when the document is ready:
event: tool_progress
data: {"tool": "generate_xlsx", "status": "running", "label": "Generation du fichier Excel", "detail": "Bilan SYSCOHADA 2025"}event: tool_progress data: {"tool": "generate_xlsx", "status": "completed", "label": "Fichier Excel genere", "detail": "3 feuilles, 127 lignes"}
event: file data: {"filename": "bilan-syscohada-2025.xlsx", "url": "https://files.deblo.ai/generated/2026/03/26/abc123/bilan-syscohada-2025.xlsx", "size": 45230, "content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"} ```
The frontend parses these events and renders them differently. A tool_progress event with status: running shows an animated progress indicator with the tool's label. When the status changes to completed, the indicator shows a checkmark. The file event triggers a FileDownloadCard component -- a styled card with the file type icon (spreadsheet, document, presentation), the filename, the file size in human-readable format, and a prominent download button.
The FileDownloadCard is deliberately simple. Users in Abidjan on 3G connections do not want a preview modal or an inline document viewer. They want to tap "Download" and have the file saved to their device. The card shows exactly what the file is and lets them download it in one tap.
---
The UploadedFile Model
Every generated file is recorded in the UploadedFile table, the same table that stores user-uploaded files. A file_source enum distinguishes between uploads and generations:
class FileSource(str, enum.Enum):
UPLOADED = "uploaded" # User uploaded via file picker or drag-and-drop
GENERATED = "generated" # AI generated via tool call
OCR_PROCESSED = "ocr" # Processed through OCR pipelineclass UploadedFile(Base): __tablename__ = "uploaded_files"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=True)
filename = Column(String(500), nullable=False) original_filename = Column(String(500), nullable=True) content_type = Column(String(100), nullable=False) file_size = Column(Integer, nullable=False) file_url = Column(String(2000), nullable=False) s3_key = Column(String(1000), nullable=False)
file_source = Column( SAEnum(FileSource, name="file_source_enum"), nullable=False, default=FileSource.UPLOADED, )
# For generated files: the tool that created them generation_tool = Column(String(50), nullable=True)
# Text extraction (for RAG search) extracted_text = Column(Text, nullable=True) text_extracted = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) ```
Storing uploaded and generated files in the same table is an intentional design choice. It means file search, file listing, and file management all work identically regardless of origin. When a user asks "Show me all my files," they see both their uploads and their AI-generated documents in one list. When they search for "bilan 2025," the semantic search finds both an uploaded scanned balance sheet and an AI-generated Excel workbook.
The generation_tool column tracks which tool created the file. This enables analytics: we can see that generate_xlsx accounts for 62% of all file generation, followed by generate_pdf at 24%, with the other four formats sharing the remaining 14%. This data informed our decision to invest more in Excel formatting quality -- it is by far the most-used generation format, especially among Pro users.
---
Pro-Only Restrictions
Not all file generation is available to all users. The restriction model is based on user plan and file complexity:
K12 students on the free tier can generate Markdown files and simple HTML pages -- useful for homework summaries and study notes. Generating Excel, PDF, PowerPoint, or Word files requires a paid plan or sufficient credit balance. The restriction is enforced at the tool execution layer: when the tool executor receives a generate_xlsx call, it checks the user's plan before proceeding. If the user lacks access, the tool returns an error message that the LLM translates into a friendly explanation with an upgrade prompt.
Pro users get unrestricted access to all six formats, including large multi-file generations that run as background jobs. A Pro user can ask for "a complete audit program with risk matrix in Excel, engagement letter in Word, and presentation deck in PowerPoint" and the system will generate all three files sequentially within a single agentic loop iteration.
The complexity check is separate from the format check. Even on a paid plan, generating a 100-sheet Excel workbook or a 200-slide PowerPoint would consume excessive server resources. We cap sheets at 20 per workbook, slides at 50 per deck, and pages at 100 per PDF. These limits are generous enough to cover any legitimate use case while preventing abuse.
---
The AI Plans Before It Generates
One pattern we discovered through testing is that the AI produces significantly better documents when it plans the structure first. A raw "generate me a balance sheet" prompt might produce a single-sheet workbook with arbitrary columns. But if the AI first outlines its plan -- "I will create a workbook with three sheets: Active (assets), Passif (liabilities), and Capitaux Propres (equity), following the SYSCOHADA presentation format" -- and the user confirms, the result is dramatically better.
We encode this behaviour in the system prompt. The AI is instructed to describe the document structure before calling the generation tool, especially for complex documents. For simple requests (a single table, a short summary), it can generate immediately. For multi-sheet workbooks, multi-section reports, or slide decks, it outlines the plan and waits for confirmation.
This planning step also serves as a natural checkpoint for credit consumption. A 20-sheet workbook costs more credits than a 3-sheet one. By showing the plan first, the user can adjust scope before committing credits.
---
Large File Generation as Background Jobs
When the AI determines that a file generation will be complex -- multiple files, many sheets, or extensive content that requires several LLM calls to populate -- it switches to background mode. Article 11 in this series covers the background job system in detail, but the file generation integration deserves specific discussion.
The key insight is that generating a 50-page PDF is not a single tool call. The AI needs to generate the content for each section, which means multiple LLM roundtrips. A 10-section report might require 10 separate LLM calls to generate the content, followed by one tool call to assemble the PDF. In streaming mode, this would take 3-5 minutes and risk browser timeout. In background mode, it runs unattended and the user receives a notification when the file is ready.
Background file generation updates progress incrementally. The tool_progress events are stored in Redis and delivered to the frontend via the polling endpoint. The user sees: "Generating section 1 of 10: Introduction" ... "Generating section 5 of 10: Financial Analysis" ... "Assembling PDF" ... "Upload complete." Each step updates the progress bar. When the job completes, the FileDownloadCard appears in the conversation.
---
Numbers
File generation statistics from the first 100 sessions:
- Total files generated: 847
- Format breakdown: XLSX 62%, PDF 24%, DOCX 7%, PPTX 4%, MD 2%, HTML 1%
- Average generation time: 3.8 seconds (excluding background jobs)
- Background job file generation: 12% of all files
- Average file size: 38 KB (XLSX), 142 KB (PDF), 89 KB (DOCX), 215 KB (PPTX)
- S3 storage consumed: 2.1 GB total
- Generation failure rate: 1.4% (primarily malformed JSON from the LLM)
The 1.4% failure rate deserves attention. When the LLM produces invalid JSON -- a missing closing bracket, a string where a number is expected, a null value in a required field -- the generation fails. We handle this with a retry mechanism: if JSON parsing fails, we ask the LLM to fix the JSON and try again, up to two retries. This brings the effective failure rate below 0.3%.
The dominance of XLSX is not surprising. Africa's professional class runs on spreadsheets. Accountants, auditors, financial analysts, tax consultants -- they all think in rows and columns. A tool that generates a properly formatted SYSCOHADA-compliant balance sheet in 4 seconds, from a natural language description, is genuinely transformative for their workflow.
---
This is Part 13 of a 20-part series on building Deblo.ai.
1. AI Tutoring for 250 Million African Students 2. 100 Sessions Later: The Architecture of an AI Education Platform 3. The Agentic Loop: 24 AI Tools in a Single Chat 4. System Prompts That Teach: Anti-Cheating, Socratic Method, and Grade-Level Adaptation 5. WhatsApp OTP and the African Authentication Problem 6. Credits, FCFA, and 6 African Payment Gateways 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. Building a React Native K12 App in 7 Days 10. 101 AI Advisors: Professional Intelligence for Africa 11. Background Jobs: When AI Takes 30 Minutes to Think 12. From Abidjan to 250 Million: The Deblo.ai Story 13. Generating PDFs, Spreadsheets, and Slide Decks From a Chat Message (you are here) 14. Organizations: Families, Schools, and Companies on One Platform 15. Interactive Quizzes With LaTeX: Testing Students Inside a Chat 16. RAG Pipeline: Document Search With pgvector and Semantic Chunking 17. Six Languages, One Platform: i18n for Africa 18. Tasks, Goals, and Recurring Reminders 19. AI Memory and Context Compression 20. Observability: Tracking Every LLM Call in Production