Perfect. This is the “make it feel real” post.
Up to now, your system:
- drafts
- supervises
- retries
- pauses for Slack approval
- resumes safely
But the outputs are still technical.
Now we make them human-friendly and operational.
Below is Post 5 — focused, clean, and practical.
No new architecture. Just polishing the workflow into something you would actually use every week.
---
layout: post
title: "Operational Polish: Human Reports and Draft Preview Endpoints"
date: 2026-02-18
lastmod: 2026-02-18
published: false
image: "https://daehnhardt.com/images/ai_art/flux/langgraph-operational-polish.jpg"
image_title: "Editorial illustration of a clean dashboard showing revision history and approval status, modern calm design, workflow nodes connected clearly, box format"
thumb_image: "https://daehnhardt.com/images/thumbnails/langgraph-operational-polish.jpg"
tags:
- AI
- Python
- Automation
- Infrastructure
- Series
keywords: "LangGraph production workflow, human readable AI reports, Slack approval preview link, artifact endpoint FastAPI"
---
Operational Polish: Human Reports and Draft Preview Endpoints
Your system now:
- drafts newsletters
- supervises quality
- retries if necessary
- pauses for Slack approval
- resumes safely
That is already powerful.
But right now:
- reports are JSON
- Slack shows truncated previews
- there’s no easy way to view artifacts on mobile
Let’s fix that.
We will add:
report.md— human-readable run summary/artifacts/{thread_id}— preview endpoint- Slack message with preview link
No new architecture. Just operational maturity.
Why This Matters
AI systems fail in subtle ways.
When something goes wrong, you need:
- revision history
- supervisor feedback
- human approval status
- timestamps
Machines like JSON.
Humans like readable summaries.
So we produce both.
Step 1 — Write a Human Report (report.md)
Update node_finalize_report() in app/graph.py.
After building the JSON report, also generate Markdown.
Add this inside the finalize node:
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 in node_finalize_report():
human_md = build_human_report(state["report"])
state["report_md"] = human_md
Step 2 — Write the Markdown File
Update app/run.py (or the FastAPI finalize logic).
After writing report.json, also write:
(out_dir / "report.md").write_text(
result.get("report_md", ""),
encoding="utf-8"
)
Now each run produces:
out/
newsletter.md
subject_lines.txt
report.json
report.md
Much nicer.
Step 3 — Add Artifact Preview Endpoint
Now we add a small read-only endpoint to your FastAPI server.
In app/server.py:
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):
"""
Simple preview endpoint for the latest newsletter draft.
In a production system, you'd map thread_id to folders.
"""
file_path = ARTIFACT_DIR / "newsletter.md"
if not file_path.exists():
return {"error": "No artifact found."}
return FileResponse(file_path)
This keeps it simple for now.
Later, you could:
- isolate artifacts per thread_id
- serve HTML-rendered previews
- restrict with auth
For this tutorial, clarity beats complexity.
Step 4 — Add Slack Preview Link
Modify your Slack message block.
Inside post_slack_message():
Add:
public_base = os.getenv("PUBLIC_BASE_URL")
preview_link = f"{public_base}/artifacts/{thread_id}" if public_base else "Preview unavailable"
Then add another Slack block:
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"<{preview_link}|View Full Draft>"
}
}
Now Slack shows:
- draft snippet
- Approve / Reject buttons
- clickable preview link
On mobile, this feels smooth.
What We Did (Conceptually)
We did not change orchestration.
We improved:
- Observability
- Human experience
- Operational clarity
That is production thinking.
What Your System Now Looks Like
You now have:
✔ Local worker (Ollama) ✔ Supervisor (OpenAI) ✔ Retry loop ✔ Max revisions ✔ Slack human approval ✔ Interrupt + resume ✔ JSON audit logs ✔ Human-readable reports ✔ Preview endpoint
This is no longer a tutorial toy.
It is a disciplined AI editorial pipeline.
What Comes Next
Now we have two strong directions:
Option A — Tool Boundaries (MCP)
Introduce:
- FastMCP server
- Isolate external actions
- Prevent prompt injection risks
Option B — Production Hardening
Introduce:
- Idempotency keys
- Per-run artifact isolation
- Structured logging
- Error recovery
Given your style and goals, I would suggest:
Next post: Tool Boundaries with MCP
Because that completes the architecture story.
Then we can close the series with a proper overview.
You are building something thoughtful here. Not flashy. Not chaotic.
Structured.
Shall we move into MCP tool isolation next?