Your multi-agent team just spent 30 seconds coordinating across three LLM calls to produce a research report. The output? A string in your terminal. You copy it into a Google Doc, lose the formatting, forget to share it, and next week nobody can find it. The agents did their job. Your output pipeline didn't.
Here's how to fix that with SurfaceDocs — an API-first document layer that turns agent output into hosted, shareable, versioned documents. No frontend to build. No S3 buckets to configure.
The Problem: AutoGen Output Goes Nowhere
Let's start with a typical AutoGen 0.4 setup. Three agents collaborate in a RoundRobinGroupChat to research a topic and produce a report:
import asyncio
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination
from autogen_ext.models.openai import OpenAIChatCompletionClient
model_client = OpenAIChatCompletionClient(model="gpt-4o")
researcher = AssistantAgent(
"researcher",
model_client=model_client,
system_message="You are a research analyst. Given a topic, find key facts, trends, and data points. Be thorough but concise.",
)
analyst = AssistantAgent(
"analyst",
model_client=model_client,
system_message="You are a strategic analyst. Take the researcher's findings and identify insights, risks, and opportunities. Be opinionated.",
)
writer = AssistantAgent(
"writer",
model_client=model_client,
system_message="You are a report writer. Synthesize the research and analysis into a clear, structured report. When done, end your message with FINAL_REPORT.",
)
team = RoundRobinGroupChat(
[researcher, analyst, writer],
termination_condition=TextMentionTermination("FINAL_REPORT"),
)
async def main():
result = await team.run(task="Research the current state of edge AI deployment in manufacturing")
# Now what?
print(result.messages[-1].content) # <- This is where most pipelines stop
asyncio.run(main())
That print() at the end is the whole problem. The report exists for exactly as long as your terminal session does. Maybe you pipe it to a file. Maybe you paste it into Slack. Either way, it's not searchable, not shareable via URL, and not versioned.
The Fix: Three Lines to a Hosted Document
Install the SurfaceDocs SDK alongside your AutoGen dependencies:
pip install autogen-agentchat autogen-ext[openai] surfacedocs
Now replace that print() with something useful:
from surfacedocs import SurfaceDocs
docs = SurfaceDocs() # reads SURFACEDOCS_API_KEY from env
result = docs.save_raw(
title="Edge AI in Manufacturing — Research Report",
blocks=[
{"type": "heading", "content": "Edge AI in Manufacturing", "metadata": {"level": 1}},
{"type": "paragraph", "content": report_content},
],
metadata={"source": "autogen-team", "tags": ["research", "edge-ai"]},
)
print(result.url) # https://app.surfacedocs.dev/d/abc123
That URL is live immediately. Share it in Slack, drop it in a ticket, send it to a stakeholder. The document is hosted, formatted, and permanent.
But we can do better than manually constructing blocks.
Full Working Example: Structured Output From Agents
The real power comes from having your writer agent output structured content that maps directly to SurfaceDocs' document schema. SurfaceDocs ships a SYSTEM_PROMPT and DOCUMENT_SCHEMA you can inject into your agent's instructions — the LLM outputs JSON, and the SDK saves it directly.
Here's the complete, runnable pipeline:
import asyncio
import json
from autogen_agentchat.agents import AssistantAgent
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination
from autogen_ext.models.openai import OpenAIChatCompletionClient
from surfacedocs import SurfaceDocs, SYSTEM_PROMPT, DOCUMENT_SCHEMA
model_client = OpenAIChatCompletionClient(model="gpt-4o")
docs = SurfaceDocs() # uses SURFACEDOCS_API_KEY env var
researcher = AssistantAgent(
"researcher",
model_client=model_client,
system_message=(
"You are a research analyst. Given a topic, identify key facts, "
"recent developments, statistics, and notable players. "
"Present findings as structured bullet points. Be thorough."
),
)
analyst = AssistantAgent(
"analyst",
model_client=model_client,
system_message=(
"You are a strategic analyst. Review the researcher's findings and "
"provide: 1) Key insights, 2) Risks and challenges, 3) Opportunities. "
"Be direct and opinionated. Support claims with the research provided."
),
)
writer = AssistantAgent(
"writer",
model_client=model_client,
system_message=(
"You are a report writer. Take the research and analysis from your "
"teammates and produce a final report.\n\n"
"OUTPUT FORMAT: You must output ONLY valid JSON matching this schema:\n"
f"{json.dumps(DOCUMENT_SCHEMA, indent=2)}\n\n"
"Use heading, paragraph, list, and quote block types to structure "
"the report clearly. Include a title field.\n\n"
"When your JSON output is complete, add FINAL_REPORT on the last line."
),
)
team = RoundRobinGroupChat(
[researcher, analyst, writer],
termination_condition=TextMentionTermination("FINAL_REPORT"),
)
async def run_research_team(topic: str, folder_id: str | None = None) -> str:
"""Run the research team and publish output to SurfaceDocs."""
result = await team.run(task=topic)
# The writer's output is the last message (before termination)
final_output = result.messages[-1].content
# Strip the FINAL_REPORT marker and parse JSON
json_str = final_output.replace("FINAL_REPORT", "").strip()
try:
doc_json = json.loads(json_str)
# Use docs.save() which accepts LLM-structured JSON directly
doc_result = docs.save(doc_json, folder_id=folder_id)
except json.JSONDecodeError:
# Fallback: save as raw paragraphs if JSON parsing fails
doc_result = docs.save_raw(
title=f"Research Report: {topic[:50]}",
blocks=[
{"type": "heading", "content": f"Research: {topic}", "metadata": {"level": 1}},
{"type": "paragraph", "content": final_output},
],
metadata={"source": "autogen-team", "tags": ["research"]},
)
return doc_result.url
async def main():
url = await run_research_team(
topic="Analyze the current state of edge AI deployment in manufacturing: "
"key vendors, adoption barriers, and 2026 outlook.",
folder_id="your_folder_id", # optional: organize into a folder
)
print(f"Report published: {url}")
asyncio.run(main())
A few things to note:
-
DOCUMENT_SCHEMAtells the writer agent exactly what JSON structure to produce. No prompt engineering guesswork. -
docs.save()accepts the raw LLM JSON output and handles validation. If the agent produces well-formed document JSON, it goes straight to a hosted doc. -
The fallback matters. LLMs sometimes fumble structured output. Wrapping the parse in a try/except with
save_raw()means you never lose output — worst case, it's a plain paragraph instead of a formatted report. -
folder_idis optional but useful. Organize reports by project, client, or team.
Versioning: Same URL, Updated Content
Research isn't a one-shot task. You run the team on Monday, get feedback, run it again on Wednesday with a refined prompt. Without versioning, that's two separate documents and a broken link in someone's Slack message.
push_version solves this. Same document URL, new content:
async def update_report(document_id: str, topic: str) -> str:
"""Re-run the team and push a new version to an existing document."""
result = await team.run(task=topic)
final_output = result.messages[-1].content
json_str = final_output.replace("FINAL_REPORT", "").strip()
try:
doc_json = json.loads(json_str)
version_result = docs.push_version(document_id, doc_json)
except json.JSONDecodeError:
# Fallback: push raw content as new version
version_result = docs.push_version(document_id, {
"title": f"Research Report (Updated): {topic[:50]}",
"blocks": [
{"type": "paragraph", "content": final_output},
],
})
return version_result.url
async def main():
# First run — creates the document
url = await run_research_team("Edge AI in manufacturing: 2026 outlook")
document_id = url.split("/")[-1] # extract ID from URL
print(f"v1 published: {url}")
# Second run — updates the same document
url = await update_report(document_id, "Edge AI in manufacturing: 2026 outlook with focus on ROI data")
print(f"v2 published: {url}") # same URL, new content
asyncio.run(main())
Anyone who bookmarked or received the original URL now sees the latest version. Previous versions are still accessible through the SurfaceDocs UI. This is particularly useful for recurring reports — weekly market scans, daily standups, ongoing research threads.
Why This Matters
The pattern here isn't specific to research reports. Any AutoGen pipeline that produces human-readable output benefits from a proper output layer:
- Code review summaries from a multi-agent review team → versioned docs per PR
- Competitive analysis from researcher + analyst agents → shared with the sales team via URL
- Incident postmortems from log-analyzer + writer agents → searchable via the SurfaceDocs API
- Meeting notes from transcription + summarizer agents → organized in folders by project
The point is: agent output deserves better than print(). It deserves a URL.
Where to Go From Here
- SurfaceDocs docs: docs.surfacedocs.dev — full SDK reference, block types, search API
- SurfaceDocs: surfacedocs.dev — free tier, no credit card
- AutoGen docs: microsoft.github.io/autogen — team patterns, custom agents, tool use
-
pip install surfacedocs autogen-agentchat autogen-ext[openai]— everything you need
The multi-agent coordination problem is mostly solved. The output problem isn't. Wire in a proper output layer and your agents' work actually goes somewhere.
Top comments (0)