KDCube
← Engineering

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
ONE TURN IS ONE FUNCTION — THE SEAM WHERE KDCUBE MEETS YOUR GRAPH user message one turn KDCUBE — NEVER IMPORTS YOUR FRAMEWORK run() the shared door @on_reactive_event execute_core (state, thread_id, params) your vendored graph the same package · the same imports · the same nodes multi-node · subagents · its own memory and persistence — unchanged comm_ctx: step · delta · complete reusable chat component renders the turn live — no UI code
The platform calls one function; everything to the right of the seam stays yours.

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.

REBUILD EVERY TURN — ANY WORKER MUST BE ABLE TO SERVE ANY TURN CACHED GRAPH — BREAKS AT SCALE worker A long-lived entrypoint object holds a cached compiled graph works — on this process only turn 1 worker B different process, different machine no cache here — and no way to see A's whatever A cached is invisible — and drifts turn 2 FRESH BUILD PER TURN — ANY WORKER, SAME RESULT worker A · turn 1 _build_graph() build → run → discard worker B · turn 2 _build_graph() build → run → discard worker C · turn 3 _build_graph() build → run → discard checkpointer connection opened once — a connection, not per-turn state the only thing reused
The graph lives for one turn. Durable state lives in shared storage.

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.

TWO MEMORIES — YOUR AGENT'S, AND THE CONVERSATION THE USER SCROLLS YOUR AGENT'S MEMORY checkpointer · running summary its own store — routed, still its own its NEXT turn for the agent, by the agent THE CONVERSATION RECORD · KDCUBE the turn log user message · attachments · produced files · your final_answer captured from comm citations · progress steps · follow-ups — the objects your turn already emitted reload reopened chat the transcript replayed — looks exactly like the live turn you wrote no reload code reload reads comm + the turn log — never your runtime state
Your memory serves your next turn; the platform's record serves the person scrolling back.

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.

A SIGNAL ACROSS SIBLING TASKS — THE GUARD THAT READ ITS DEFAULT REASSIGN THE VAR — INVISIBLE UPSTREAM parent task · the guard reads the flag → False (its default) copy_context — bindings copied at creation child task · persists the rich log ContextVar.set(True) # reassignment the set never crosses back guard sees "not recorded" → minimal log overwrites the rich one; the user's message vanished on reload MUTATE A SHARED OBJECT — VISIBLE TO BOTH parent task · the guard reads dict["recorded"]True one shared dict the ContextVar points here child task · persists the rich log flag["recorded"] = True # mutation both tasks hold the same object — the mutation is the bridge
Reassignment doesn't cross sibling tasks; mutating a shared object does.

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.

CAPABILITIES — THE APP SETS THE CEILING, THE CONVERSATION NARROWS IT admin ceiling · config allowed models · declared tools allowed: false is a hard ceiling saved conversation choice model · tool opt-outs · Save may narrow allowed — never widen it the intersection — narrowed, never widened _build_graph() per turn, inside execute_core the bound graph this conversation's model the enabled tools — this turn
The per-turn rebuild is what makes the narrowing a clean input, not a mutation of a cached object.

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.

ONE APP, MANY AGENTS — ADD AN AGENT = ADD A SPEC execute_core selects the spec, runs it agent_id in state spec: research graph multi-node · dedicated answer node how to build · how to stream · its role name vendored package A, unchanged spec: ReAct agent looping model node · create_agent same registry shape, different build vendored package B, unchanged stream adapter A answer node → deltas stream adapter B loop turns → steps + deltas the only place the shapes differ shared platform glue — identity gate (agent_id in the keys) · storage (one schema, agent_id column) · economics · capabilities · conversation record
Adding an agent is adding a spec — never a branch inside execute_core.

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.

KDCube Deep · 14.07.2026