KDCube
← Engineering
KDCube Engineering · Deep Dive

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.

14 July 2026Engineering18 minExperienceEngine Room
langgraphhosted agentexecute_coreturn workspaceordered deliverypending lane foldlive steeriso-exec

THE DECISION BEFORE THE IMPLEMENTATION

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.

LayerResponsibility
Your product keepsFramework, graph, prompts, tools, domain behavior, agent-owned memory
The host layer addsAsync execute_core, stream adapter, state mapper, accounted model/provider seam, canonical app package
KDCube operatesAuthenticated serialized turns, bound context, communicator transport, conversation record/reload, app lifecycle
Optional connectionsAttachments, 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.

entrypoint.pyPYTHON
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
THE SEAM · ONE TURN IS ONE FUNCTION KDCUBE HOST CONTRACT · DOES NOT REINTERPRET YOUR GRAPH user messageordered lane · one wakeup the door@on_reactive_event execute_core( state, thread_id) your solution/ graphsame framework · domain nodesbuilt fresh per turn · discarded COMM_CTX STREAM → THE REUSABLE CHAT COMPONENT RENDERS YOUR TURN LIVE PRIMARY TURN SEAM · APP-OWNED FRAMEWORK AND CONTROL LOOP
Fig. 1 — the primary turn seam: KDCube loads the app; the wrap drives the app-owned graph without replacing its control loop.

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 chat
comm_ctx.delta(token, i, marker="answer")a streamed answer chunk
comm_ctx.complete(data={"final_answer": ...})the turn ends; the record persists

For 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.

THE RULE

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.

THE RULE

Never cache a graph that has captured turn-bound values.

entrypoint.pyPYTHON
# 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.

SCALED SERVING · THE BOUND GRAPH LIVES FOR ONE TURN WRONG · TURN-BOUND GRAPH CACHED worker A · long-lived entrypoint self.graph = turn 1 binding model + tools captured later turn lands here: stale binding is reused worker B · another process, another machine self.graph = other binding same conversation — drift RIGHT · BOUND FROM SHARED TURN STATE worker A · execute_coreread → bind/build → run worker B · execute_coreread → bind/build → run worker C · execute_coreread → bind/build → run shared state · checkpointer · saved pickscontinuity lives here — true connections are reused IMMUTABLE COMPILE CACHE OPTIONAL · BOUND GRAPH IS NOT CONTINUITY
Fig. 2 — immutable compile caches may be an optimization; a bound graph is not continuity.

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.

TWO MEMORIES · YOURS AND THE CONVERSATION'S your agent’s memorycheckpointer · running summarytenant · project · agent · user · conversation FEEDS ITS OWN NEXT TURN THE CONVERSATION RECORD · KDCUBE-OWNED minimal turn logmessage · files · answer comm eventscitations · steps · follow-ups reload materializes bothsame content · completed state RELOAD READS COMM + THE TURN LOG — NEVER YOUR RUNTIME STATE YOURS FEEDS YOUR NEXT TURN · KDCUBE’S REBUILDS THE CONVERSATION
Fig. 3 — two memories: yours feeds your next turn; KDCube's rebuilds the conversation.

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.

THE BOUNDARY

Reassignment doesn't cross sibling tasks; mutating a shared object does.

THE CROSS-TASK SIGNAL WRONG · REASSIGN THE VAR parent · run() child task flag.set(True) parent reads False bindings copied at spawn REASSIGNMENT NEVER CROSSES BACK RIGHT · MUTATE A SHARED OBJECT parent · run() child task d["recorded"]=True one shared dictthe ContextVar points at it SAME OBJECT · PARENT READS True REASSIGNMENT DOESN’T CROSS SIBLING TASKS · MUTATING A SHARED OBJECT DOES
Fig. 4 — the cross-task signal: share an object, mutate it, never reassign the var.

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.

THE CAPABILITIES GATE admin ceilingallowed models · allowed tools saved conversation choicemodel pick · tool opt-outs NARROWS WITHIN · NEVER WIDENS per-turn _build_graphthe narrowing is an input,not a mutation the bound graphexactly this turn’s shape ADMIN ALLOWS THE SET · THE CONVERSATION NARROWS WITHIN IT
Fig. 5 — the capabilities gate: admin allows the set; the conversation narrows within it.

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 + code
pullmaterialize any link into the working directory — arriving now, uploaded earlier, or produced by code before
execprocess — pulled files sit under bare filenames; every file the code writes is hosted back, with a link

The 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.

THE TURN WORKSPACE · LINKS, NOT AMBIENT FILES THE TURN FRAME [Turn start turn_<id>] directory EMPTY · fresh files arrive as LINKS only; nothing is read automatically [User message] whats in this file? [Files arriving this turn] report.docx · 2.9 MB link: conv:fi:turn_<id>... LINKS FLOW TO THE DOORS THREE DOORS OVER LINKS readview: text bounded · images visual pullmaterialize a link into the directory execprocess; written files hosted back THIS TURN working directorystarts EMPTY every turnholds only what this turn pulls or writes CONVERSATION RECORD every file · conv:fi: link one resolver · read | pull | download HOSTED BACK NOTHING IS READ FOR THE MODEL AUTOMATICALLY — IT DECIDES WHICH LINKS TO OPEN, EVERY TURN ANEW
Fig. 6 — nothing is read automatically; the model opens links through three doors, every turn anew.

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.

ONE APP · MANY AGENTS execute_coredispatch on state[‘agent_id’] spec: research graphdedicated answer nodeclient: kdcube-agent:app:research spec: lg-reactlooping ReAct model nodeclient: kdcube-agent:app:lg-react its stream adapter its stream adapter shared platform glue — identity · storage · economics · capabilities · conversation record ADD AN AGENT = ADD A SPEC · CONSENT REMAINS PER AGENT
Fig. 7 — add an agent = add a spec; platform glue is shared, consent remains per agent.

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.

· Read more

KDCube Engineering
№ 2026-07-14 · kdcube.tech