Your LangGraph Agent in KDCube
You have a capable LangGraph agent — a multi-node graph, nested subagents, its own retrieval and memory, its own persistence — running on one machine for one user. KDCube hosts it at scale without rewriting it. Your graph definition and behavior stay yours; KDCube builds a fresh graph instance for each bound turn and discards it afterward. The platform adds ordered delivery, streaming to a reusable chat UI, user-bound state, a reloadable conversation record, downloadable files, and conversation-scoped capabilities — around a graph it never imports.
The instinct when a platform says "host your agent" is to expect a conversion: port your graph to their framework, adopt their control loop. KDCube does the opposite. Your agent is vendored unchanged — the same package, the same imports, the same nodes — and a thin wrap hands it hosting. The wrap is small enough to read in one sitting; the interesting part is what the platform takes over on your behalf and the few places where a distributed, multi-user runtime forces a decision the single-machine version never had to make.
This is the engineering of that boundary, drawn from a worked instance:
ported-langgraph-agents — one KDCube app hosting two vendored agents, a custom research
graph and a langchain.agents.create_agent ReAct agent.
One turn is one function
The whole integration surface is a single method. When a user message becomes a turn, KDCube calls
execute_core(state, thread_id, params). You read the message out of state,
run your graph to completion, and set state["final_answer"]. That method is the
only place your framework is named — KDCube never imports LangGraph.
class MyAgent(BaseEntrypointWithEconomics):
async def execute_core(self, *, state, thread_id, params):
question = external_events_text(state.get("external_events") or [])
graph = self._build_graph(...) # your compiled graph, this turn
answer = await self._stream(graph, question, thread_id)
state["final_answer"] = answer
Everything else in this article is something KDCube does around that function.
Your stream becomes the chat
Your agent already emits progress — node starts, token deltas, a final answer, whether through
astream_events, callbacks, or plain generators. You redirect that existing stream at
KDCube's communicator, and the reusable chat component renders the turn live with no UI code:
comm_ctx.step(node, "running")— a progress step;comm_ctx.delta(token, index, marker="answer")— a streamed answer chunk;comm_ctx.complete(data={"final_answer": ...})— turn end.
For a graph that already streams, this mapping is 1:1. There is no KDCube-specific event shape to learn on the way in — you emit into a published envelope contract, and the same component that renders the built-in agent renders yours.
Delivered in order, one at a time
You never write the code that decides when your turn runs. A user message is appended to
the conversation's event-bus lane and, atomically, one wakeup is enqueued; the wakeup
fires the shared door, and the door calls execute_core. Turns of one conversation are
serialized across workers; a message that arrives mid-turn waits and becomes the next turn.
A run-to-completion graph gets that contract — one event, one turn, strictly ordered, nothing
lost — for free, and declares accepts_followup: false so the composer offers
"queue for next turn" instead of pretending it folds input mid-flight. The mechanism has its own
article; here it is enough to know the door owns it.
Many users, any worker
Your agent ran for one user on one machine. Hosted, the same process serves many users
concurrently, and a later turn may land on another process or machine. One KDCube
deployment is bound to one tenant/project; the identity gate maps that bound
(tenant, project, user) onto your agent's own per-user key, and
conversation_id onto its thread/checkpointer key.
tenant + project + user -> your memory key "t:p:user"
+ conversation -> your thread key "t:p:user:conversation"
Fold tenant and project into the key even though they are fixed for one deployment. The same app can run in another deployment against shared infrastructure, and the scope must remain explicit there too. Forwarding a raw or constant user id is the silent bug that can mix one user's memory with another's turn. Keep the mapping in its own module and test that user, agent, conversation, and deployment scopes produce different keys before any database exists.
Rebuild every turn
Here is the first decision the single-machine version never faced. On one machine you compile your graph once and reuse it forever. In KDCube a turn can land on any worker or machine, so anything you cache in one process is invisible to the next turn somewhere else — and drifts. The rule is blunt: rebuild the agent every turn.
# inside execute_core — not at load time
graph = self._build_graph(agent_id, disabled_tools) # fresh this turn
The instinct to cache the compiled graph "for speed" is exactly the instinct to resist; correctness beats the microseconds, and the per-turn build is also what lets a saved conversation tool selection narrow the graph before it runs. You reuse only true connections — a database pool, a checkpointer connection opened once — because a connection is not rebuildable per-turn state. Nothing per-turn lives on the entrypoint object. The graph instance exists only for this bound turn and is discarded afterward. That discipline is what lets any worker serve any turn.
This graph lifetime is not the code sandbox. The graph itself is trusted app code. When it invokes the optional code-execution tool, generated Python crosses a separate boundary into KDCube's isolated executor with a sparse workspace; approved tools stay on the trusted supervisor side.
One schema, one scoping column
Your agent keeps its own store — its memory, its knowledge base, its checkpointer — and it stays
yours. Hosted, it is routed onto KDCube's shared Postgres, and the temptation is to give each agent
or app its own schema. Resist that too: KDCube uses one schema per
tenant/project, with tables prefixed by the app's technical bundle_id,
and separates agents by an
agent_id column, not a schema. A per-agent schema multiplies DDL,
pollutes the database, and buys nothing the column does not. App-owned memory and knowledge rows
carry (tenant, project, bundle_id, agent_id, user_id) scope and reads apply that scope;
checkpointer thread ids independently fold user, agent, and conversation. The platform already
provides the vector extension; your app
never runs CREATE EXTENSION, only idempotent CREATE TABLE IF NOT EXISTS at
load.
One bridge detail is worth naming: KDCube's pool is asyncpg, while LangGraph's
checkpointer speaks psycopg. You never hand the pool across that boundary — you derive a
psycopg DSN from the same settings the pool was built from. The pool's presence
is the "hosted" signal; the settings are the durable connection bridge.
The reload nobody wrote
Your agent has memory — a checkpointer, a running summary. That is its memory, for its next turn. It is not the conversation the user scrolls back through. Those are two different memories, and conflating them is the classic port mistake.
KDCube owns the second one. A built-in agent writes a rich timeline; a run-to-completion graph
writes none — so the platform records a minimal turn log for any framework. The
interesting part is what "minimal" now means: the log carries the user's message, its
attachments, and any files your turn produced, alongside your final_answer. And
the dynamic objects your turn emitted — citations, progress steps, follow-ups — are captured from the
communicator and replayed on reload, so a reopened conversation looks
exactly like the live one. The rule that keeps this honest: reload content comes from comm
plus the turn log, never from your runtime state. You already emit through
comm_ctx; you write no reload code, and you set no history format.
If your turn produces a downloadable file — a report, a CSV from a code tool — it is hosted into
that same conversation record, and the file card's Download button resolves through one operation the
app serves (scene_object_action). It is a generic object-action endpoint, not a canvas
feature; for a conversation file it reads the bytes back from storage and returns a link. An app
that hosts files must serve it, or the file shows but Download has nowhere to go.
A signal that had to cross a task boundary
The reload work surfaced a bug worth keeping as a lesson, because it is pure distributed-Python
and easy to reintroduce. The platform's minimal-log recorder must not fire when a framework
already wrote a rich log — so it guards on an "already recorded" flag. The flag was a
ContextVar. The rich log is persisted inside a separate asyncio task.
And a ContextVar set in a child task never propagates back to the parent —
copy_context copies the bindings at task creation, and the child's reassignment
is invisible upstream. So the guard read its default, and the fallback overwrote the rich log with a
minimal one: the user's message vanished on reload.
The fix is a single idea. A ContextVar value that a child reassigns does not
cross the boundary — but a mutable object the var points at is shared, and
a mutation the child makes is visible to the parent. So the signal lives in a dict on the ContextVar,
and writers mutate it; they never reassign the var. The room the runtime restores
across process boundaries is a different mechanism; within one process, across sibling
tasks, a shared object is the bridge.
Capabilities: pick a model, narrow the tools, run code
Everything above makes one agent run correctly. Capabilities let a user shape it. They are optional, and they share one shape: the admin sets a ceiling in config, the user saves a narrower choice for the conversation, and the per-turn rebuild binds the result. An optional user baseline seeds future conversations once; it does not rewrite existing conversations.
Model
Declare a model provider per agent with an answer role and a model list. The platform resolves the conversation's saved pick and overlays it onto the turn's roles around your graph run. One sharp edge earns a line: if you name the conversation with a first-turn title generated on that same answer role, also bind the role in base config — the pick overlay is scoped to the active turn, and the title runs outside it, so without the base binding the title model resolves to nothing and the title comes back empty.
Tools
Declare tools as a connection list — the standard KDCube shape — and the
capabilities menu lists them natively. An admin allowed: false is a hard ceiling; a user
may opt out of an allowed tool but never opt in to a denied one. The picker is a
local draft until Save changes. Each turn you bind exactly the intersection of
admin-declared and conversation-enabled — and this is precisely why the graph is
rebuilt per turn: the narrowing is a clean input to the build, not a mutation of a cached object.
Code
Isolated execution is not tied to KDCube's built-in ReAct harness. The worked
lg-react agent binds run_python as an ordinary LangChain tool, provisions
the same platform per-turn workspace root, and calls the shared exec subsystem. In split-isolation
mode, generated Python receives only narrow work and output mounts: it gets no network, secret
environment, deployment descriptors, or app/platform storage paths. Approved side effects and
external calls remain in trusted supervisor tools, evaluated under the restored request identity.
Files the code produces are hosted into the conversation's attachment storage — so reload and
Download work through the machinery above, with no extra plumbing.
One app, many agents
Because the seam is a single function, one app can host several agents behind it. The worked
instance dispatches on an agent_id carried in state: a small registry maps
each id to a spec — how to build that agent's graph, how to stream its shape, how to name its role —
and execute_core selects the spec and runs it. Adding an agent is adding a spec, never a
branch inside execute_core. The identity gate folds the active agent_id
into the keys, while app-owned rows also carry agent_id scope, so two agents can share a
schema without using the same state keys. Different agent
shapes — a dedicated-answer-node graph versus a looping ReAct model — differ only in their
stream adapter; everything else is shared platform glue.
What you actually write
Strip it to the surface and the port is small: your solution vendored verbatim, plus a thin wrap and the standard app package.
your Python agent (unchanged) KDCube adds (the wrap)
────────────────────────────── ─────────────────────────────────
solution/ (vendored verbatim) ─► execute_core drive it; REBUILT every turn
its graph / framework stream adapter its stream → comm_ctx
its memory + persistence identity gate platform identity → its keys
its streaming loop + package: docs / interface / config / tests
+ (optional) model pick · tools · code-exec
· file download · title
The platform serializes turns, preserves bound user/agent/conversation scope, records the conversation, replays the reload, and enforces the capabilities ceiling. You keep your framework, add a wrap, rebuild it per turn — and ship an app.
Read more
The live docs behind this article:
- Port Your Solution To A KDCube App — the executable, step-by-step recipe this article narrates
- Connect Your Agentic Loop To The Event Bus — ordered, exactly-once turn delivery in depth
- Isolated Code Execution — computation inside, authority through tools
- Split Isolated Runtime — executor/supervisor separation, mounts, environment, and tool mediation
- Cross-Runtime Context — what the runtime restores across boundaries, and what it does not
- Hosted Agent Conversation — the framework-neutral turn log, communicator replay, files, and agent-owned checkpointer split
- Chat Widget Solution — the reusable chat component and the object-action download contract