# Share a LangGraph Run's Output — Publish From a Node

Canonical: https://commareports.com/agents/share-langgraph-output
Published: 2026-09-13

> A LangGraph run ends with a report in state and nobody to read it. Add a publish node, return a URL, and let the graph read the comments on its next run.

# 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`:

```python
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](/agents/context-budgeting-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:

```python
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](/comment-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](/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](/agents/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 →](/docs/api)**

### Related

- [Where should my agent post?](/agents/where-should-my-agent-post) ·
  [Let an agent respond to comments](/agents/let-an-agent-respond-to-comments)
- [Share a LangSmith eval result](/agents/share-langsmith-eval-report) ·
  [Share a LlamaIndex workflow's output](/agents/share-llamaindex-output)
- [The API](/docs/api) · [MCP setup](/mcp)
