Share what a LangGraph run produced

A graph finishes. The final state has a report key holding several thousand lines of HTML, and the run returns it to whatever called invoke.

If that caller was a script, the report is in a variable. If it was a LangGraph Platform deployment, it is in a response body on a server you do not shell into. Either way, the analyst who needed it has nothing.

Put the publish in a node

The graph already models "then this happens" as an edge. Publishing is a node like any other — the last one before END:

def publish(state: State) -> dict:
    r = httpx.post(
        "https://commareports.com/api/v1/reports",
        headers={"Authorization": f"Bearer {os.environ['COMMA_API_TOKEN']}"},
        json={
            "title": state["title"],
            "html": state["report_html"],
        },
    ).json()
    return {"report_url": r["url"], "report_id": r["id"], "report_html": ""}

graph.add_node("publish", publish)
graph.add_edge("analyze", "publish")
graph.add_edge("publish", END)

Note the last line of the return. Clearing report_html once it has an address is the whole trick: LangGraph checkpoints state after every node, so a large document in state gets serialized again on each step, and gets replayed into context if the graph resumes. Swapping the document for its URL is the same move as context budgeting for AI reports.

Interrupts get something to interrupt for

Human-in-the-loop is the case where this stops being convenience. interrupt() suspends the graph and waits for a human value, which means the human has to form an opinion about work they cannot see.

Publish first, interrupt second:

def review_gate(state: State):
    decision = interrupt({
        "message": "Review the findings and approve or send back.",
        "report_url": state["report_url"],
    })
    return {"approved": decision == "approve"}

Now the reviewer gets a page, not a JSON blob — and the notes they leave are anchored to the paragraph they disagree with, per commenting on HTML. On resume, the graph can pull those threads and act on them rather than on a single approve/reject bit.

Deployed graphs have no filesystem you can reach

This is the failure people hit second. Locally, open report.html works and the publish node feels optional. On LangGraph Platform — or any container, any worker — the file is written into a sandbox that is gone when the run ends.

It is the same problem a CI job has, and it has the same fix: a URL. See publishing from CI.

Worth knowing

  • One report, many revisions. Store report_id in state and update it on the next run, so a recurring graph keeps one address instead of scattering a new link per execution.
  • Scope the token to reports:write. A graph with tool access should not hold a token that can do more than publish — see scoped tokens for AI agents.
  • 5 MB per report body.
  • Reports are snapshots. The HTML renders sandboxed; nothing re-executes.

Try it

Free — unlimited reports, commenters and revisions.

Read the API reference →

Related