
Agent Workflow Designer
- 108 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
agent-workflow-designer is a Claude skill that designs multi-agent orchestration systems using five core patterns across LangGraph, CrewAI, AutoGen, and Claude Code.
About
agent-workflow-designer is a Claude skill for designing multi-agent orchestration systems. A developer uses it to pick an orchestration pattern, define agent routing and handoffs, manage workflow state, and add reliability like circuit breakers and retries. It provides framework-specific implementations for LangGraph, CrewAI, AutoGen, and Claude Code agent teams, plus scripts for cost estimation and workflow validation.
- Designs multi-agent orchestration with five patterns (sequential, fan-out/fan-in, hierarchical, event-driven, consensus)
- Covers agent routing, circuit breakers, context budgeting, and cost optimization
- Targets LangGraph, CrewAI, AutoGen, and Claude Code agent teams
Agent Workflow Designer by the numbers
- 108 all-time installs (skills.sh)
- Ranked #4,093 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
agent-workflow-designer capabilities & compatibility
Free skill; example code calls LLM APIs (e.g. Anthropic) which incur token cost when run.
- Capabilities
- agenthub · agent protocol · workflow validator
- Works with
- anthropic
- Use cases
- orchestration · planning
- Pricing
- Free
What agent-workflow-designer says it does
The agent designs multi-agent orchestration systems using five core patterns: sequential pipeline, parallel fan-out/fan-in, hierarchical delegation, event-driven reactor, and consensus validation.
It implements agent routing strategies, circuit breaker reliability patterns, context window budgeting, and cost optimization
Building multi-step AI pipelines that exceed one agent's capability
npx skills add https://github.com/borghei/claude-skills --skill agent-workflow-designerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 108 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Design fault-tolerant multi-agent AI pipelines with the right orchestration pattern, routing, and cost controls.
Who is it for?
Developers building production multi-agent pipelines who need pattern selection, routing, and reliability engineering.
Skip if: Single-agent tasks that do not need orchestration.
When should I use this skill?
When building AI pipelines with multiple specialized agents, designing fan-out/fan-in patterns, or implementing fault-tolerant workflows.
What you get
A chosen orchestration pattern with typed handoffs, routing, state management, and reliability controls.
- orchestration pattern selection
- agent routing design
- reliability and cost strategy
By the numbers
- 5 core orchestration patterns
- 3 scripts (cost_estimator, workflow_validator, workflow_visualizer)
- 4 target frameworks (LangGraph, CrewAI, AutoGen, Claude agent teams)
Files
Agent Workflow Designer
The agent designs multi-agent orchestration systems using five core patterns: sequential pipeline, parallel fan-out/fan-in, hierarchical delegation, event-driven reactor, and consensus validation. It implements agent routing strategies, circuit breaker reliability patterns, context window budgeting, and cost optimization across LangGraph, CrewAI, AutoGen, and Claude Code agent teams.
Core Capabilities
1. Pattern Selection and Design
- Sequential pipelines with typed handoffs
- Parallel fan-out/fan-in with merge strategies
- Hierarchical delegation with dynamic subtask discovery
- Event-driven reactors with pub/sub agent triggers
- Consensus validation with voting and arbitration
2. Agent Routing
- Intent-based routing with classifier agents
- Skill-based routing using capability matching
- Cost-aware routing (cheap models for simple tasks)
- Load-balanced routing across agent pools
- Fallback chains with graceful degradation
3. State and Context Management
- Persistent workflow state across agent hops
- Context window budgeting and summarization
- Checkpoint/resume for long-running workflows
- Conflict resolution for parallel state updates
4. Reliability Engineering
- Circuit breakers for failing agents
- Retry with exponential backoff and model fallback
- Dead letter queues for unprocessable tasks
- Timeout enforcement at every agent boundary
- Idempotent operations for safe retries
When to Use
- Building multi-step AI pipelines that exceed one agent's capability
- Parallelizing research, analysis, or generation tasks
- Creating specialist agent teams with defined roles and contracts
- Designing fault-tolerant AI workflows for production deployment
- Optimizing cost across workflows with mixed model tiers
Pattern Selection Decision Tree
What does the workflow look like?
│
├─ Linear: step A feeds step B feeds step C
│ └─ SEQUENTIAL PIPELINE
│ Best for: content pipelines, code review chains, data transformation
│
├─ Parallel: N independent tasks, then combine
│ └─ FAN-OUT / FAN-IN
│ Best for: competitive research, multi-source analysis, parallel code gen
│
├─ Tree: orchestrator breaks work into subtasks dynamically
│ └─ HIERARCHICAL DELEGATION
│ Best for: complex projects, open-ended research, code generation with planning
│
├─ Reactive: agents respond to events/triggers
│ └─ EVENT-DRIVEN REACTOR
│ Best for: monitoring, alerting, continuous integration, chat workflows
│
└─ Verification: multiple agents must agree on output
└─ CONSENSUS VALIDATION
Best for: high-stakes decisions, code review, fact checking, safety-critical outputPattern 1: Sequential Pipeline
Each stage transforms input and passes structured output to the next. Type-safe handoffs prevent data loss between stages.
LangGraph Implementation
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langchain_anthropic import ChatAnthropic
class PipelineState(TypedDict):
topic: str
research: str
draft: str
final: str
stage_costs: Annotated[list[dict], "append"] # accumulates cost per stage
def research_stage(state: PipelineState) -> dict:
model = ChatAnthropic(model="claude-sonnet-4-20250514", max_tokens=2048)
result = model.invoke(
f"Research the following topic thoroughly. Provide key facts, statistics, "
f"and expert perspectives:\n\n{state['topic']}"
)
return {
"research": result.content,
"stage_costs": [{"stage": "research", "tokens": result.usage_metadata["total_tokens"]}],
}
def writing_stage(state: PipelineState) -> dict:
model = ChatAnthropic(model="claude-sonnet-4-20250514", max_tokens=4096)
result = model.invoke(
f"Using this research, write a compelling 800-word blog post with a hook, "
f"3 main sections, and a CTA:\n\n{state['research']}"
)
return {
"draft": result.content,
"stage_costs": [{"stage": "writing", "tokens": result.usage_metadata["total_tokens"]}],
}
def editing_stage(state: PipelineState) -> dict:
model = ChatAnthropic(model="claude-haiku-4-20250514", max_tokens=4096)
result = model.invoke(
f"Edit this draft for clarity, flow, and grammar. Return only the improved "
f"version:\n\n{state['draft']}"
)
return {
"final": result.content,
"stage_costs": [{"stage": "editing", "tokens": result.usage_metadata["total_tokens"]}],
}
# Build the graph
graph = StateGraph(PipelineState)
graph.add_node("research", research_stage)
graph.add_node("write", writing_stage)
graph.add_node("edit", editing_stage)
graph.add_edge("research", "write")
graph.add_edge("write", "edit")
graph.add_edge("edit", END)
graph.set_entry_point("research")
pipeline = graph.compile()
# Execute
result = pipeline.invoke({"topic": "The future of AI agents in enterprise software"})
print(f"Total cost: {sum(s['tokens'] for s in result['stage_costs'])} tokens")Pattern 2: Parallel Fan-Out / Fan-In
Independent tasks run concurrently. A merge function combines results.
import asyncio
from dataclasses import dataclass
@dataclass
class FanOutTask:
name: str
system_prompt: str
user_message: str
model: str = "claude-sonnet-4-20250514"
@dataclass
class FanOutResult:
task_name: str
output: str
tokens_used: int
success: bool
error: str | None = None
async def fan_out_fan_in(
tasks: list[FanOutTask],
merge_prompt: str,
max_concurrent: int = 5,
timeout_seconds: float = 60.0,
) -> dict:
"""Execute tasks in parallel with concurrency limit and timeout."""
import anthropic
client = anthropic.AsyncAnthropic()
semaphore = asyncio.Semaphore(max_concurrent)
async def run_one(task: FanOutTask) -> FanOutResult:
async with semaphore:
try:
response = await asyncio.wait_for(
client.messages.create(
model=task.model,
max_tokens=2048,
system=task.system_prompt,
messages=[{"role": "user", "content": task.user_message}],
),
timeout=timeout_seconds,
)
return FanOutResult(
task_name=task.name,
output=response.content[0].text,
tokens_used=response.usage.input_tokens + response.usage.output_tokens,
success=True,
)
except Exception as e:
return FanOutResult(
task_name=task.name, output="", tokens_used=0,
success=False, error=str(e),
)
# FAN-OUT: run all tasks concurrently
results = await asyncio.gather(*[run_one(t) for t in tasks])
successful = [r for r in results if r.success]
failed = [r for r in results if not r.success]
if not successful:
raise RuntimeError(f"All {len(tasks)} fan-out tasks failed: {[f.error for f in failed]}")
# FAN-IN: merge results
combined = "\n\n---\n\n".join(
f"## {r.task_name}\n{r.output}" for r in successful
)
merge_response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system="Synthesize the following parallel analyses into a unified report.",
messages=[{"role": "user", "content": f"{merge_prompt}\n\n{combined}"}],
)
return {
"synthesis": merge_response.content[0].text,
"individual_results": successful,
"failures": failed,
"total_tokens": sum(r.tokens_used for r in results) + merge_response.usage.input_tokens + merge_response.usage.output_tokens,
}Pattern 3: Hierarchical Delegation
An orchestrator agent dynamically decomposes work and delegates to specialists.
from typing import Literal
SPECIALISTS = {
"researcher": "Find accurate information with sources. Be thorough and cite evidence.",
"coder": "Write clean, tested code. Include error handling and type hints.",
"writer": "Create clear, engaging content. Match the requested tone and format.",
"analyst": "Analyze data and produce evidence-backed conclusions with visualizations.",
"reviewer": "Review work product for quality, accuracy, and completeness.",
}
@dataclass
class SubTask:
id: str
agent: Literal["researcher", "coder", "writer", "analyst", "reviewer"]
task: str
depends_on: list[str]
priority: int = 0 # higher = run first when deps are equal
class HierarchicalOrchestrator:
def __init__(self, client):
self.client = client
async def plan(self, request: str) -> list[SubTask]:
"""Orchestrator creates an execution plan with dependencies."""
response = await self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
system=f"""You are a task orchestrator. Break down the request into subtasks.
Available specialists: {', '.join(SPECIALISTS.keys())}
Respond with JSON: {{"subtasks": [{{"id": "1", "agent": "researcher", "task": "...", "depends_on": []}}]}}
Rules:
- Minimize the number of subtasks (prefer fewer, more substantial tasks)
- Only add dependencies when output is genuinely needed
- Independent tasks should have empty depends_on for parallel execution""",
messages=[{"role": "user", "content": request}],
)
import json
plan = json.loads(response.content[0].text)
return [SubTask(**st) for st in plan["subtasks"]]
async def execute(self, request: str) -> str:
"""Plan, execute with dependency resolution, and synthesize."""
subtasks = await self.plan(request)
results = {}
# Execute in dependency order, parallelize where possible
for batch in self._batch_by_dependencies(subtasks):
batch_results = await asyncio.gather(*[
self._run_specialist(st, results) for st in batch
])
for st, result in zip(batch, batch_results):
results[st.id] = result
# Final synthesis
all_outputs = "\n\n".join(f"### {k}\n{v}" for k, v in results.items())
synthesis = await self.client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system="Synthesize specialist outputs into a coherent final response.",
messages=[{"role": "user", "content": f"Request: {request}\n\nOutputs:\n{all_outputs}"}],
)
return synthesis.content[0].text
def _batch_by_dependencies(self, subtasks: list[SubTask]) -> list[list[SubTask]]:
"""Group subtasks into batches that can run in parallel."""
completed = set()
remaining = list(subtasks)
batches = []
while remaining:
batch = [t for t in remaining if all(d in completed for d in t.depends_on)]
if not batch:
raise ValueError("Circular dependency detected in subtask plan")
batches.append(sorted(batch, key=lambda t: -t.priority))
completed.update(t.id for t in batch)
remaining = [t for t in remaining if t.id not in completed]
return batchesPattern 4: Event-Driven Reactor
Agents react to events from a message bus. Decoupled and scalable.
from collections import defaultdict
from typing import Callable, Any
class AgentEventBus:
"""Simple event bus for agent-to-agent communication."""
def __init__(self):
self._handlers: dict[str, list[Callable]] = defaultdict(list)
self._history: list[dict] = []
def subscribe(self, event_type: str, handler: Callable):
self._handlers[event_type].append(handler)
async def publish(self, event_type: str, payload: Any, source: str):
event = {"type": event_type, "payload": payload, "source": source}
self._history.append(event)
handlers = self._handlers.get(event_type, [])
results = await asyncio.gather(
*[h(event) for h in handlers],
return_exceptions=True,
)
errors = [(h, r) for h, r in zip(handlers, results) if isinstance(r, Exception)]
if errors:
for handler, error in errors:
print(f"Handler {handler.__name__} failed: {error}")
return results
# Usage: code review pipeline triggered by PR events
bus = AgentEventBus()
async def on_pr_opened(event):
"""Security agent scans PR for vulnerabilities."""
diff = event["payload"]["diff"]
# ... scan and publish results
await bus.publish("security_scan_complete", {"findings": findings}, "security-agent")
async def on_security_complete(event):
"""Review agent incorporates security findings into review."""
# ... generate review with security context
bus.subscribe("pr_opened", on_pr_opened)
bus.subscribe("security_scan_complete", on_security_complete)Pattern 5: Consensus Validation
Multiple agents independently evaluate the same input. A quorum determines the final output.
@dataclass
class Vote:
agent: str
verdict: str # "approve" | "reject" | "revise"
confidence: float # 0.0 - 1.0
reasoning: str
async def consensus_validate(
content: str,
validators: list[dict], # [{"name": "...", "system": "..."}]
quorum: float = 0.66,
confidence_threshold: float = 0.7,
) -> dict:
"""Run content through multiple validators and determine consensus."""
votes: list[Vote] = []
# Collect independent votes (no agent sees another's vote)
vote_tasks = []
for v in validators:
vote_tasks.append(get_agent_vote(v["name"], v["system"], content))
raw_votes = await asyncio.gather(*vote_tasks)
votes = [v for v in raw_votes if v is not None]
# Calculate consensus
approvals = [v for v in votes if v.verdict == "approve"]
approval_rate = len(approvals) / len(votes) if votes else 0
avg_confidence = sum(v.confidence for v in votes) / len(votes) if votes else 0
if approval_rate >= quorum and avg_confidence >= confidence_threshold:
return {"decision": "approved", "approval_rate": approval_rate, "votes": votes}
elif any(v.verdict == "reject" for v in votes):
rejections = [v for v in votes if v.verdict == "reject"]
return {"decision": "rejected", "reasons": [r.reasoning for r in rejections], "votes": votes}
else:
return {"decision": "needs_revision", "feedback": [v.reasoning for v in votes], "votes": votes}Agent Routing Strategies
Intent-Based Router
class IntentRouter:
"""Route requests to specialized agents based on intent classification."""
ROUTING_TABLE = {
"code_generation": {"agent": "coder", "model": "claude-sonnet-4-20250514"},
"code_review": {"agent": "reviewer", "model": "claude-sonnet-4-20250514"},
"research": {"agent": "researcher", "model": "claude-sonnet-4-20250514"},
"simple_question": {"agent": "assistant", "model": "claude-haiku-4-20250514"},
"creative_writing": {"agent": "writer", "model": "claude-sonnet-4-20250514"},
"complex_analysis": {"agent": "analyst", "model": "claude-sonnet-4-20250514"},
}
async def route(self, message: str) -> dict:
# Use a fast, cheap model for classification
classification = await self.client.messages.create(
model="claude-haiku-4-20250514",
max_tokens=50,
system="Classify the user intent. Respond with ONLY one of: code_generation, code_review, research, simple_question, creative_writing, complex_analysis",
messages=[{"role": "user", "content": message}],
)
intent = classification.content[0].text.strip().lower()
return self.ROUTING_TABLE.get(intent, self.ROUTING_TABLE["simple_question"])Context Window Budgeting
MODEL_LIMITS = {
"claude-sonnet-4-20250514": 200_000,
"claude-haiku-4-20250514": 200_000,
"claude-opus-4-20250514": 200_000,
"gpt-4o": 128_000,
}
class ContextBudget:
def __init__(self, model: str, pipeline_stages: int, reserve_pct: float = 0.15):
self.total = MODEL_LIMITS.get(model, 128_000)
self.reserve = int(self.total * reserve_pct)
self.per_stage = (self.total - self.reserve) // pipeline_stages
self.used = 0
def allocate(self, stage: str) -> int:
available = self.total - self.reserve - self.used
allocation = min(self.per_stage, int(available * 0.6))
return max(allocation, 1000) # minimum 1000 tokens per stage
def consume(self, tokens: int):
self.used += tokens
def summarize_if_needed(self, text: str, budget: int) -> str:
estimated_tokens = len(text) // 4
if estimated_tokens <= budget:
return text
# Truncate to budget with marker
char_limit = budget * 4
return text[:char_limit] + "\n\n[Content truncated to fit context budget]"Cost Optimization Matrix
| Strategy | Cost Reduction | Quality Impact | When to Use |
|---|---|---|---|
| Haiku for routing/classification | 85-90% | Minimal | Always for intent routing |
| Haiku for editing/formatting | 60-70% | Low | Mechanical tasks |
| Sonnet for most stages | Baseline | Baseline | Default choice |
| Opus only for final synthesis | +50% on that stage | Higher quality | High-stakes output |
| Prompt caching (system prompts) | 50-90% per call | None | Repeated system prompts |
| Truncate intermediate outputs | 20-40% | May lose detail | Long pipelines |
| Parallel + early termination | 30-50% | None if threshold met | Search/validation tasks |
| Batch similar requests | Up to 50% | Increased latency | Non-real-time workloads |
Reliability Patterns
Circuit Breaker
import time
class CircuitBreaker:
"""Prevent cascading failures when an agent/model is down."""
def __init__(self, failure_threshold: int = 5, recovery_time: float = 60.0):
self.failure_threshold = failure_threshold
self.recovery_time = recovery_time
self.failures = 0
self.state = "closed" # closed = healthy, open = failing, half-open = testing
self.last_failure_time = 0.0
def can_execute(self) -> bool:
if self.state == "closed":
return True
if self.state == "open":
if time.time() - self.last_failure_time > self.recovery_time:
self.state = "half-open"
return True
return False
return True # half-open: allow one test request
def record_success(self):
self.failures = 0
self.state = "closed"
def record_failure(self):
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = "open"Common Pitfalls
- Over-orchestration — if a single prompt can handle it, adding agents adds cost and latency, not value
- Circular dependencies in subtask graphs causing infinite loops; always validate DAG structure before execution
- Context bleed — passing entire previous outputs to every stage; summarize or extract only what is needed
- No timeout enforcement — a stuck agent blocks the entire pipeline; set wall-clock timeouts at every boundary
- Silent failures — agent returns plausible but incorrect output; add validation stages for critical paths
- Ignoring cost — 10 parallel Opus calls is expensive; model selection is a cost decision, not just a quality one
- Stateless retries on stateful operations — ensure idempotency before enabling automatic retries
- Single point of failure in orchestrator — if the orchestrator agent fails, the entire workflow fails
Best Practices
1. Start with a single prompt — only add agents when you prove one cannot handle the task 2. Type your handoffs — use dataclasses or TypedDicts for inter-agent data, not raw strings 3. Budget context upfront — calculate token allocations before running the pipeline 4. Use cheap models for routing — Haiku for classification costs 10x less than Sonnet 5. Validate DAG structure at build time, not runtime 6. Log every agent call with input hash, output hash, tokens, latency, and cost 7. Set SLAs per stage — if research takes >30s, timeout and use cached results 8. Test with production-scale inputs — a pipeline that works on 100 words may fail on 10,000
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Pipeline hangs indefinitely | Missing timeout enforcement on one or more agent stages | Add asyncio.wait_for() with explicit timeout_seconds at every agent boundary; use the Circuit Breaker pattern to fail fast |
| Circular dependency error at runtime | Subtask graph contains a cycle (e.g., task A depends on B which depends on A) | Validate DAG structure at build time with topological sort; the _batch_by_dependencies method catches this but validation should happen earlier |
| Context window exceeded mid-pipeline | Intermediate outputs grow beyond the model's token limit | Use the ContextBudget class to allocate tokens per stage; summarize or truncate outputs before passing to the next stage |
| Fan-out tasks return inconsistent formats | Each parallel agent interprets the output schema differently | Define a shared TypedDict or dataclass for all fan-out results; add a validation step before the merge function |
| Orchestrator plan creates too many subtasks | The planning prompt does not constrain subtask count, leading to over-decomposition | Add explicit constraints in the planner system prompt (e.g., "maximum 5 subtasks"); review and approve plans before execution in high-stakes workflows |
| Consensus never reaches quorum | Validators disagree consistently or confidence scores are too low | Lower the quorum threshold, add a tiebreaker agent, or revise validator prompts to align on evaluation criteria |
| Cost spikes on parallel workflows | Expensive models (Opus) used for all fan-out branches instead of routing by complexity | Apply cost-aware routing: use Haiku for classification and simple tasks, Sonnet for most work, Opus only for final synthesis or high-stakes decisions |
Success Criteria
- Pipeline end-to-end latency stays within the defined SLA (e.g., under 60 seconds for a 5-stage workflow) with no stage exceeding its individual timeout
- Agent routing accuracy exceeds 90% when measured against a labeled test set of at least 100 representative requests
- Fan-out/fan-in workflows complete with fewer than 5% task failures across all parallel branches under normal operating conditions
- Total token cost per workflow run decreases by at least 40% after applying model tiering (Haiku for routing, Sonnet for core work, Opus for synthesis)
- Circuit breakers trigger correctly within 5 consecutive failures and recover automatically after the defined recovery window
- Context window utilization stays below 85% of model limits at every pipeline stage, with no truncation-related quality degradation
- All inter-agent handoffs pass schema validation with zero type errors across 100 consecutive workflow executions
Scope & Limitations
This skill covers:
- Design and implementation of five core multi-agent orchestration patterns (sequential, parallel, hierarchical, event-driven, consensus)
- Agent routing strategies including intent-based, skill-based, and cost-aware routing
- Reliability engineering patterns: circuit breakers, retries, timeouts, and dead letter queues
- Context window budgeting, cost optimization, and framework-specific implementations (LangGraph, CrewAI, AutoGen)
This skill does NOT cover:
- Training or fine-tuning the underlying LLMs used by agents (see
engineering/ml-pipeline-architectfor ML training workflows) - Infrastructure provisioning, container orchestration, or deployment pipelines (see
engineering/cloud-infrastructure-designerfor cloud architecture) - Human-in-the-loop approval workflows or UI design for agent dashboards (see
product-team/ux-researcherfor user-facing workflow design) - Long-term agent memory, vector database setup, or RAG pipeline construction (see
engineering/rag-pipeline-architectfor retrieval-augmented generation)
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
engineering/ml-pipeline-architect | Agent workflows that include ML inference stages use ML Pipeline Architect for model serving and batch prediction design | Workflow DAG exports stage specs to ML pipeline; ML pipeline returns inference endpoints for agent consumption |
engineering/rag-pipeline-architect | Research and retrieval agents within workflows rely on RAG pipelines for grounded knowledge access | Agent sends queries to RAG pipeline; RAG returns ranked document chunks with citations for agent context |
engineering/cloud-infrastructure-designer | Production deployment of agent workflows requires infrastructure design for scaling, queuing, and monitoring | Workflow resource requirements feed into infrastructure specs; infra returns endpoint URLs, queue ARNs, and scaling policies |
engineering/api-design-architect | Inter-agent communication contracts and external API boundaries follow API design standards | Agent handoff schemas are validated against API design specs; API architect provides OpenAPI definitions for external integrations |
engineering/system-design-architect | Overall system architecture decisions (sync vs async, monolith vs distributed) shape workflow topology choices | System design constraints (latency budgets, availability targets) inform pattern selection; workflow requirements feed back into system capacity planning |
project-management/technical-project-planning | Complex multi-agent projects require structured planning for phased rollout, risk management, and milestone tracking | Workflow complexity estimates feed into project plans; PM skill provides sprint boundaries and dependency timelines for staged deployment |
#!/usr/bin/env python3
"""Estimate token usage and dollar cost for agent workflow execution.
Takes a workflow definition with step-level token and model annotations, then
calculates per-step and total costs using published pricing. Supports model
tiering analysis (what-if you swapped Sonnet for Haiku on routing steps?).
Workflow JSON format:
{
"name": "content-pipeline",
"steps": [
{
"id": "research",
"agent": "researcher",
"depends_on": [],
"model": "claude-sonnet-4-20250514",
"estimated_input_tokens": 2000,
"estimated_output_tokens": 4000
},
{
"id": "write",
"agent": "writer",
"depends_on": ["research"],
"model": "claude-sonnet-4-20250514",
"estimated_input_tokens": 5000,
"estimated_output_tokens": 8000
}
]
}
Optional fields per step:
- "parallel_branches": int (for fan-out steps, multiplies cost)
- "retry_probability": float 0-1 (expected retry rate, adds proportional cost)
- "cached_input_tokens": int (tokens served from prompt cache at reduced rate)
Usage:
python cost_estimator.py workflow.json
python cost_estimator.py workflow.json --json
python cost_estimator.py workflow.json --runs 1000
python cost_estimator.py workflow.json --override-model claude-haiku-4-20250514
"""
import argparse
import json
import sys
from typing import Any
# Pricing per 1M tokens (USD) as of early 2026
# Source: anthropic.com/pricing, openai.com/pricing
MODEL_PRICING: dict[str, dict[str, float]] = {
# Anthropic models
"claude-opus-4-20250514": {"input": 15.00, "output": 75.00, "cached_input": 1.50},
"claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00, "cached_input": 0.30},
"claude-haiku-4-20250514": {"input": 0.80, "output": 4.00, "cached_input": 0.08},
# Aliases
"claude-opus": {"input": 15.00, "output": 75.00, "cached_input": 1.50},
"claude-sonnet": {"input": 3.00, "output": 15.00, "cached_input": 0.30},
"claude-haiku": {"input": 0.80, "output": 4.00, "cached_input": 0.08},
# OpenAI models (approximate)
"gpt-4o": {"input": 2.50, "output": 10.00, "cached_input": 1.25},
"gpt-4o-mini": {"input": 0.15, "output": 0.60, "cached_input": 0.075},
"gpt-4-turbo": {"input": 10.00, "output": 30.00, "cached_input": 5.00},
# Default fallback
"default": {"input": 3.00, "output": 15.00, "cached_input": 0.30},
}
DEFAULT_MODEL = "claude-sonnet-4-20250514"
DEFAULT_INPUT_TOKENS = 1000
DEFAULT_OUTPUT_TOKENS = 500
def get_pricing(model: str) -> dict[str, float]:
"""Look up pricing for a model, falling back to default."""
return MODEL_PRICING.get(model, MODEL_PRICING["default"])
def cost_for_tokens(token_count: int, rate_per_million: float) -> float:
"""Calculate dollar cost for a given token count and rate per 1M tokens."""
return (token_count / 1_000_000) * rate_per_million
def estimate_step(step: dict[str, Any], override_model: str | None = None) -> dict[str, Any]:
"""Estimate cost for a single workflow step."""
step_id = step.get("id", "<unknown>")
model = override_model or step.get("model", DEFAULT_MODEL)
pricing = get_pricing(model)
input_tokens = step.get("estimated_input_tokens", DEFAULT_INPUT_TOKENS)
output_tokens = step.get("estimated_output_tokens", DEFAULT_OUTPUT_TOKENS)
cached_tokens = step.get("cached_input_tokens", 0)
parallel = step.get("parallel_branches", 1)
retry_prob = step.get("retry_probability", 0.0)
# Separate cached from non-cached input
non_cached_input = max(0, input_tokens - cached_tokens)
# Base cost for one execution
base_input_cost = cost_for_tokens(non_cached_input, pricing["input"])
base_cached_cost = cost_for_tokens(cached_tokens, pricing["cached_input"])
base_output_cost = cost_for_tokens(output_tokens, pricing["output"])
base_cost = base_input_cost + base_cached_cost + base_output_cost
# Multiply by parallel branches
branch_cost = base_cost * parallel
# Add expected retry cost
retry_cost = branch_cost * retry_prob
total_cost = branch_cost + retry_cost
total_tokens = (input_tokens + output_tokens) * parallel
total_tokens_with_retry = total_tokens + int(total_tokens * retry_prob)
return {
"step_id": step_id,
"model": model,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cached_input_tokens": cached_tokens,
"parallel_branches": parallel,
"retry_probability": retry_prob,
"base_cost_usd": round(base_cost, 6),
"total_cost_usd": round(total_cost, 6),
"total_tokens": total_tokens_with_retry,
}
def estimate_workflow(
workflow: dict[str, Any],
num_runs: int = 1,
override_model: str | None = None,
) -> dict[str, Any]:
"""Estimate cost for the entire workflow."""
name = workflow.get("name", "<unnamed>")
steps = workflow.get("steps", [])
step_estimates = []
for step in steps:
est = estimate_step(step, override_model)
step_estimates.append(est)
per_run_cost = sum(e["total_cost_usd"] for e in step_estimates)
per_run_tokens = sum(e["total_tokens"] for e in step_estimates)
# Model breakdown
model_costs: dict[str, float] = {}
model_tokens: dict[str, int] = {}
for e in step_estimates:
m = e["model"]
model_costs[m] = model_costs.get(m, 0.0) + e["total_cost_usd"]
model_tokens[m] = model_tokens.get(m, 0) + e["total_tokens"]
model_breakdown = [
{"model": m, "cost_usd": round(model_costs[m], 6), "tokens": model_tokens[m]}
for m in sorted(model_costs.keys())
]
# Optimization suggestions
suggestions = _generate_suggestions(step_estimates)
return {
"workflow": name,
"step_count": len(steps),
"per_run": {
"cost_usd": round(per_run_cost, 6),
"total_tokens": per_run_tokens,
},
"projected": {
"runs": num_runs,
"total_cost_usd": round(per_run_cost * num_runs, 4),
"total_tokens": per_run_tokens * num_runs,
},
"steps": step_estimates,
"model_breakdown": model_breakdown,
"optimization_suggestions": suggestions,
}
def _generate_suggestions(step_estimates: list[dict]) -> list[str]:
"""Generate cost optimization suggestions based on step analysis."""
suggestions: list[str] = []
# Find expensive steps using Opus
opus_steps = [e for e in step_estimates if "opus" in e["model"].lower()]
if opus_steps:
names = ", ".join(e["step_id"] for e in opus_steps)
suggestions.append(
f"Steps using Opus ({names}): consider Sonnet for 5x cost reduction "
f"unless output quality requires Opus."
)
# Find routing/classification steps not using Haiku
for e in step_estimates:
sid = e["step_id"].lower()
if any(k in sid for k in ("route", "classify", "dispatch", "triage", "intent")):
if "haiku" not in e["model"].lower():
suggestions.append(
f"Step '{e['step_id']}' appears to be routing/classification — "
f"consider Haiku for ~85% cost reduction."
)
# Find steps with no caching on large inputs
for e in step_estimates:
if e["input_tokens"] > 3000 and e["cached_input_tokens"] == 0:
suggestions.append(
f"Step '{e['step_id']}' uses {e['input_tokens']} input tokens with no caching — "
f"prompt caching could reduce input cost by ~90%."
)
# High parallel branch counts
for e in step_estimates:
if e["parallel_branches"] > 5:
suggestions.append(
f"Step '{e['step_id']}' fans out to {e['parallel_branches']} branches — "
f"consider early termination or reducing branch count."
)
if not suggestions:
suggestions.append("No obvious optimizations detected. Workflow appears cost-efficient.")
return suggestions
def format_human(result: dict[str, Any]) -> str:
"""Format estimation results for human reading."""
lines: list[str] = []
lines.append(f"Workflow: {result['workflow']}")
lines.append(f"Steps: {result['step_count']}")
lines.append("")
# Per-run summary
pr = result["per_run"]
lines.append(f"Per-Run Cost: ${pr['cost_usd']:.4f}")
lines.append(f"Per-Run Tokens: {pr['total_tokens']:,}")
proj = result["projected"]
if proj["runs"] > 1:
lines.append("")
lines.append(f"Projected ({proj['runs']:,} runs):")
lines.append(f" Total Cost: ${proj['total_cost_usd']:,.2f}")
lines.append(f" Total Tokens: {proj['total_tokens']:,}")
# Step breakdown
lines.append("")
lines.append("Step Breakdown:")
lines.append(f" {'Step':<20} {'Model':<30} {'Tokens':>10} {'Cost':>10}")
lines.append(f" {'-'*20} {'-'*30} {'-'*10} {'-'*10}")
for s in result["steps"]:
model_short = s["model"].replace("claude-", "").replace("-20250514", "")
branch_note = f" x{s['parallel_branches']}" if s["parallel_branches"] > 1 else ""
lines.append(
f" {s['step_id']:<20} {model_short + branch_note:<30} "
f"{s['total_tokens']:>10,} ${s['total_cost_usd']:>9.4f}"
)
# Model breakdown
lines.append("")
lines.append("By Model:")
for mb in result["model_breakdown"]:
model_short = mb["model"].replace("claude-", "").replace("-20250514", "")
lines.append(f" {model_short:<30} {mb['tokens']:>10,} tokens ${mb['cost_usd']:.4f}")
# Suggestions
if result["optimization_suggestions"]:
lines.append("")
lines.append("Optimization Suggestions:")
for s in result["optimization_suggestions"]:
lines.append(f" - {s}")
return "\n".join(lines)
def load_workflow(path: str | None, use_stdin: bool) -> dict[str, Any]:
"""Load workflow definition from file or stdin."""
if use_stdin:
raw = sys.stdin.read()
elif path:
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
else:
raise ValueError("Provide a file path or use --stdin")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
if not isinstance(data, dict) or "steps" not in data:
raise ValueError("Workflow must be a JSON object with a 'steps' array")
return data
def main() -> int:
parser = argparse.ArgumentParser(
description="Estimate token usage and cost for agent workflow execution",
epilog="Pricing based on published rates as of early 2026.",
)
parser.add_argument("file", nargs="?", help="Path to workflow JSON file")
parser.add_argument("--stdin", action="store_true", help="Read workflow from stdin")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--runs", type=int, default=1,
help="Number of projected runs for total cost (default: 1)")
parser.add_argument("--override-model", type=str, default=None,
help="Override model for all steps (what-if analysis)")
args = parser.parse_args()
if not args.file and not args.stdin:
parser.error("Provide a workflow file path or use --stdin")
if args.runs < 1:
parser.error("--runs must be >= 1")
try:
workflow = load_workflow(args.file, args.stdin)
except (ValueError, FileNotFoundError, PermissionError) as e:
if args.json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
print(f"Error: {e}", file=sys.stderr)
return 2
result = estimate_workflow(workflow, num_runs=args.runs, override_model=args.override_model)
if args.json_output:
print(json.dumps(result, indent=2))
else:
print(format_human(result))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Validate agent workflow DAGs for cycles, dead ends, unreachable nodes, and structural issues.
Accepts a JSON workflow definition and checks for:
- Cycles (circular dependencies between steps)
- Unreachable nodes (steps not reachable from any entry point)
- Dead ends (steps with no outgoing edges that aren't marked as terminal)
- Missing dependency references (edges pointing to non-existent steps)
- Duplicate step IDs
- Empty workflows
Workflow JSON format:
{
"name": "my-workflow",
"steps": [
{"id": "research", "agent": "researcher", "depends_on": []},
{"id": "write", "agent": "writer", "depends_on": ["research"]},
{"id": "review", "agent": "reviewer", "depends_on": ["write"], "terminal": true}
]
}
Usage:
python workflow_validator.py workflow.json
python workflow_validator.py workflow.json --json
python workflow_validator.py --stdin < workflow.json
echo '{"steps":[...]}' | python workflow_validator.py --stdin --json
"""
import argparse
import json
import sys
from collections import deque
from typing import Any
def load_workflow(path: str | None, use_stdin: bool) -> dict[str, Any]:
"""Load workflow definition from file or stdin."""
if use_stdin:
raw = sys.stdin.read()
elif path:
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
else:
raise ValueError("Provide a file path or use --stdin")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
if not isinstance(data, dict):
raise ValueError("Workflow must be a JSON object")
if "steps" not in data:
raise ValueError("Workflow must contain a 'steps' array")
if not isinstance(data["steps"], list):
raise ValueError("'steps' must be a JSON array")
return data
def validate_workflow(workflow: dict[str, Any]) -> dict[str, Any]:
"""Run all validation checks on the workflow. Returns a results dict."""
steps = workflow.get("steps", [])
name = workflow.get("name", "<unnamed>")
errors: list[dict[str, str]] = []
warnings: list[dict[str, str]] = []
# --- Check: empty workflow ---
if not steps:
errors.append({"check": "empty_workflow", "message": "Workflow has no steps"})
return _build_result(name, steps, errors, warnings)
# --- Check: duplicate IDs ---
seen_ids: dict[str, int] = {}
for i, step in enumerate(steps):
step_id = step.get("id")
if step_id is None:
errors.append({
"check": "missing_id",
"message": f"Step at index {i} has no 'id' field",
})
continue
if step_id in seen_ids:
errors.append({
"check": "duplicate_id",
"message": f"Duplicate step ID '{step_id}' (first at index {seen_ids[step_id]}, again at {i})",
})
seen_ids[step_id] = i
all_ids = set(seen_ids.keys())
# --- Build adjacency structures ---
forward: dict[str, list[str]] = {sid: [] for sid in all_ids}
reverse: dict[str, list[str]] = {sid: [] for sid in all_ids}
terminal_ids: set[str] = set()
entry_ids: set[str] = set()
for step in steps:
step_id = step.get("id")
if step_id is None:
continue
deps = step.get("depends_on", [])
if not isinstance(deps, list):
errors.append({
"check": "invalid_depends_on",
"message": f"Step '{step_id}' has non-list 'depends_on': {deps}",
})
deps = []
if step.get("terminal", False):
terminal_ids.add(step_id)
if not deps:
entry_ids.add(step_id)
for dep in deps:
if dep not in all_ids:
errors.append({
"check": "missing_dependency",
"message": f"Step '{step_id}' depends on '{dep}' which does not exist",
})
else:
forward[dep].append(step_id)
reverse[step_id].append(dep)
# --- Check: cycle detection (Kahn's algorithm) ---
in_degree = {sid: len(reverse.get(sid, [])) for sid in all_ids}
queue = deque(sid for sid, deg in in_degree.items() if deg == 0)
topo_order: list[str] = []
while queue:
node = queue.popleft()
topo_order.append(node)
for neighbor in forward.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
if len(topo_order) != len(all_ids):
cycle_nodes = sorted(all_ids - set(topo_order))
errors.append({
"check": "cycle_detected",
"message": f"Circular dependency among steps: {cycle_nodes}",
"nodes": cycle_nodes,
})
# --- Check: unreachable nodes ---
reachable: set[str] = set()
bfs_queue = deque(entry_ids)
while bfs_queue:
node = bfs_queue.popleft()
if node in reachable:
continue
reachable.add(node)
for neighbor in forward.get(node, []):
if neighbor not in reachable:
bfs_queue.append(neighbor)
unreachable = sorted(all_ids - reachable)
if unreachable:
errors.append({
"check": "unreachable_nodes",
"message": f"Steps not reachable from any entry point: {unreachable}",
"nodes": unreachable,
})
# --- Check: dead ends ---
for sid in all_ids:
if not forward.get(sid) and sid not in terminal_ids:
warnings.append({
"check": "dead_end",
"message": f"Step '{sid}' has no outgoing edges and is not marked terminal",
})
# --- Check: no entry points ---
if not entry_ids:
errors.append({
"check": "no_entry_point",
"message": "No entry points found (all steps have dependencies)",
})
# --- Check: missing agent field ---
for step in steps:
step_id = step.get("id", "<unknown>")
if "agent" not in step:
warnings.append({
"check": "missing_agent",
"message": f"Step '{step_id}' has no 'agent' field specified",
})
return _build_result(name, steps, errors, warnings,
topo_order=topo_order,
entry_points=sorted(entry_ids),
terminal_nodes=sorted(terminal_ids))
def _build_result(
name: str,
steps: list,
errors: list[dict],
warnings: list[dict],
topo_order: list[str] | None = None,
entry_points: list[str] | None = None,
terminal_nodes: list[str] | None = None,
) -> dict[str, Any]:
valid = len(errors) == 0
result: dict[str, Any] = {
"workflow": name,
"valid": valid,
"step_count": len(steps),
"errors": errors,
"warnings": warnings,
}
if topo_order is not None:
result["topological_order"] = topo_order
if entry_points is not None:
result["entry_points"] = entry_points
if terminal_nodes is not None:
result["terminal_nodes"] = terminal_nodes
return result
def format_human(result: dict[str, Any]) -> str:
"""Format validation results for human reading."""
lines: list[str] = []
status = "VALID" if result["valid"] else "INVALID"
lines.append(f"Workflow: {result['workflow']}")
lines.append(f"Status: {status}")
lines.append(f"Steps: {result['step_count']}")
if result.get("entry_points"):
lines.append(f"Entry: {', '.join(result['entry_points'])}")
if result.get("terminal_nodes"):
lines.append(f"Terminal: {', '.join(result['terminal_nodes'])}")
if result.get("topological_order"):
lines.append(f"Order: {' -> '.join(result['topological_order'])}")
if result["errors"]:
lines.append("")
lines.append(f"ERRORS ({len(result['errors'])}):")
for err in result["errors"]:
lines.append(f" [{err['check']}] {err['message']}")
if result["warnings"]:
lines.append("")
lines.append(f"WARNINGS ({len(result['warnings'])}):")
for warn in result["warnings"]:
lines.append(f" [{warn['check']}] {warn['message']}")
if result["valid"] and not result["warnings"]:
lines.append("")
lines.append("No issues found. Workflow DAG is well-formed.")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate agent workflow DAGs for structural issues",
epilog="Exit codes: 0 = valid, 1 = invalid, 2 = input error",
)
parser.add_argument("file", nargs="?", help="Path to workflow JSON file")
parser.add_argument("--stdin", action="store_true", help="Read workflow from stdin")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
args = parser.parse_args()
if not args.file and not args.stdin:
parser.error("Provide a workflow file path or use --stdin")
try:
workflow = load_workflow(args.file, args.stdin)
except (ValueError, FileNotFoundError, PermissionError) as e:
if args.json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
print(f"Error: {e}", file=sys.stderr)
return 2
result = validate_workflow(workflow)
if args.json_output:
print(json.dumps(result, indent=2))
else:
print(format_human(result))
return 0 if result["valid"] else 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Generate Mermaid diagrams from agent workflow DAG definitions.
Reads a workflow JSON and produces a Mermaid graph that visualizes steps, edges,
agent assignments, patterns (fan-out/fan-in, sequential, etc.), and optional
model/cost annotations.
Workflow JSON format:
{
"name": "content-pipeline",
"steps": [
{"id": "research", "agent": "researcher", "depends_on": [], "model": "claude-sonnet-4-20250514"},
{"id": "write", "agent": "writer", "depends_on": ["research"]},
{"id": "review", "agent": "reviewer", "depends_on": ["write"], "terminal": true}
]
}
Optional step fields:
- "model": str - annotates the node with model name
- "terminal": bool - marks step as a terminal/end node
- "parallel_branches": int - shows fan-out multiplier on node
- "description": str - short label shown inside the node
Output formats:
- Mermaid (default): paste into any Mermaid-compatible renderer
- Also supports --direction (TD, LR, BT, RL) for graph orientation
Usage:
python workflow_visualizer.py workflow.json
python workflow_visualizer.py workflow.json --json
python workflow_visualizer.py workflow.json --direction LR
python workflow_visualizer.py workflow.json --annotate-models
python workflow_visualizer.py workflow.json --annotate-cost
python workflow_visualizer.py --stdin < workflow.json
"""
import argparse
import json
import sys
from typing import Any
# Agent-to-style mapping for visual differentiation
AGENT_STYLES: dict[str, str] = {
"researcher": "fill:#e1f5fe,stroke:#0288d1",
"writer": "fill:#f3e5f5,stroke:#7b1fa2",
"editor": "fill:#fce4ec,stroke:#c62828",
"reviewer": "fill:#fff3e0,stroke:#ef6c00",
"coder": "fill:#e8f5e9,stroke:#2e7d32",
"analyst": "fill:#fff9c4,stroke:#f9a825",
"orchestrator":"fill:#f5f5f5,stroke:#616161,stroke-width:2px",
"router": "fill:#e0e0e0,stroke:#424242,stroke-dasharray:5 5",
"validator": "fill:#ffebee,stroke:#b71c1c",
}
MODEL_SHORT_NAMES: dict[str, str] = {
"claude-opus-4-20250514": "Opus",
"claude-sonnet-4-20250514": "Sonnet",
"claude-haiku-4-20250514": "Haiku",
"claude-opus": "Opus",
"claude-sonnet": "Sonnet",
"claude-haiku": "Haiku",
"gpt-4o": "GPT-4o",
"gpt-4o-mini": "GPT-4o-mini",
"gpt-4-turbo": "GPT-4T",
}
def load_workflow(path: str | None, use_stdin: bool) -> dict[str, Any]:
"""Load workflow definition from file or stdin."""
if use_stdin:
raw = sys.stdin.read()
elif path:
with open(path, "r", encoding="utf-8") as f:
raw = f.read()
else:
raise ValueError("Provide a file path or use --stdin")
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
if not isinstance(data, dict) or "steps" not in data:
raise ValueError("Workflow must be a JSON object with a 'steps' array")
return data
def sanitize_id(step_id: str) -> str:
"""Make a step ID safe for Mermaid node identifiers."""
return step_id.replace("-", "_").replace(" ", "_").replace(".", "_")
def build_node_label(step: dict[str, Any], annotate_models: bool, annotate_cost: bool) -> str:
"""Build the display label for a workflow node."""
step_id = step.get("id", "?")
agent = step.get("agent", "")
desc = step.get("description", "")
model = step.get("model", "")
parallel = step.get("parallel_branches", 1)
parts: list[str] = []
# Primary label
if desc:
parts.append(f"<b>{step_id}</b><br/>{desc}")
else:
parts.append(f"<b>{step_id}</b>")
# Agent
if agent:
parts.append(f"<i>{agent}</i>")
# Model annotation
if annotate_models and model:
short = MODEL_SHORT_NAMES.get(model, model.split("/")[-1])
parts.append(f"[{short}]")
# Parallel branches
if parallel > 1:
parts.append(f"x{parallel} branches")
# Cost annotation
if annotate_cost:
input_t = step.get("estimated_input_tokens", 0)
output_t = step.get("estimated_output_tokens", 0)
if input_t or output_t:
parts.append(f"{input_t + output_t:,} tok")
return "<br/>".join(parts)
def detect_patterns(steps: list[dict[str, Any]]) -> list[dict[str, str]]:
"""Detect orchestration patterns in the workflow."""
patterns: list[dict[str, str]] = []
all_ids = {s["id"] for s in steps}
# Build dependency map
dependents: dict[str, list[str]] = {s["id"]: [] for s in steps}
for s in steps:
for dep in s.get("depends_on", []):
if dep in dependents:
dependents[dep].append(s["id"])
# Fan-out: one node with multiple dependents
for sid, deps in dependents.items():
if len(deps) >= 3:
patterns.append({
"pattern": "fan-out",
"source": sid,
"targets": ", ".join(deps),
})
# Fan-in: one node with multiple dependencies
for s in steps:
deps = s.get("depends_on", [])
if len(deps) >= 3:
patterns.append({
"pattern": "fan-in",
"target": s["id"],
"sources": ", ".join(deps),
})
# Sequential chain detection
chain: list[str] = []
for s in steps:
deps = s.get("depends_on", [])
if not deps:
# Potential chain start
current = s["id"]
seq = [current]
while True:
next_nodes = dependents.get(current, [])
if len(next_nodes) == 1:
next_node = next_nodes[0]
# Check the next node only depends on current
next_step = next((st for st in steps if st["id"] == next_node), None)
if next_step and len(next_step.get("depends_on", [])) == 1:
seq.append(next_node)
current = next_node
else:
break
else:
break
if len(seq) >= 3:
chain = seq
patterns.append({
"pattern": "sequential-pipeline",
"chain": " -> ".join(seq),
})
return patterns
def generate_mermaid(
workflow: dict[str, Any],
direction: str = "TD",
annotate_models: bool = False,
annotate_cost: bool = False,
) -> str:
"""Generate a Mermaid diagram string from the workflow."""
steps = workflow.get("steps", [])
name = workflow.get("name", "workflow")
lines: list[str] = []
lines.append(f"---")
lines.append(f"title: {name}")
lines.append(f"---")
lines.append(f"graph {direction}")
# Nodes
entry_ids: set[str] = set()
terminal_ids: set[str] = set()
agents_used: set[str] = set()
for step in steps:
sid = sanitize_id(step["id"])
label = build_node_label(step, annotate_models, annotate_cost)
deps = step.get("depends_on", [])
agent = step.get("agent", "")
is_terminal = step.get("terminal", False)
if not deps:
entry_ids.add(sid)
if is_terminal:
terminal_ids.add(sid)
if agent:
agents_used.add(agent)
# Node shape: entry = stadium, terminal = double circle, default = rounded rect
if not deps:
lines.append(f" {sid}([{label}])")
elif is_terminal:
lines.append(f" {sid}(({label}))")
else:
lines.append(f" {sid}[{label}]")
lines.append("")
# Edges
for step in steps:
sid = sanitize_id(step["id"])
for dep in step.get("depends_on", []):
dep_safe = sanitize_id(dep)
lines.append(f" {dep_safe} --> {sid}")
lines.append("")
# Style classes based on agent
styled_agents: set[str] = set()
for step in steps:
agent = step.get("agent", "")
sid = sanitize_id(step["id"])
if agent in AGENT_STYLES:
class_name = f"cls_{agent}"
if agent not in styled_agents:
style = AGENT_STYLES[agent]
lines.append(f" classDef {class_name} {style}")
styled_agents.add(agent)
lines.append(f" class {sid} {class_name}")
return "\n".join(lines)
def build_result(
workflow: dict[str, Any],
mermaid: str,
direction: str,
annotate_models: bool,
annotate_cost: bool,
) -> dict[str, Any]:
"""Build the full result dict."""
steps = workflow.get("steps", [])
patterns = detect_patterns(steps)
entry_points = [s["id"] for s in steps if not s.get("depends_on")]
terminal_nodes = [s["id"] for s in steps if s.get("terminal", False)]
agents = sorted({s.get("agent", "") for s in steps if s.get("agent")})
edge_count = sum(len(s.get("depends_on", [])) for s in steps)
return {
"workflow": workflow.get("name", "<unnamed>"),
"step_count": len(steps),
"edge_count": edge_count,
"entry_points": entry_points,
"terminal_nodes": terminal_nodes,
"agents": agents,
"patterns_detected": patterns,
"options": {
"direction": direction,
"annotate_models": annotate_models,
"annotate_cost": annotate_cost,
},
"mermaid": mermaid,
}
def format_human(result: dict[str, Any]) -> str:
"""Format results for human reading."""
lines: list[str] = []
lines.append(f"Workflow: {result['workflow']}")
lines.append(f"Steps: {result['step_count']}, Edges: {result['edge_count']}")
lines.append(f"Agents: {', '.join(result['agents']) if result['agents'] else 'none specified'}")
if result["entry_points"]:
lines.append(f"Entry: {', '.join(result['entry_points'])}")
if result["terminal_nodes"]:
lines.append(f"Terminal: {', '.join(result['terminal_nodes'])}")
if result["patterns_detected"]:
lines.append("")
lines.append("Detected Patterns:")
for p in result["patterns_detected"]:
pattern = p.get("pattern", "unknown")
if pattern == "fan-out":
lines.append(f" Fan-out: {p['source']} -> [{p['targets']}]")
elif pattern == "fan-in":
lines.append(f" Fan-in: [{p['sources']}] -> {p['target']}")
elif pattern == "sequential-pipeline":
lines.append(f" Sequential: {p['chain']}")
lines.append("")
lines.append("Mermaid Diagram:")
lines.append("```mermaid")
lines.append(result["mermaid"])
lines.append("```")
lines.append("")
lines.append("Copy the mermaid block into any compatible renderer")
lines.append("(GitHub, Notion, mermaid.live, VS Code preview, etc.)")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate Mermaid diagrams from agent workflow definitions",
epilog="Paste output into mermaid.live or any Mermaid-compatible renderer.",
)
parser.add_argument("file", nargs="?", help="Path to workflow JSON file")
parser.add_argument("--stdin", action="store_true", help="Read workflow from stdin")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output full results as JSON (includes mermaid field)")
parser.add_argument("--direction", choices=["TD", "LR", "BT", "RL"], default="TD",
help="Graph direction: TD (top-down), LR (left-right), etc. (default: TD)")
parser.add_argument("--annotate-models", action="store_true",
help="Show model names on each node")
parser.add_argument("--annotate-cost", action="store_true",
help="Show estimated token counts on each node")
parser.add_argument("--raw", action="store_true",
help="Output only the raw Mermaid text (no wrapper)")
args = parser.parse_args()
if not args.file and not args.stdin:
parser.error("Provide a workflow file path or use --stdin")
try:
workflow = load_workflow(args.file, args.stdin)
except (ValueError, FileNotFoundError, PermissionError) as e:
if args.json_output:
print(json.dumps({"error": str(e)}, indent=2))
else:
print(f"Error: {e}", file=sys.stderr)
return 2
mermaid = generate_mermaid(
workflow,
direction=args.direction,
annotate_models=args.annotate_models,
annotate_cost=args.annotate_cost,
)
if args.raw:
print(mermaid)
return 0
result = build_result(workflow, mermaid, args.direction,
args.annotate_models, args.annotate_cost)
if args.json_output:
print(json.dumps(result, indent=2))
else:
print(format_human(result))
return 0
if __name__ == "__main__":
sys.exit(main())
Related skills
FAQ
What patterns does it support?
Sequential pipeline, parallel fan-out/fan-in, hierarchical delegation, event-driven reactor, and consensus validation.
Which frameworks are covered?
LangGraph, CrewAI, AutoGen, and Claude Code agent teams, with example implementations.