Migrating from langgraph
CubePi and langgraph both build tool-using LLM agents, but they have different mental models. This page maps langgraph concepts onto CubePi so you can port code without having to re-learn from scratch.
Mental-model shift
| langgraph | CubePi | Why |
|---|---|---|
| State graph with nodes, edges, channels | Agent loop that's a plain while loop you can read | A linear loop is easier to reason about than a graph; CubePi never branches at runtime — control flow lives in middleware |
| Channels (typed state slots) | AgentContext.extra + AgentState.messages | A single dict + a single message list cover every state shape we've seen |
StateGraph.add_node(name, fn) | A middleware hook or a tool | Functions in langgraph nodes split into two roles in CubePi: tool execution (when the model decides) vs. middleware (always-on transforms) |
add_edge(a, b) / add_conditional_edges | Built-in: tools → next turn → tools → … | The conditional shape (tool calls → re-prompt) is the loop; you don't reify it |
MemorySaver / SqliteSaver / PostgresSaver | MemoryCheckpointer / SQLiteCheckpointer / PostgresCheckpointer | Same idea, append-only schema instead of full snapshots |
config: {"configurable": {"thread_id": …}} | Agent(thread_id=…) | First-class agent parameter |
stream_mode="messages" / "values" / "updates" | agent.subscribe(listener) — one event stream | One pattern, eleven event types |
Tools as @tool decorated functions | AgentTool with Pydantic params + async execute | Closer to OpenAI/Anthropic native shape |
HumanMessage, AIMessage | UserMessage, AssistantMessage | Same role-tagged messages, just renamed |
Interrupts via interrupt_before / interrupt_after | agent.steer(...), agent.follow_up(...), agent.abort() | Imperative control instead of declarative interrupt points |
config_schema | Constructor parameters on Agent | No separate schema layer |
Side-by-side: a tool-using agent
langgraph
from typing import TypedDict
from langchain_anthropic import ChatAnthropic
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get current weather for a city."""
return f"72°F and sunny in {city}"
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929").bind_tools([get_weather])
class State(TypedDict):
messages: list
def call_model(state: State):
return {"messages": [llm.invoke(state["messages"])]}
def should_continue(state: State):
last = state["messages"][-1]
return "tools" if last.tool_calls else END
graph = StateGraph(State)
graph.add_node("llm", call_model)
graph.add_node("tools", ToolNode([get_weather]))
graph.add_edge("__start__", "llm")
graph.add_conditional_edges("llm", should_continue)
graph.add_edge("tools", "llm")
app = graph.compile()
for chunk in app.stream({"messages": [("user", "Weather in Tokyo?")]}):
print(chunk)
CubePi
import asyncio
from pydantic import BaseModel
from cubepi import Agent, AgentTool, AgentToolResult, Model, TextContent
from cubepi.providers.anthropic import AnthropicProvider
class GetWeatherParams(BaseModel):
city: str
async def get_weather(tool_call_id, params: GetWeatherParams, *, signal=None, on_update=None):
return AgentToolResult(content=[TextContent(text=f"72°F and sunny in {params.city}")])
agent = Agent(
provider=AnthropicProvider(api_key="…"),
model=Model(id="claude-sonnet-4-5-20250929", provider="anthropic"),
tools=[AgentTool(
name="get_weather",
description="Get current weather for a city.",
parameters=GetWeatherParams,
execute=get_weather,
)],
)
agent.subscribe(lambda e, s=None: print(e.type))
asyncio.run(agent.prompt("Weather in Tokyo?"))
CubePi version removes:
- The
StateGraph, edges, nodes,ENDsentinel, conditional edges. - The
ToolNoderegistry — tools go directly to theAgent. - The
should_continuefunction — the loop knows when there are tool calls. - The
StateTypedDict — state lives on the agent.
Mapping common patterns
Checkpointing
# langgraph
from langgraph.checkpoint.sqlite import SqliteSaver
graph.compile(checkpointer=SqliteSaver.from_conn_string(":memory:"))
# CubePi
from cubepi.checkpointer import SQLiteCheckpointer
async with SQLiteCheckpointer("agent.db") as cp:
agent = Agent(..., checkpointer=cp, thread_id="conv-1")
CubePi's append-only model is O(1) per message, regardless of conversation length. langgraph saves full snapshots, which scales linearly with history.
Streaming
# langgraph
for chunk in app.stream(state, stream_mode="messages"):
if chunk["event"] == "on_chat_model_stream":
print(chunk["data"]["chunk"].content, end="")
# CubePi
def on_event(event, signal=None):
if event.type == "message_update" and event.stream_event.type == "text_delta":
print(event.stream_event.delta, end="")
agent.subscribe(on_event)
await agent.prompt("…")
One subscriber, one stream — no mode flag.
Interrupting / human-in-the-loop
# langgraph
graph.compile(interrupt_before=["tools"])
# CubePi
class HumanApproval(Middleware):
async def before_tool_call(self, ctx, *, signal=None):
approved = await ask_human(f"Run {ctx.tool_call.name}({ctx.args})?")
if not approved:
return BeforeToolCallResult(block=True, reason="rejected")
return None
Imperative interrupts via middleware. You decide per call instead of configuring graph-level interrupt points.
Branching
# langgraph
graph.add_conditional_edges("llm", lambda s: "tools" if s["messages"][-1].tool_calls else "summary")
graph.add_node("summary", summarize)
graph.add_edge("summary", END)
# CubePi
class SummariseAtEnd(Middleware):
async def should_stop_after_turn(self, ctx) -> bool:
msg = ctx.message
if not any(isinstance(c, ToolCall) for c in msg.content):
# No more tool calls; we're done. Inject a summary turn first.
...
return True
return False
There's no built-in branching primitive; flow control happens through
should_stop_after_turn and after_model_response.
What langgraph does that CubePi doesn't (yet)
- Multi-agent supervisor patterns. No first-class "agents
spawning agents" abstraction. You can build it by running multiple
Agentinstances with shared tools. - Visual graph rendering. No
app.get_graph().draw_mermaid()equivalent. CubePi's flow is linear so the picture would be a single line anyway. - Time travel / fork at arbitrary checkpoints. The Postgres schema has fork columns but no API surface in v0.4.
- First-party UI for traces. CubePi doesn't render its own trace
visualizer the way LangSmith / Langfuse do; instead it emits
vendor-neutral OpenTelemetry — point any OTLP backend
(LangSmith's OTel endpoint, Langfuse v3, Jaeger, Tempo,
Honeycomb, Datadog, …) at it via
Tracer(exporters=[OTLPSpanExporter(...)]). See Tracing → OTLP & Backends.
What CubePi does that langgraph doesn't
- Native OpenTelemetry tracing —
Tracer+Meteremit OTel spans + GenAI-semconv attributes out of the box, ingestible by any OTLP backend. See Tracing → Overview. - Native async-first — every entry point is async. No
app.invokevs.app.ainvokesplit. - Append-only persistence — O(1) DB writes, JSONB-queryable messages.
- 3 core deps vs. langchain-core + langgraph-sdk + transitives.
- Streaming-realistic test provider (
FauxProvider) ships in the box. - MCP loaders for HTTP + stdio transports.
Porting checklist
- Replace
StateGraphconstruction with a singleAgent(...)call. - Move
@tool-decorated functions toAgentToolinstances (Pydantic models for params, async execute). - Replace
MemorySaver/SqliteSaver/PostgresSaverwithMemoryCheckpointer/SQLiteCheckpointer/PostgresCheckpointer. - Replace
stream_modecallbacks withagent.subscribe(...). - Convert custom nodes that did message transforms →
Middlewarehooks. - Convert
interrupt_before/after→before_tool_call/after_model_responsemiddleware. - If you had a
summaryorroutenode — fold it intoafter_model_responsewithdecision="stop"or"loop_to_model".
See also
- Core Concepts — the building blocks you're mapping to.
- Middleware → Composition — where flow-control logic lives.
- Checkpointing — the new persistence story.