Elena' s AI Blog

Operational Polish: Human Reports and Draft Preview Endpoints

21 Sep 2026 (updated: 21 Sep 2026) / 12 minutes to read

Elena Daehnhardt

Generated by Midjourney. Prompt: Superintelligent AI depicted as a superhero floating above the city.


TL;DR:
  • Three operational additions to a working LangGraph newsletter pipeline, none of which change the graph itself. First, a build_human_report() helper renders the existing JSON run report as Markdown and writes report.md next to report.json. Second, a read-only FastAPI GET /artifacts/{thread_id} route returns the draft through FileResponse, which streams the file asynchronously and sets Content-Length, Last-Modified and ETag for you. Third, the Slack approval message gains a Block Kit section with an mrkdwn link, so an approver on a phone can read the full draft instead of a truncated snippet.

Previous: Part 21 β€” Tiny Local RAG: LangChain + LangGraph + Ollama (Markdown Files)

Next: Part 32 β€” Production Hardening: Idempotency, Run Isolation, and Crash Safety

Operational Polish: Human Reports and Draft Preview Endpoints

By this point in the series, the system drafts newsletters, supervises its own quality, retries when the supervisor is unhappy, pauses for Slack approval, and resumes safely afterwards. That is a genuinely capable pipeline.

It is also, if I am honest, a bit unpleasant to live with. The run report is raw JSON. Slack shows a truncated snippet of the draft. There is no sane way to read the full artefact on a phone, which is exactly where approval requests tend to find you.

This post adds three things, and none of them touch the graph:

  1. report.md β€” a human-readable run summary alongside the JSON one
  2. GET /artifacts/{thread_id} β€” a read-only preview endpoint on the FastAPI server
  3. A Slack approval message carrying a real preview link

No new architecture. Just the operational maturity that turns a demo into something you are willing to be on call for.


Why AI Workflow Runs Need Both a JSON Report and a Markdown Report

A run report is a structured record of everything one workflow execution did β€” revision history, supervisor verdicts, approval status, and timestamps. It exists because AI systems fail in subtle ways. A pipeline that crashes tells you immediately; a pipeline that quietly produced a mediocre draft on revision three tells you nothing at all unless you wrote it down.

The trouble is that the two readers of that record want opposite things. A dashboard, a test, or a later automated pass wants stable keys it can index. A human at 9pm on a Sunday wants to scan five lines and know whether to worry.

So produce both. The JSON stays the source of truth; the Markdown is a rendering of it, generated from the same dictionary, so the two cannot drift apart.


Step 1 β€” Build a Human-Readable Markdown Run Report in LangGraph

Update node_finalize_report() in app/graph.py. After building the JSON report, render the same dictionary as Markdown.

def build_human_report(report: dict) -> str:
    lines = []
    lines.append("# Newsletter Run Summary\n")
    lines.append(f"**Created:** {report['created_at']}")
    lines.append(f"**Approved by supervisor:** {report['approved']}")
    lines.append(f"**Final revision:** {report['final_revision']} / {report['max_revisions']}")
    lines.append("")
    lines.append("## Revision History")

    for entry in report.get("history", []):
        lines.append(
            f"- Revision {entry['revision']} | "
            f"Approved: {entry['approved']} | "
            f"Issues: {entry['issue_count']}"
        )

    if report.get("final_issues"):
        lines.append("\n## Final Issues")
        for issue in report["final_issues"]:
            lines.append(f"- {issue}")

    lines.append("\n---")
    lines.append("Generated by LangGraph Orchestrator")

    return "\n".join(lines)

Then, inside node_finalize_report():

human_md = build_human_report(state["report"])
state["report_md"] = human_md

Note that build_human_report() indexes created_at, approved, final_revision and max_revisions directly, and uses .get() only for the optional history and final_issues keys. That is deliberate. If the finalize node ever stops populating one of the four required keys, I would rather the run fail loudly here than silently emit a report with a blank field in it.


Step 2 β€” Write report.md Alongside report.json in the Run Output Directory

Update app/run.py (or the FastAPI finalize logic, depending on which entry point you are using). After writing report.json, also write the Markdown:

(out_dir / "report.md").write_text(
    result.get("report_md", ""),
    encoding="utf-8"
)

The explicit encoding="utf-8" matters more than it looks. pathlib.Path.write_text() defaults to the platform’s preferred encoding when you omit it, which on some Windows configurations still means cp1252 β€” and a supervisor comment containing a stray em dash will then take your whole run down with a UnicodeEncodeError. Pin it.

