Your LangGraph Agent in KDCube
A working agent is product IP, not migration debt. Preserve its LangGraph core and add a bounded async host layer for ordered turns, streaming, user-bound state, and reload.
A working agent is product IP, not migration debt. When real users arrive, production should add an operating layer around that agent — not force its graph, prompts, tools, and domain behavior into another framework.
The move from a proven agent to a hosted product is often framed as a bad choice: rewrite the agent for a platform, or build every production service around it yourself. That choice confuses the reasoning system with the runtime it now needs. The agent decides what to do. The runtime must deliver turns in order, bind the right user and conversation, stream progress, retain the record, and account for paid work across concurrent workers.
KDCube takes the narrower path: preserve the independently maintainable agent and add an explicit async host layer at stable seams. This is not “zero integration.” It is a bounded integration whose surface can be named and tested: one turn method, one stream adapter, one state-scope mapper, one accounted service boundary, and the standard app package. Everything beyond that is connected only when the product needs it.
If your goal is to settle the agent in KDCube, start with Settle Your Solution In A KDCube App. This article is the engineering evidence behind that recipe. It goes deep into the edge cases the worked reference exposed; those findings are proof of the boundary, not a claim that every baseline must enable every capability.
| Layer | Responsibility |
|---|---|
| Your product keeps | Framework, graph, prompts, tools, domain behavior, agent-owned memory |
| The host layer adds | Async execute_core, stream adapter, state mapper, accounted model/provider seam, canonical app package |
| KDCube operates | Authenticated serialized turns, bound context, communicator transport, conversation record/reload, app lifecycle |
| Optional connections | Attachments, model/tool choices, web, isolated exec, turn workspace, hosted files, multi-agent dispatch |
Byte identity is not the invariant. A safe async, configuration, or model seam may require a documented source change, and the host may compose a shared workspace instruction into the prompt. Those changes stay small, centralized, and visible; they do not become a second agent implementation.
This is the engineering of that boundary, drawn from the worked
ported-langgraph-agents@2026-07-13
app: one KDCube app hosting two original agent shapes, a custom research graph
and a langchain.agents.create_agent ReAct agent. Read its
solution/ core
beside the
platform/ wrap:
the boundary is visible in the source tree.
01 One turn is one function
The primary turn seam 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"]. KDCube loads your app, and your
solution/ and platform/ modules import LangGraph normally;
the platform does not reinterpret the graph or replace its control loop.
class MyAgent(BaseEntrypointWithEconomics): async def execute_core(self, *, state, thread_id, params): question = external_events_text(state.get("external_events") or []) graph = await 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.
02 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 appears in the chatcomm_ctx.delta(token, i, marker="answer")a streamed answer chunkcomm_ctx.complete(data={"final_answer": ...})the turn ends; the record persistsFor the worked research graph, which has a dedicated answer node, that mapping
is direct: answer-node tokens become answer deltas and selected node lifecycles
become steps. It is not universal. The looping create_agent graph emits
several model turns while it chooses and runs tools, so its adapter surfaces tool
runs as steps and treats the final model turn with no tool call as the authoritative
answer. In the standard loop, tool-deciding turns carry no visible text; if a model
emits a visible preamble before a later tool-call chunk, the adapter keeps that round
text as its own timeline event instead of gluing it to the final answer.
Already-streamed bytes cannot be retracted. Inspect the framework's event shape and
keep that policy in the stream adapter. The output still uses one published KDCube
envelope contract, so the chat component does not change.
03 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; messages arriving mid-turn wait and become part of the next turn,
all together. A run-to-completion graph receives everything still pending on the
lane in arrival order, so a correction is read with the work it corrects rather
than one paid turn later.
Mid-flight input is different from mid-flight control. Nothing folds into the
graph-owned loop, but the lane is watched while it runs and a steer cancels
the stream. The checkpointer retains the last completed node. The worked
adapter declares accepts_steer: true and
accepts_followup: false: the composer offers stop while presenting an
extra message as pending for the next turn. Only intent arriving after the last
steer can wake that turn; earlier content remains pending for a future start fold.
Cancellation can still land between two checkpointed halves of one exchange: the model's tool call exists, but the tools node never wrote its result. Before another model request, the adapter repairs every unanswered call with a truthful tool result saying it did not run, attributed to the tools node. Otherwise the provider rejects the replayed history and one stop breaks the conversation. The worked app runs this repair after a stop and before every turn, so a timeout or dead worker can heal on the next message too.
One nuance the host integration surfaced the first time a user attached a file: a message
with attachments is one ingress batch — the prompt event plus one
event per hosted file, sharing a batch_id — while the wakeup that
starts your turn names only one occurrence. Rehydrated alone, the turn
sees the prompt and is blind to both the files beside it and messages queued
during an earlier run. The wrap reads the lane once, read-only, and folds every
pending occurrence in sequence order while retaining batch, sequence, and arrival
stamps. Consumption bookkeeping stays with the shared door; attachment siblings
enrich input rather than creating extra turns.
Model input is the whole pending lane at turn start, not only the wakeup event or its ingress batch.
04 Many users, any worker
Your original deployment can validly own a process-local identity and
configuration boundary. Hosted, the same process serves many users
concurrently, and a later turn may land on another process or machine.
Authentication and delegation are already resolved before
execute_core; the state-scope mapper projects the bound
(tenant, project, agent, user) onto your agent's memory key, and
conversation_id onto its thread/checkpointer key. Shared storage
rows also carry the app/bundle and agent scopes explicitly.
tenant + project + agent + user -> your agent memory key + conversation -> your thread/checkpointer key tenant + project + app + agent + user -> your storage-row scope
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 agent keys and storage-row scopes separately before any database exists.
05 Rebuild every turn
Here is the first decision the single-machine version may not have faced. A standalone process can validly cache an immutable compiled graph while it owns stable configuration. In KDCube, a later turn can land on any worker, so a process-local graph is never continuity or durable state. More importantly, the worked app binds the current conversation's model and tool selection while building the graph. It therefore rebuilds the bound graph every turn.
Never cache a graph that has captured turn-bound values.
# inside execute_core — not at load time graph = await self._build_graph( agent_id, disabled_tools=disabled_tools, ) # fresh and bound to this turn
Do not cache a graph that has already captured identity, model choice, tool selection, or any other turn-bound value. A genuinely immutable compile artifact may be a per-worker optimization in another design, but it must never become the store of conversation continuity. The worked app reuses only true connections — a database pool and a checkpointer connection opened once — while keeping every per-turn value off the long-lived entrypoint object. Its graph instance exists only for the bound turn and is discarded afterward. That discipline lets any worker build an equivalent turn from shared state.
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.
06 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. The worked
app's memory and knowledge tables are prefixed by its technical
bundle_id and separate agents by an
agent_id column, not a schema. Its LangGraph
checkpointer keeps the framework's standard tables in that schema and isolates
sessions with the scoped thread id. 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.
07 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 host-integration 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. Accepted user
blocks are written before the assistant completion even when the turn ends in a
terminal exception. A graph recursion error can therefore become the assistant
result without turning the historical record into an assistant-only turn or hiding
every turn that follows it. And the dynamic objects your turn emitted — citations,
progress steps, follow-ups — are captured from the communicator and
materialized on reload, so a reopened conversation reconstructs the same renderable
citations, steps, follow-ups, and files. Reload is not a replay of the original
transport timing.
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.
The same holds for streamed panels. A turn that drove a live
widget — the code-execution panel is the flagship: program name, the code,
status, produced files — streamed it as marker-tagged deltas through the
communicator. Those streams are aggregated and persisted with the turn. On reload
the server emits each stored stream row as a synthetic, completed
chat.delta; the client hydrates the same panel content without
reenacting the original token cadence. This, too, is framework-neutral — your graph
drove a widget through comm_ctx, and the platform did the rest.
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 validates the bytes through the shared
resolver and returns a cookie-authenticated download link. An app that hosts files
must serve it, or the file shows but Download has nowhere to go.
08 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.
Reassignment doesn't cross sibling tasks; mutating a shared object does.
09 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.
Knobs. The app descriptor is the hosted configuration surface — every runtime knob an operator may need exists as a declared property your wrap reads. A standalone solution may keep its offline env-var idiom, but the wrap injects descriptor values; process environment is not the hosted app configuration surface. The knob that earns the warning here is the answer model's output-token budget: a tool-calling agent passes whole payloads as tool arguments (the exec tool carries the full program text), so the budget must fit narration plus one complete tool call. Too small, and the response is cut mid-arguments — the tool rejects the truncated call as "missing argument", the model retries the identical call into the same ceiling, and the loop only ends at the graph recursion limit. Size it as a generous safety cap (the model stops on its own), declare it per agent in the descriptor, and KDCube's LangChain adapter both logs the interruption with evidence and tells the model, in its own message, that it was cut off and how to proceed.
Tools. Declare tools as a connection list —
the standard KDCube shape — and the capabilities menu lists them natively. The
declaration is the hard ceiling: an undeclared tool is never bound, and each
connection's allowed: [tool names] is its allow-list. A user may opt
out of a declared tool but never opt in to an undeclared 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.
For a managed KDCube MCP connection, tool selection and user delegation are
different gates. The app declares delegated: true, requested
scopes, the concrete url, and the Connection Hub catalog
resource. The hosted agent acts as
kdcube-agent:<app>:<agent_id> with its own per-user grant.
Each turn the host loads the bearer already bound to that record; it never passes
the user's browser session into the graph. No grant means no MCP contact, and a
grant to lg-react grants nothing to the research graph beside it.
If that MCP reaches Slack, Gmail, or another provider, two consents chain: Delegated by KDCube admits this agent to the KDCube resource; Delegated to KDCube authorizes use of the connected provider account. Revoking either stops the tool, and the provider credential never reaches LangGraph or model context.
The generic named-services bridge makes the second gate more precise. Its hosted
connection carries the named_services:use door grant; the live
Connection Hub catalog defines the attempted action and its requirements; the
selected account's current account_scope must carry the provider claim.
The bridge rebinds the current agent and account scope when the tool is actually
invoked, including across an MCP dispatch. Missing authority returns structured
consent feedback rather than a vague tool failure.
Web. The platform's paid web tools bind to your preserved agent core
like any other tool — one connection line declares web_search and
web_fetch, and the picker offers them — with both paid meters
intact. The search provider (the deployment's configured backend) bills
through the same per-turn accounting context your model calls already use; the LLM
that filters and segments the fetched results bills through your app's accounted
model service. Results arrive shaped for a chat model — content bounded, truncation
stated, binary payloads as metadata — and the search widget streams into the chat
with no client change. Your agent gains grounded, billed web access without changing
its solution core; the wrap imports and binds the platform backends.
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.
10 Files in: the turn workspace
Download covered files going out. Files coming in — the user
attaches a spreadsheet, or your own code produced a report two turns ago — ride
one platform concept: the distributed turn workspace. When the
code-exec/workspace connection is bound, that turn gets a working directory on the
shared exec volume; the code tool runs with it as its current directory; and it
obeys a single rule with no exceptions:
it starts empty every turn. Nothing carries over in the directory
itself — not the user's files, not files the code produced or pulled before. What
is durable is the conversation: every file keeps a conversation
link (conv:fi:...) that identifies it in any later turn.
The model learns all of this in-band. Nothing is read for it automatically — not text, not images. Each turn's input arrives framed:
[Turn start turn_<id>] Your working directory is EMPTY — it starts fresh every turn. Files are given to you as LINKS only; nothing is read for you automatically. ... [User message] whats in this file? [Files arriving this turn] - report.docx (application/vnd...document, 2.9 MB) — link: conv:fi:turn_<id>.user.attachments/report.docx
The frame is the same idea as the built-in agent's turn-scoped timeline, adapted to a chat-shaped history: an explicit boundary with the turn id, the user's words verbatim, and each arriving file as metadata plus link. The boundary has to be in-band, because the failure mode without it is cognitive, not mechanical: the model's history says "I pulled that file before" — and the fresh, empty directory silently contradicts everything it remembers.
Three tools operate over the links, and they bind together with the code-exec connection — the triad stands or falls as one:
readview one file in visible context — text bounded; images and PDFs visual; other binaries routed to pull + codepullmaterialize any link into the working directory — arriving now, uploaded earlier, or produced by code beforeexecprocess — pulled files sit under bare filenames; every file the code writes is hosted back, with a linkThe shared conv:fi: byte resolver serves read and pull; the Download
object action validates through that same resolver before returning a
cookie-authenticated download_url. The ref is a durable locator, not a
local path and not bearer authority: trusted resolution fixes tenant, project, and
the bound user independently, so guessing another user's conversation ref yields no
bytes. A provider action that accepts an existing conversation file uses the same
actor-scoped resolution and only then materializes disposable bytes beside the
provider call. Turnless clients instead request an upload target, send the full
bytes, and pass its staged_ref; inline base64 is only a small
generated-file fallback. A link that downloads therefore also reads and pulls. And
because the paradigm is platform-shaped rather than agent-shaped, its instruction
block is a shared SDK building block, parameterized by tool names: any agent
connected to the workspace gets the same literacy — the turn lifecycle, the link
vocabulary, the three doors.
11 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 map its inputs, 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. Each AgentSpec
owns the shape-specific build function, input mapper, model role, and stream adapter.
The state-scope mapper 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. Identity, storage, economics, capabilities,
conversation recording, and the turn door remain shared platform glue.
Each spec also has a distinct delegated-client id. Consent binds to that agent and resource, so one app can offer several agents without turning approval for one into approval for all.
12 What you actually write
Strip it to the surface and the host layer is small: your independently maintainable solution package, plus a thin async wrap and the standard app package.
your Python agent (preserved core) KDCube adds (the wrap / async host layer)
───────────────────────────────── ─────────────────────────────────────────
solution/ (framework + domain) ─► execute_core bind/build for this turn
its graph / framework stream adapter its stream → comm_ctx
its memory + persistence state mapper bound identity → its keys
its streaming loop + package: docs / interface / config / tests
independently maintainable + explicit config/model seams
+ (optional) model pick · tools · code-exec
· workspace read/pull · file download
· title · shared workspace guide
The platform serializes turns, preserves bound user/agent/conversation scope, records the conversation, hydrates it on reload, and enforces the capabilities ceiling. You keep your framework, add a wrap, build from the bound turn configuration — and ship an app.