Free lesson · Forward Deployed GenAI Engineering
Generate full SOW proposals with LangGraph workflows
You build a ProposalDraftGenerator as a LangGraph workflow chaining executive_summary, technical_approach, timeline, pricing, and review nodes with cross-section consistency checks.
Course: AI Solution Delivery · Chapter 3 · SOW & Proposal Generation
Free to read — no subscription required.
Introduction
Engineers often spend hours manually assembling Statements of Work, stitching together scope descriptions, milestone schedules, pricing tables, and risk registers into a client document — and still ship drafts where the timeline contradicts the pricing section. Automating this pipeline with LangGraph and an LLM as the drafting engine lets each SOW section inform the next, enforcing cross-section consistency before any document leaves the system. By the end of this lesson, you'll be able to build a multi-node LangGraph workflow that generates each proposal section in sequence, passes structured state between nodes, and validates the complete SOW before final assembly.
Key Terminology
- ProposalState — A
TypedDictthat serves as the single shared dictionary flowing through the entire LangGraph proposal pipeline, holding both raw input fields (scope_data,milestone_schedule,pricing,risk_register,acceptance_criteria) and the accumulated output fields that each node populates as it runs. - Node function — A Python function wired into the LangGraph
StateGraphthat receives the full shared state as its argument, calls the LLM to generate one proposal section, and returns a partial dictionary containing only the keys it writes (e.g.,{"executive_summary": ...}). - Return-dict pattern — The convention where each node returns a dictionary of only the keys it is responsible for writing; LangGraph merges that partial result back into
ProposalStateautomatically, making the new values immediately visible to all downstream nodes. - Cross-section consistency — The property achieved when a downstream node (such as
generate_technical_approach) explicitly reads an upstream node's output (such asstate['executive_summary']) as a grounding input to its prompt, preventing contradictions between sections that would arise if each node were generated independently. - Review gate — The combination of the
consistency_issueslist and thereview_passedboolean inProposalStatethat allows a terminal validation node to inspect the assembled sections and block final document assembly if quality checks fail.
Concepts
Shared State as the Pipeline's Backbone
Generating a multi-section SOW in sequence presents a coordination problem: the pricing section needs to reference the milestone schedule, the technical approach needs to echo what the executive summary promised, and a final validator needs to see every section at once. The standard approach — passing return values from function to function — breaks down when the graph has branches or when the number of shared fields grows.
LangGraph solves this with a single shared state dictionary defined as a TypedDict. Every node in the graph receives the entire state, reads whatever fields it needs, and writes back only the fields it produces. Because LangGraph merges each node's return dictionary into the live state object, every downstream node automatically sees the accumulated outputs of every upstream node — without any explicit wiring between node function signatures (see Code Walkthrough).
The Return-Dict Pattern and Why Nodes Only Write Their Own Keys
A common mistake when building multi-node pipelines is having nodes return the full state dictionary after modifying it. This creates coupling: if two nodes touch different fields and run concurrently, one will silently overwrite the other's results. The return-dict pattern avoids this entirely — each node returns a dictionary containing only the keys it is responsible for. LangGraph performs the merge.
For example, generate_executive_summary returns {"executive_summary": response.choices[0].message.content} and nothing else. It does not touch technical_approach, pricing_section, or any other field. This is what makes the graph composable: adding a new section node is a matter of writing a function that reads the fields it needs and returns the single key it owns, with no changes to any other node.
Sequential Ordering Enables Grounded Generation
The ordering of nodes in the StateGraph is not cosmetic — it determines which previously generated content each node can use as a grounding input to its prompt. generate_technical_approach is scheduled after generate_executive_summary precisely because it passes state['executive_summary'] directly into the user message of its GPT-4o call. The LLM is asked to stay consistent with what the executive summary already committed to, rather than generating a technical narrative independently.
This sequential dependency pattern extends to the validation step. The review_passed flag and consistency_issues list in ProposalState give a final node the ability to inspect every generated section before any document assembly happens. If cross-section inconsistencies are detected, the gate stays closed — final assembly never runs. This design keeps the document integrity check inside the graph itself rather than delegating it to a post-processing step outside the pipeline.
Code Walkthrough
Now that you understand how milestone definitions, acceptance criteria, and pricing data need to flow through a structured pipeline, the ProposalState TypedDict is the mechanism that makes every node's output immediately available to every downstream node.
The state model defines a single shared dictionary. Input fields — scope_data, milestone_schedule, pricing, risk_register, and acceptance_criteria — carry raw structured inputs into the graph. Output fields — executive_summary, technical_approach, timeline_section, pricing_section, and terms_and_conditions — accumulate as each node runs. The consistency_issues list and review_passed flag let a final validation node gate document assembly on quality:
Code snippetpython
1from typing import TypedDict, Optional, List 2from langgraph.graph import StateGraph, END 3 4class ProposalState(TypedDict): 5 """State shared across proposal generation nodes.""" 6 scope_data: dict 7 milestone_schedule: dict 8 pricing: dict 9 risk_register: dict 10 acceptance_criteria: list 11 # Generated sections 12 executive_summary: Optional[str] 13 technical_approach: Optional[str] 14 timeline_section: Optional[str] 15 pricing_section: Optional[str] 16 terms_and_conditions: Optional[str] 17 # Review results 18 consistency_issues: List[str] 19 review_passed: bool
With the state model in place, each node function reads from shared state and returns only the keys it writes. generate_executive_summary calls GPT-4o with a system prompt focused on business value, approach, and expected outcomes, drawing on scope_data, milestone_schedule, and pricing. generate_technical_approach runs next and reads the already-written executive_summary to keep the technical narrative consistent with what the executive summary promised — preventing cross-section drift. The return-dict pattern is what LangGraph uses to merge node output back into shared state automatically:
Code snippetpython
1import openai 2 3proxy_url = "http://openai-proxy:8080" # injected by the lab environment 4 5def generate_executive_summary(state: dict) -> dict: 6 """Generate the executive summary from scope data.""" 7 client = openai.OpenAI(api_key="student-token", base_url=proxy_url) 8 response = client.chat.completions.create( 9 model="gpt-4o", 10 messages=[ 11 { 12 "role": "system", 13 "content": ( 14 "You are a proposal writer for AI delivery engagements. " 15 "Write a concise executive summary that highlights " 16 "business value, approach, and expected outcomes." 17 ), 18 }, 19 { 20 "role": "user", 21 "content": ( 22 f"Scope: {state['scope_data']}\n" 23 f"Milestones: {state['milestone_schedule']}\n" 24 f"Pricing: {state['pricing']}" 25 ), 26 }, 27 ], 28 ) 29 return {"executive_summary": response.choices[0].message.content} 30 31def generate_technical_approach(state: dict) -> dict: 32 """Generate technical approach referencing exec summary.""" 33 client = openai.OpenAI(api_key="student-token", base_url=proxy_url) 34 response = client.chat.completions.create( 35 model="gpt-4o", 36 messages=[ 37 { 38 "role": "system", 39 "content": ( 40 "Write the technical approach section. " 41 "Reference the executive summary for consistency. " 42 "Detail architecture, tools, and methodology." 43 ), 44 }, 45 { 46 "role": "user", 47 "content": ( 48 f"Executive Summary: {state['executive_summary']}\n" 49 f"Scope: {state['scope_data']}" 50 ), 51 }, 52 ], 53 ) 54 return {"technical_approach": response.choices[0].message.content}
Only return the keys your node writes — the graph engine handles the merge. Every downstream node then sees the updated state with the new section populated, which is what allows generate_technical_approach to reference state['executive_summary'] as a grounding input.
Verify by constructing a minimal state dictionary with sample scope_data, milestone_schedule, and pricing values, calling generate_executive_summary on it, merging the result back into the state dict, then calling generate_technical_approach on the merged state, and confirming both returned dictionaries contain non-empty strings for their respective section keys.
Do's and Don'ts
Having walked through the material above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do declare every input and output field in
ProposalStateas aTypedDictbefore wiring any nodes — includingOptionaloutput fields likeexecutive_summaryandtechnical_approach— because LangGraph's merge engine expects all state keys to exist at graph initialization; absent keys causeKeyErrorwhen downstream nodes likegenerate_technical_approachreadstate['executive_summary']. - ✓Do pass upstream section output as an explicit prompt input to each downstream node — feed
state['executive_summary']intogenerate_technical_approach's user message so the technical narrative stays anchored to the business value claims the executive summary already made, which is the mechanism that prevents the timeline-contradicts-pricing cross-section drift that manual SOW assembly reliably produces. - ✓Do gate final document assembly on
review_passedand inspectconsistency_issuesbefore concatenating sections — the validation node writes these two fields precisely so the graph can halt on detected cross-section contradictions (e.g., a milestone date that conflicts with the pricing table) before any document leaves the pipeline.
Don'ts
- ✗Don't return the full state dictionary from a node —
returnonly the keys the node writes (e.g.,{"executive_summary": response.choices[0].message.content}), because LangGraph merges partialreturndicts into shared state; returning keys your node did not generate silently overwrites sibling nodes' outputs liketechnical_approachwith stale or wrong values. - ✗Don't write
generate_technical_approachto read onlyscope_datawithout also readingstate['executive_summary']— generating the technical section in isolation lets the two sections diverge so the executive summary promises outcomes the technical approach never delivers, which is the specific inconsistency this sequential node ordering is designed to eliminate. - ✗Don't assume LangGraph's auto-merge applies when verifying node functions outside the graph — when testing
generate_technical_approachin isolation, you must manually mergegenerate_executive_summary'sreturndict into the state dict first, otherwisestate['executive_summary']isNoneand the technical approach prompt generates a section with no grounding context.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the Forward Deployed GenAI Engineering subscription.
From · cancel anytime
More free lessons in AI Solution Delivery
- Ch 1Generate executive discovery reports from structured assessment data
- Ch 2Classify project risks with DSPy-optimized prompts
- Ch 3Generate full SOW proposals with LangGraph workflowsYou are here
- Ch 3Detect risky contract language with NeMo Guardrails
- Ch 4Build a RAG prototype with pgvector retrieval
- Ch 4Package prototypes with Dockerfiles, Helm charts, and K8s manifests
- Ch 5Detect and redact PII with Presidio and LlamaGuard 4