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:
report.mdβ a human-readable run summary alongside the JSON oneGET /artifacts/{thread_id}β a read-only preview endpoint on the FastAPI server- 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.mdandreport.jsonboth land inout/after every runbuild_human_report()is called from the finalize node, not from the API layer, so both entry points get itGET /artifacts/{thread_id}returns a 404 rather than a 200 with an error body when the file is missingPUBLIC_BASE_URLis 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
- FastAPI:
FileResponseand other custom responses β asynchronous file streaming, media-type inference, and theContent-Length/Last-Modified/ETagheaders it sets - FastAPI: handling errors with
HTTPExceptionβ returning a real 404 instead of a 200 with an error body - Python docs:
pathlib.Path.write_text()β why theencodingargument is not optional in practice - Slack: formatting message text β the
<url|label>mrkdwn link syntax - Slack: Block Kit section block reference β the block type used for the preview link
- LangGraph: persistence and threads β where
thread_idcomes from in the first place
Did you like this post? Please let me know if you have any comments or suggestions.
Python posts that might be interesting for youStay Ahead in AI, Machine Learning & Python
No hype. Weekly notes on AI tools, Python, and what I'm actually building β plus six free gifts, including the 15-page Fantastic AI: The 2026 Toolkit and a Git Commands & Contribution Workflow Cheatsheet.
You're in
Check your inbox for Set a password to unlock articles if you want gated tutorials. Log in with the same email.