Each run now produces:

out/
  newsletter.md
  subject_lines.txt
  report.json
  report.md

Much nicer.


Step 3 β€” Serve Draft Artifacts with a FastAPI FileResponse Endpoint

Now a small read-only endpoint on the FastAPI server. In app/server.py:

from fastapi import HTTPException
from fastapi.responses import FileResponse
from pathlib import Path

ARTIFACT_DIR = Path("out")


@app.get("/artifacts/{thread_id}")
async def view_artifact(thread_id: str):
    """
    Read-only preview of the latest newsletter draft.
    In production you would map thread_id to its own folder.
    """
    file_path = ARTIFACT_DIR / "newsletter.md"

    if not file_path.exists():
        raise HTTPException(status_code=404, detail="No artifact found.")

    return FileResponse(file_path, media_type="text/plain")

Two details are worth pausing on.

FileResponse streams the file asynchronously and fills in Content-Length, Last-Modified and ETag headers for you, per the FastAPI custom-response documentation. You get conditional-request caching without writing any of it. It also infers the media type from the filename when you do not pass one, and a .md file typically resolves to text/markdown, which most browsers download rather than display. Passing media_type="text/plain" keeps the draft readable in the browser window, which is the entire point of this endpoint.

The route takes thread_id and then ignores it. That is intentional for this tutorial, and it is also the safer of the two naive options. The tempting one-line β€œimprovement” β€” ARTIFACT_DIR / thread_id / "newsletter.md" β€” hands an unauthenticated caller a path-traversal primitive, because a thread_id of ../../etc is a perfectly valid path segment as far as pathlib is concerned. If you do isolate artefacts per thread, validate the identifier against a strict allow-list pattern first, and resolve the final path to confirm it still sits inside ARTIFACT_DIR.

Later, you might reasonably want to isolate artefacts per thread_id, render HTML previews instead of plain text, and put the endpoint behind authentication. For this post, clarity beats completeness β€” but do not ship the unauthenticated version to a public host with anything confidential in out/.


Step 4 β€” Add a Slack Block Kit Preview Link to the Approval Message

Modify the Slack message blocks. Inside post_slack_message():

public_base = os.getenv("PUBLIC_BASE_URL")

preview_link = f"{public_base}/artifacts/{thread_id}" if public_base else "Preview unavailable"

Then add another block:

{
    "type": "section",
    "text": {
        "type": "mrkdwn",
        "text": f"<{preview_link}|View Full Draft>"
    }
}

That <url|label> form is Slack’s own mrkdwn link syntax β€” Slack does not accept Markdown’s [label](url) inside a mrkdwn text object, so the square-bracket version will render as literal characters and quietly embarrass you in front of the whole channel. Worth knowing before the first approval request goes out.

The approval message now carries the draft snippet, the Approve and Reject buttons, and a clickable link to the full text. On a phone, that reads as a normal message rather than a wall of truncated Markdown.


Checklist Before You Call This Done

  • report.md and report.json both land in out/ after every run
  • build_human_report() is called from the finalize node, not from the API layer, so both entry points get it
  • GET /artifacts/{thread_id} returns a 404 rather than a 200 with an error body when the file is missing
  • PUBLIC_BASE_URL is set in the deployed environment, otherwise the Slack link degrades to plain text
  • The artefact endpoint is not exposed publicly without auth if the drafts are confidential

Final Thoughts: Observability Is the Cheapest Part of Production

None of this changed how the system thinks. The orchestration graph is byte-for-byte what it was at the start of the post. What changed is how much of itself the system is willing to show you: a readable record of what happened, a URL where the output actually lives, and an approval request you can act on from a phone without squinting.

That gap β€” between a pipeline that works and a pipeline you can supervise β€” is usually a few dozen lines of unglamorous code. It is also the gap most demos never close.

Next in this series, I will pick up structured logging and run visibility, which is the same instinct applied to the parts of the run you cannot see at all.


References

Did you like this post? Please let me know if you have any comments or suggestions.

Python posts that might be interesting for you



desktop bg dark

About Elena

Elena, a PhD in Computer Science, simplifies AI concepts and helps you use machine learning.

Citation
Elena Daehnhardt. (2026) 'Operational Polish: Human Reports and Draft Preview Endpoints', daehnhardt.com, 21 September 2026. Available at: https://daehnhardt.com/blog/2026/09/21/human-reports-and-draft-preview-endpoints/
All Posts