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:

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:

  1. Content Selection – Falls back to source.full_text when input_text is missing

  2. Prompt Assembly – Combines the stored transformation.prompt with DefaultPrompts.transformation_instructions (if enabled) and adds an # INPUT marker

  3. LLM Provisioning – Calls provision_langchain_model from open_notebook/ai/provision.py with the assembled system prompt and HumanMessage

  4. 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 through clean_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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →