Deep Research Agent
%%{init: {'look': 'handDrawn', 'theme': 'base', 'themeVariables': {'primaryColor': '#eef2ff', 'primaryBorderColor': '#1e40af', 'primaryTextColor': '#1e293b', 'lineColor': '#1e3a8a', 'edgeLabelBackground': '#ffffff', 'clusterBkg': '#fbfcff', 'clusterBorder': '#2563eb', 'fontFamily': '-apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif', 'fontSize': '15px'}, 'flowchart': {'nodeSpacing': 50, 'rankSpacing': 58, 'padding': 14, 'htmlLabels': true, 'curve': 'basis'}}}%%
flowchart LR
G(["Research goal"]) --> D("Break it into<br/>subtopics")
D --> F
subgraph round["keep going until it holds up"]
direction LR
F("Research each one<br/>in parallel") --> R{"Enough<br/>evidence?"}
end
R -. "no · dig into the gaps" .-> F
R == "yes" ==> W("Write the brief")
W --> P("Hand back a PDF")
style round stroke-dasharray: 6 5Outcome: turn a research goal into a decision-ready brief — decomposed into subtopics, researched in parallel with provider-native web search, reviewed for coverage after each round, and rendered as a PDF once the evidence holds up.
The loop is the recipe
A single research prompt with web search enabled returns something that reads well and stops at whatever the model found on its first pass. There is no notion of "this is thin" because nothing is checking.
This workflow separates finding from judging, and lets judging drive the next round:
decompose_goalsplits the goal into 3–5 independently searchable subtopics.prepare_research_fanoutbuilds oneLLM_CHAT_COMPLETEinput per open subtopic in JQ — the subtopic count determines the width of the fan-out at runtime.research_subtopicsis aFORK_JOIN_DYNAMICoverLLM_CHAT_COMPLETEwithwebSearch: true. Subtopics are researched concurrently, each as its own durable, retryable task.review_coverageruns ongpt-4oand is explicitly forbidden from writing the brief. It returns{sufficient, gaps, nextSubtopics}.- When
sufficientis false,nextSubtopicsbecomes the next round's fan-out — the loop researches the gaps, not the original list again.
The loop condition bounds both dimensions:
Five rounds maximum, and !== true means an absent or malformed verdict keeps the loop from exiting on a false positive. Whatever the state when the loop ends, write_brief receives the accumulated evidence and the unresolved gaps, and is instructed to carry them into an Open questions section rather than resolving them from its own knowledge. A brief that admits what it could not find is the useful output.
Prerequisites
An OpenAI integration whose model supports webSearch. PDF rendering is built in — GENERATE_PDF needs no external service.
Cost scales as rounds × subtopics, so the worst case here is 5 × 5 = 25 web-search calls plus 5 review calls. The research calls use gpt-4o-mini; only review_coverage and write_brief use gpt-4o. Lower the round cap before widening the fan-out if you need to cut spend.
Runnable definition
Save this as deep-research-agent.json:
{
"name": "deep_research_agent",
"description": "Decomposes a research goal into subtopics, researches them in parallel with provider-native web search, has a model review coverage after each round, and loops for up to five rounds before rendering a source-linked brief as a PDF.",
"version": 1,
"schemaVersion": 2,
"timeoutSeconds": 3600,
"timeoutPolicy": "TIME_OUT_WF",
"inputParameters": [
"goal",
"audience"
],
"variables": {
"open_subtopics": [],
"evidence": [],
"review": {
"sufficient": false,
"gaps": [],
"rounds": 0
}
},
"tasks": [
{
"name": "decompose_goal",
"taskReferenceName": "decompose",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o-mini",
"messages": [
{
"role": "system",
"message": "Break a research goal into 3 to 5 independent, specifically searchable subtopics. Return JSON: {\"subtopics\": [string]}. Each subtopic must be answerable on its own with a web search; do not return overlapping subtopics."
},
{
"role": "user",
"message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}"
}
],
"temperature": 0.2,
"maxTokens": 500,
"jsonOutput": true
}
},
{
"name": "seed_subtopics",
"taskReferenceName": "seed_subtopics",
"type": "SET_VARIABLE",
"inputParameters": {
"open_subtopics": "${decompose.output.result.subtopics}"
}
},
{
"name": "research_rounds",
"taskReferenceName": "research_loop",
"type": "DO_WHILE",
"evaluatorType": "graaljs",
"inputParameters": {
"research_loop": "${research_loop.output}",
"sufficient": "${review_coverage.output.result.sufficient}"
},
"loopCondition": "(function(){ return $.research_loop['iteration'] < 5 && $.sufficient !== true; })();",
"loopOver": [
{
"name": "prepare_research_fanout",
"taskReferenceName": "prepare_fanout",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"subtopics": "${workflow.variables.open_subtopics}",
"goal": "${workflow.input.goal}",
"queryExpression": ". as $root | {forkInputs: (($root.subtopics // [])[:5] | map({llmProvider: \"openai\", model: \"gpt-4o-mini\", webSearch: true, temperature: 0.2, maxTokens: 1200, messages: [{role: \"system\", message: \"Research the subtopic with web search. Return markdown with findings and a Sources section of full URLs. If the evidence is thin or contested, say so explicitly rather than filling the gap.\"}, {role: \"user\", message: (\"Goal: \" + $root.goal + \"\\nSubtopic: \" + .)}]}))}"
}
},
{
"name": "research_subtopics",
"taskReferenceName": "research_fanout",
"type": "FORK_JOIN_DYNAMIC",
"inputParameters": {
"forkTaskType": "LLM_CHAT_COMPLETE",
"forkTaskInputs": "${prepare_fanout.output.result.forkInputs}"
}
},
{
"name": "join_research",
"taskReferenceName": "join_research",
"type": "JOIN",
"joinOn": []
},
{
"name": "accumulate_evidence",
"taskReferenceName": "accumulate",
"type": "JSON_JQ_TRANSFORM",
"inputParameters": {
"existing": "${workflow.variables.evidence}",
"round": "${join_research.output}",
"queryExpression": "{evidence: (((.existing // []) + [((.round | tojson)[0:20000])]) | .[-5:])}"
}
},
{
"name": "store_evidence",
"taskReferenceName": "store_evidence",
"type": "SET_VARIABLE",
"inputParameters": {
"evidence": "${accumulate.output.result.evidence}"
}
},
{
"name": "review_coverage",
"taskReferenceName": "review_coverage",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o",
"messages": [
{
"role": "system",
"message": "You review research coverage against a goal. You do not write the brief. Return JSON: {\"sufficient\": boolean, \"gaps\": [string], \"nextSubtopics\": [string]}. Set sufficient=true only when the evidence supports a decision-ready brief with real sources. When false, nextSubtopics must be 1 to 3 new searchable subtopics that close the largest gaps."
},
{
"role": "user",
"message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}\nEvidence so far: ${workflow.variables.evidence}"
}
],
"temperature": 0.0,
"maxTokens": 700,
"jsonOutput": true
}
},
{
"name": "record_review",
"taskReferenceName": "record_review",
"type": "SET_VARIABLE",
"inputParameters": {
"open_subtopics": "${review_coverage.output.result.nextSubtopics}",
"review": {
"sufficient": "${review_coverage.output.result.sufficient}",
"gaps": "${review_coverage.output.result.gaps}",
"rounds": "${research_loop.output.iteration}"
}
}
}
]
},
{
"name": "write_brief",
"taskReferenceName": "write_brief",
"type": "LLM_CHAT_COMPLETE",
"inputParameters": {
"llmProvider": "openai",
"model": "gpt-4o",
"messages": [
{
"role": "system",
"message": "Write a decision-ready research brief in markdown for the stated audience. Sections: Executive summary, Findings, Competing evidence, Open questions, Sources. Every non-obvious claim must trace to a source URL in the evidence. Carry any remaining gaps into Open questions rather than resolving them from your own knowledge."
},
{
"role": "user",
"message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}\nEvidence: ${workflow.variables.evidence}\nKnown gaps: ${workflow.variables.review.gaps}"
}
],
"temperature": 0.3,
"maxTokens": 3000
}
},
{
"name": "render_brief_pdf",
"taskReferenceName": "render_pdf",
"type": "GENERATE_PDF",
"inputParameters": {
"markdown": "${write_brief.output.result}",
"pageSize": "A4",
"theme": "default",
"pdfMetadata": {
"title": "${workflow.input.goal}",
"author": "Conductor Deep Research Agent",
"subject": "Research brief for ${workflow.input.audience}"
}
}
}
],
"outputParameters": {
"brief": "${write_brief.output.result}",
"pdf": "${render_pdf.output}",
"review": "${workflow.variables.review}",
"rounds": "${research_loop.output.iteration}"
}
}
Register and run
conductor workflow create deep-research-agent.json
conductor workflow start -w deep_research_agent --sync -i '{"goal":"Assess the market category for organic coffee in North America","audience":"engineering leadership"}'
Open Executions in the Conductor UI and select the new execution to review the task graph, and each task's inputs and outputs.
rounds in the output tells you how much work the goal actually needed. A vague goal typically burns all five rounds and still reports gaps; a narrow one converges in one or two. That number is a useful signal about the question, not just the run.
Production notes
- Cap the evidence you carry. Unbounded accumulation runs past the context window and the review call starts failing.
- Use your best model for the review. It's the only thing deciding whether the work is done.
- Store the PDF, pass a URI. Don't push binaries through workflow state.
- Keep the source URLs. A conclusion you can't re-derive in six months isn't evidence.
- Cost is rounds x subtopics. Lower the round cap before widening the fan-out.
- Web results are untrusted input. Review the Sources section before circulating anything regulated.
- It publishes nothing. Put an approval in front of external delivery.