Implementing Custom Content Transformations Using LangGraph in Open-Notebook
Open-Notebook implements custom content transformations through a three-layer architecture combining Pydantic domain models, LangGraph state machines, and FastAPI endpoints, allowing users to define reusable LLM-powered text transformations via declarative configuration.
Open-Notebook is an open-source AI research assistant that leverages LangGraph to power server-side content transformations. Implementing custom content transformations using LangGraph enables developers to create reusable, prompt-driven text processing pipelines that integrate seamlessly with the application's SurrealDB-backed storage layer.
Architecture Overview
The transformation system is organized into three tightly-coupled layers:
- Domain Model – Stores transformation metadata and prompt templates in
open_notebook/domain/transformation.py - Graph Definition – Defines the LangGraph
StateGraphthat executes LLM operations inopen_notebook/graphs/transformation.py - API Surface – Exposes CRUD and execution endpoints through
api/routers/transformations.py
This separation allows you to define new transformations by creating database records without modifying Python code, while the LangGraph engine handles the orchestration.
Domain Model Layer
The Transformation Model
The Transformation class in open_notebook/domain/transformation.py is a Pydantic model that stores user-defined prompts and configuration:
class Transformation(ObjectModel):
table_name: ClassVar[str] = "transformation"
name: str # short identifier, used in UI & API
title: str # human-readable label
description: str
prompt: str # raw Jinja-style template
apply_default: bool # if true, prepend default instructions
Each record represents a reusable transformation template that the LangGraph pipeline can execute against any text input.
Default Prompts Configuration
The DefaultPrompts singleton in the same file holds site-wide transformation instructions. When apply_default is true, the system automatically prepends these instructions to your custom prompt, ensuring consistent formatting or persona across all transformations.
LangGraph State Machine
State Definition
The graph operates on a TransformationState TypedDict defined in open_notebook/graphs/transformation.py:
class TransformationState(TypedDict):
input_text: str # optional raw text to transform
source: Source # optional SurrealDB Source object
transformation: Transformation
output: str
This state object carries the configuration and content through the graph nodes.
Graph Execution Flow
The transformation graph is a minimal StateGraph containing a single execution node (run_transformation). The node logic performs four critical operations:
-
Content Selection – Falls back to
source.full_textwheninput_textis missing -
Prompt Assembly – Combines the stored
transformation.promptwithDefaultPrompts.transformation_instructions(if enabled) and adds an# INPUTmarker -
LLM Provisioning – Calls
provision_langchain_modelfromopen_notebook/ai/provision.pywith the assembled system prompt andHumanMessage -
Response Handling – Extracts plain text, strips "thinking" artifacts using
clean_thinking_content, and persists the result as an insight on the source
The compiled graph callable supports asynchronous invocation with a configurable model_id passed through the graph configuration.
API Integration
The api/routers/transformations.py router provides the complete CRUD lifecycle plus an execution endpoint. The execute method bridges HTTP requests to the LangGraph pipeline:
@router.post("/transformations/execute", response_model=TransformationExecuteResponse)
async def execute_transformation(execute_request: TransformationExecuteRequest):
transformation = await Transformation.get(execute_request.transformation_id)
model = await Model.get(execute_request.model_id)
result = await transformation_graph.ainvoke(
dict(input_text=execute_request.input_text,
transformation=transformation),
config=dict(configurable={"model_id": execute_request.model_id}),
)
return TransformationExecuteResponse(
output=result["output"],
transformation_id=execute_request.transformation_id,
model_id=execute_request.model_id,
)
This endpoint validates the transformation and model entities, then forwards the request to transformation_graph.ainvoke(). The response contains the LLM-generated output, and if a source object was provided, the system automatically stores the result as an insight in SurrealDB.
Creating a Custom Transformation
You can implement new transformations entirely through the REST API without restarting the application:
1. Define the transformation record:
POST /transformations
{
"name": "summarize",
"title": "Summarize Text",
"description": "Produces a concise summary of the input.",
"prompt": "Summarize the following text in 3 bullet points:",
"apply_default": true
}
2. (Optional) Update global default instructions:
PUT /transformations/default-prompt
{
"transformation_instructions": "You are a helpful assistant."
}
3. Execute the transformation:
POST /transformations/execute
{
"transformation_id": "<uuid-from-step-1>",
"model_id": "gpt-4o-mini",
"input_text": "Open-Notebook is an AI-powered research assistant..."
}
The response contains the transformed output and persists the result as an insight if a source object was included in the request.
Extending the Graph
For complex pipelines requiring multiple nodes or conditional branching, modify open_notebook/graphs/transformation.py:
# Add a logging node after the main transformation
async def log_output(state: dict, config: RunnableConfig) -> dict:
print("Transformation result:", state["output"])
return state
agent_state.add_node("logger", log_output)
agent_state.add_edge("agent", "logger")
agent_state.add_edge("logger", END)
You can add conditional edges using add_conditional_edges to route based on LLM confidence scores or metadata flags. All modifications preserve the same state interface, ensuring compatibility with the existing API router.
Code Examples
Python Client Example
import httpx
BASE = "http://localhost:5055"
# Create the transformation
resp = httpx.post(
f"{BASE}/transformations",
json={
"name": "extract_keywords",
"title": "Extract Keywords",
"description": "Pulls out the most important keywords.",
"prompt": "List the top 5 keywords from the text:",
"apply_default": False,
},
)
transformation_id = resp.json()["id"]
# Run it
run_resp = httpx.post(
f"{BASE}/transformations/execute",
json={
"transformation_id": transformation_id,
"model_id": "gpt-4o-mini",
"input_text": "LangGraph enables composable LLM workflows...",
},
)
print("Output:", run_resp.json()["output"])
cURL Example
# Create transformation
curl -X POST http://localhost:5055/transformations \
-H "Content-Type: application/json" \
-d '{
"name":"title_case",
"title":"Title-Case Converter",
"description":"Converts a sentence to title case.",
"prompt":"Convert the following text to title case:",
"apply_default":true
}' | jq .id
# Execute transformation
curl -X POST http://localhost:5055/transformations/execute \
-H "Content-Type: application/json" \
-d '{
"transformation_id":"<id-from-above>",
"model_id":"gpt-4o-mini",
"input_text":"open-notebook makes AI research easy"
}' | jq .output
Summary
- Three-layer architecture separates concerns between data models (
open_notebook/domain/transformation.py), graph logic (open_notebook/graphs/transformation.py), and HTTP interfaces (api/routers/transformations.py) - Declarative configuration allows new transformations via JSON API calls without code deployment
- LangGraph state machine handles prompt assembly, LLM invocation via
provision_langchain_model, and output sanitization throughclean_thinking_content - Automatic persistence stores transformation results as insights when source objects are provided
- Extensible design supports custom graph nodes and conditional edges for complex pipelines
Frequently Asked Questions
How do I add a new transformation without modifying the source code?
Create a new record via the POST /transformations endpoint with your custom prompt template. The LangGraph engine in open_notebook/graphs/transformation.py dynamically loads these records at runtime, requiring no application restarts or code changes.
What happens if I provide both input_text and a source object?
The run_transformation node in open_notebook/graphs/transformation.py prioritizes input_text when present, falling back to source.full_text only when the input text is missing. This allows you to override source content with custom text for specific transformations.
Can I use different LLM models for different transformations?
Yes. The execute endpoint accepts a model_id parameter that routes through provision_langchain_model in open_notebook/ai/provision.py. Pass any configured model ID (e.g., "gpt-4o-mini", "claude-3-opus") to use model-specific behavior for individual transformation calls.
How do I remove "thinking" artifacts from LLM outputs?
The system automatically calls clean_thinking_content from open_notebook/utils/clean_thinking_content.py before persisting insights. This function strips reasoning chains and internal monologue markers, ensuring only the final transformed content is stored in the database.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →