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.3.
- LangSmith / Langfuse integrations. Bring your own tracing via
middleware +
on_response.
What CubePi does that langgraph doesn'tâ
- 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.