Apply the llm-workflow-engineer specialist workflow. Build LLM workflows that fit the factory's conventions, not generic LangChain code. Load factory-llm-workflows through the host's skill capability when needed.
How to think (in order)
What kind of LLM workflow is this? Pick one:
- Single LLM call with structured output (intent classification, extraction) — no graph needed
- Multi-step workflow with state (chat, claim verification, document Q&A) — LangGraph
- RAG pipeline (retrieval + answer) — LangGraph with rag/general routing
- Agent with tool calls (function calling, iterative reasoning) — LangGraph with tool dispatch
- Streaming chat — LangGraph + SSE
If it's not graph-shaped, don't reach for LangGraph.
State shape? TypedDict with total=False and NotRequired for optional fields. Nested TypedDicts for complex types (e.g. RetrievedChunk). Never Pydantic — LangGraph merges shallowly.
Node structure? Each node is a function returned by a factory that injects deps (LLM client, vector store, etc.). create_<node_name>_node(deps) -> async (state) -> partial_state. Don't put deps in module scope.
Routing? If you have ≥2 paths, write a named _should_continue_after_<node>(state) -> str function. Don't inline conditionals in add_conditional_edges.
Structured output? Define a JSON schema dict that serves both as LLM tool definition AND validation contract. One source of truth.
RAG specifics:
- Hybrid search (alpha = BM25 vs semantic blend, default 0.5)
- Reranker if available (optional port —
Port | None)
- Confidence threshold gating (default 0.3)
- Fallback supplement RAG (one-attempt-only, flagged in state)
- Per-tenant vector store isolation (Weaviate tenant API or equivalent)
Streaming? SSE with typed events. Backend yields {event, data} dicts via EventSourceResponse. Frontend registers callbacks per event name. Names must match exactly — share a constant module if possible.
Multi-tenancy? Every vector store operation takes project_id / tenant_id. Never share an index across tenants.
Prompts? Local template is source of truth. Optional PromptHub override wrapped in try/except so offline dev works.
Ports/adapters? Only if you're actually swapping implementations (vector store, storage). Don't reach for hexagonal from day one.
Reference: canonical workflow file layout
src/
├── workflows/
│ └── <workflow_name>/
│ ├── state.py # TypedDict
│ ├── graph.py # assembles nodes + edges; exposes compiled graph
│ └── nodes/
│ ├── router.py
│ ├── rag.py
│ ├── general.py
│ └── ...
├── domain/
│ └── ports/
│ ├── vector_store.py
│ ├── reranker.py
│ └── chunker.py
├── adapters/
│ ├── vectorstore/
│ │ └── weaviate.py
│ └── ...
├── dependencies.py # adapter selection by env
└── api/
└── routes/
└── chat.py # SSE endpoint
Reference: canonical TypedDict + node + router shape
# state.py
from typing import TypedDict, NotRequired
class ChatState(TypedDict, total=False):
user_query: str
intent: NotRequired[str]
rewritten_query: NotRequired[str]
retrieved_chunks: NotRequired[list[RetrievedChunk]]
response: NotRequired[str]
rag_fallback_attempted: NotRequired[bool]
# nodes/router.py
ROUTER_OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"intent": {"type": "string", "enum": ["general", "rag"]},
"rewritten_query": {"type": "string"},
},
"required": ["intent"],
}
def create_router_node(llm, prompt_template):
async def router_node(state: ChatState) -> ChatState:
result = await llm.acomplete(
prompt_template.format(query=state["user_query"]),
output_schema=ROUTER_OUTPUT_SCHEMA,
)
return {"intent": result["intent"], "rewritten_query": result["rewritten_query"]}
return router_node
# graph.py
def _should_continue_after_router(state: ChatState) -> str:
if state.get("intent") == "general": return "general_node"
if not state.get("rewritten_query"): return "END"
return "rag_node"
graph.add_node("router", create_router_node(llm, ROUTER_PROMPT))
graph.add_conditional_edges("router", _should_continue_after_router, {
"general_node": "general_node",
"rag_node": "rag_node",
"END": END,
})
Output format
## Restated request
<one sentence>
## Workflow shape
- Type: <single-call / multi-step / RAG / agent-with-tools / streaming>
- State: <TypedDict fields enumerated>
- Nodes: <list with factory functions>
- Routing: <named router functions>
## Files to create or modify
<bulleted with paths>
## Code
<by file>
## Conventions check
- TypedDict (not Pydantic) for state: yes
- Node factories with injected deps: yes
- Named router functions: yes
- Structured output one-schema: yes
- Prompt local-fallback: yes
- Multi-tenant isolation: <how>
## Open questions
<things the user should confirm>
What you do NOT do
- Don't use Pydantic state. TypedDict. Always.
- Don't put routing inline in
add_conditional_edges. Named functions.
- Don't retry RAG past one fallback attempt. Use the
*_attempted flag.
- Don't make PromptHub the source of truth. Local template is canonical; PromptHub is the override.
- Don't share vector store indexes across tenants. Per-tenant API.
- Don't define two schemas (LLM + validation). One JSON schema dict.
- Don't reach for ports/adapters on day one. Only when you actually swap.
- Don't put dependencies at module scope. Use
@lru_cache factories called inside functions.
When the request is too small for this framework
If the user asks for a single one-off LLM call, a quick OpenAI completion, or an unstructured chat response, do it directly. The framework is for stateful workflows, multi-step pipelines, or production agent systems.
1---2name: factory-llm-workflow-engineer3description: Use when building LangGraph workflows, agents, RAG systems, structured-output nodes, streaming chat surfaces, or anything LLM-driven with state. Carries the factory's LLM conventions — TypedDict state schemas, node factory closures, named conditional-edge routers, JSON-schema structured output, local-prompt-fallback with optional PromptHub override, hybrid search with confidence gating and one-attempt fallback, SSE streaming with shared event-name registry. Cothon is the reference repo.4---56Apply the **llm-workflow-engineer** specialist workflow. Build LLM workflows that fit the factory's conventions, not generic LangChain code. Load `factory-llm-workflows` through the host's skill capability when needed.78## How to think (in order)9101. **What kind of LLM workflow is this?** Pick one:11 - **Single LLM call with structured output** (intent classification, extraction) — no graph needed12 - **Multi-step workflow with state** (chat, claim verification, document Q&A) — LangGraph13 - **RAG pipeline** (retrieval + answer) — LangGraph with rag/general routing14 - **Agent with tool calls** (function calling, iterative reasoning) — LangGraph with tool dispatch15 - **Streaming chat** — LangGraph + SSE16 If it's not graph-shaped, don't reach for LangGraph.17182. **State shape?** TypedDict with `total=False` and `NotRequired` for optional fields. Nested TypedDicts for complex types (e.g. `RetrievedChunk`). Never Pydantic — LangGraph merges shallowly.19203. **Node structure?** Each node is a function returned by a factory that injects deps (LLM client, vector store, etc.). `create_<node_name>_node(deps) -> async (state) -> partial_state`. Don't put deps in module scope.21224. **Routing?** If you have ≥2 paths, write a named `_should_continue_after_<node>(state) -> str` function. Don't inline conditionals in `add_conditional_edges`.23245. **Structured output?** Define a JSON schema dict that serves both as LLM tool definition AND validation contract. One source of truth.25266. **RAG specifics:**27 - Hybrid search (alpha = BM25 vs semantic blend, default 0.5)28 - Reranker if available (optional port — `Port | None`)29 - Confidence threshold gating (default 0.3)30 - Fallback supplement RAG (one-attempt-only, flagged in state)31 - Per-tenant vector store isolation (Weaviate tenant API or equivalent)32337. **Streaming?** SSE with typed events. Backend yields `{event, data}` dicts via `EventSourceResponse`. Frontend registers callbacks per event name. Names must match exactly — share a constant module if possible.34358. **Multi-tenancy?** Every vector store operation takes `project_id` / `tenant_id`. Never share an index across tenants.36379. **Prompts?** Local template is source of truth. Optional `PromptHub` override wrapped in try/except so offline dev works.383910. **Ports/adapters?** Only if you're actually swapping implementations (vector store, storage). Don't reach for hexagonal from day one.4041## Reference: canonical workflow file layout4243```44src/45├── workflows/46│ └── <workflow_name>/47│ ├── state.py # TypedDict48│ ├── graph.py # assembles nodes + edges; exposes compiled graph49│ └── nodes/50│ ├── router.py51│ ├── rag.py52│ ├── general.py53│ └── ...54├── domain/55│ └── ports/56│ ├── vector_store.py57│ ├── reranker.py58│ └── chunker.py59├── adapters/60│ ├── vectorstore/61│ │ └── weaviate.py62│ └── ...63├── dependencies.py # adapter selection by env64└── api/65 └── routes/66 └── chat.py # SSE endpoint67```6869## Reference: canonical TypedDict + node + router shape7071```py72# state.py73from typing import TypedDict, NotRequired7475class ChatState(TypedDict, total=False):76 user_query: str77 intent: NotRequired[str]78 rewritten_query: NotRequired[str]79 retrieved_chunks: NotRequired[list[RetrievedChunk]]80 response: NotRequired[str]81 rag_fallback_attempted: NotRequired[bool]8283# nodes/router.py84ROUTER_OUTPUT_SCHEMA = {85 "type": "object",86 "properties": {87 "intent": {"type": "string", "enum": ["general", "rag"]},88 "rewritten_query": {"type": "string"},89 },90 "required": ["intent"],91}9293def create_router_node(llm, prompt_template):94 async def router_node(state: ChatState) -> ChatState:95 result = await llm.acomplete(96 prompt_template.format(query=state["user_query"]),97 output_schema=ROUTER_OUTPUT_SCHEMA,98 )99 return {"intent": result["intent"], "rewritten_query": result["rewritten_query"]}100 return router_node101102# graph.py103def _should_continue_after_router(state: ChatState) -> str:104 if state.get("intent") == "general": return "general_node"105 if not state.get("rewritten_query"): return "END"106 return "rag_node"107108graph.add_node("router", create_router_node(llm, ROUTER_PROMPT))109graph.add_conditional_edges("router", _should_continue_after_router, {110 "general_node": "general_node",111 "rag_node": "rag_node",112 "END": END,113})114```115116## Output format117118```119## Restated request120<one sentence>121122## Workflow shape123- Type: <single-call / multi-step / RAG / agent-with-tools / streaming>124- State: <TypedDict fields enumerated>125- Nodes: <list with factory functions>126- Routing: <named router functions>127128## Files to create or modify129<bulleted with paths>130131## Code132<by file>133134## Conventions check135- TypedDict (not Pydantic) for state: yes136- Node factories with injected deps: yes137- Named router functions: yes138- Structured output one-schema: yes139- Prompt local-fallback: yes140- Multi-tenant isolation: <how>141142## Open questions143<things the user should confirm>144```145146## What you do NOT do147148- **Don't use Pydantic state.** TypedDict. Always.149- **Don't put routing inline in `add_conditional_edges`.** Named functions.150- **Don't retry RAG past one fallback attempt.** Use the `*_attempted` flag.151- **Don't make PromptHub the source of truth.** Local template is canonical; PromptHub is the override.152- **Don't share vector store indexes across tenants.** Per-tenant API.153- **Don't define two schemas (LLM + validation).** One JSON schema dict.154- **Don't reach for ports/adapters on day one.** Only when you actually swap.155- **Don't put dependencies at module scope.** Use `@lru_cache` factories called inside functions.156157## When the request is too small for this framework158159If the user asks for a single one-off LLM call, a quick OpenAI completion, or an unstructured chat response, do it directly. The framework is for stateful workflows, multi-step pipelines, or production agent systems.