Host a Coding Agent Inside Your KDCube App
A trusted teammate opens a browser, asks the resident agent to change a file in a git-backed store, watches the work happen as steps, reviews the diff on a desk beside the chat, and commits — under their own identity, with only the capabilities they granted.
One git-backed store with two doors. An engineer works the checkout on their laptop with their own coding agent. A teammate opens the app in a browser, asks the resident agent to change the same files, watches the work as steps in the chat, sees the diff on a desk beside it, and commits — under their own identity, with only the capabilities they granted.
A coding agent is a filesystem animal: it reads, greps, edits, runs commands, commits. This recipe makes one a citizen of your app — identity, consent, accounting, a conversation that survives a reload — without inventing a second way to work the same repository.
Current commands and descriptors still say bundle in names such
as bundles.yaml, bundle reload, and
@bundle_entrypoint. In this recipe, app = bundle: one
deployable KDCube unit.
This recipe chooses the harness-bound conversational profile: the app binds the shared turn workspace because its agent must open conversation refs, make editable derivatives, and deliver selected files into chat. A direct app-owned pipeline may instead let Claude work only in app-domain storage; the News app uses that profile. Both are trusted-runtime integrations. A stable conversation path, MCP grants, and the harness file boundary do not constrain Claude’s Bash tool to that path. Use this hosted chat pattern for trusted admin/insider users, or place each untrusted user in an OS-enforced isolated worker with a minimized filesystem, environment, and network boundary.
- A running KDCube (local is fine) and an app you can edit
- The
claudeCLI in the runtime image, with credentials reachable by the subprocess - A git repository for the content, and a branch namespace for agent session transcripts
- Connection Hub reachable in that deployment — the grant lane lives there
- Claude Code with the KDCube plugin, for the guided path
OP 10OF 120 Get the assistant that knows this platform
/plugin marketplace add https://github.com/kdcube/agent-plugins /plugin install kdcube@kdcube /kdcube:init
The plugin points your own coding agent at the current KDCube source and docs, so
the wiring below is generated against the platform you actually run, not a
remembered version of it. If you have no runtime yet, /kdcube:runtime-init
is the other half: it installs the CLI, initializes one locally, and can put the
app's surfaces behind a single public HTTPS origin.
Ask it to open the resident-agent recipe from the docs it just connected. If it
can read docs/recipes/apps/, you are pointed at the right tree.
OP 20OF 120 Lay out the store, the workspaces, and the toolchain
Three directories, and only one of them is the repository.
<bundle storage>/ store/ ← the git checkout the agent edits agent_workspaces/ <conversation_id>/ ← one workspace per CONVERSATION .claude/ ← CLI config dir + session transcript .mcp.json ← generated per turn CLAUDE.md ← generated per turn .kdcube/turn-workspace/ <turn_id>/ files/ ← editable deliverables eligible for publish git/projects/ ← editable project state conv_<source>/turn_<source>/... ← collision-safe read-only materialization <machine-local disk, NOT bundle storage>/ <toolchain>-<sha256(requirements.txt)[:16]>/ ← the operator venv
Two placements look convenient and are traps. A virtualenv inside the
package is thousands of files in every git status an operator
reads, and it disappears when the app re-materializes its source. A virtualenv on
shared storage is thousands of small files over a network
filesystem, corruptible by two hosts installing at once. Keep generated things
beside the store, never in it, and keep the toolchain on the local disk of the
machine that runs it, in a directory keyed by a hash of its requirements file.
git status in the store lists exactly the content changes — no
.venv, no workspaces, no caches.
OP 30OF 120 Run the CLI per turn, keep the workspace per conversation
Each turn starts a fresh subprocess and resumes the same session identity. The
working directory must NOT move between turns: it is part of the runtime's system
prompt, so a new path re-creates that prefix every turn — the stream reports
cache_miss_reason: system_changed — and your user waits through a
rebuild before the first token.
turn_workspace = await bind_claude_code_turn_workspace( workspace=workspace, tenant=tenant, project=project, user_id=user_id, conversation_id=conversation_id, turn_id=turn_id, entrypoint=entrypoint, state=state, publication_policy=product_policy, ) workspace_config = turn_workspace.apply_workspace_config( ClaudeCodeWorkspaceConfig( mcp_servers=product_mcp_servers, enabled_mcp_servers=tuple(product_mcp_servers), allowed_tools=product_allowed_tools, instructions_markdown=product_instructions, ) ) prepare_claude_code_workspace(workspace, workspace_config) try: result = await run_claude_code_turn( agent=agent, prompt=prompt, kind="regular", resume_existing=previous_turn_ran, session_store=ClaudeCodeSessionStoreConfig( implementation="git", # "local" only on a single-node dev box local_root=workspace / ".claude", tenant=tenant, project=project, user_id=user_id, conversation_id=conversation_id, agent_name=agent_name, git_repo=session_repo, ), ) finally: await turn_workspace.close()
The agent's memory of your conversation is its own transcript, not the platform record. Putting that transcript on a branch per conversation is what lets the next turn run on a different worker and still remember yesterday.
Two properties of that store are easier to learn here than in production. It restores its checkout at the start of every run — the directory is emptied and reset to the previous turn's snapshot — so nothing belonging to this turn may be written inside it; OP 90 turns on that fact. And a run killed on its wall clock resumes cleanly: the next turn picks up the same session with the memory it had, so a timeout costs the unfinished work, not the conversation.
Run two turns in one conversation and compare time to first token. The second must be faster. If it is not, your workspace path moved.
OP 40OF 120 Give it one rulebook, not two
If the store ships a runbook — AGENTS.md, procedures, a README that
routes — the resident agent follows that, the same document an
engineer follows on a laptop. A second, lane-only instruction set drifts from the
first within a week.
store/AGENTS.md ─────────────┬────────────► the engineer's own agent
roles · procedures · layout │
└──► CLAUDE.md, generated per turn:
"Follow AGENTS.md at <path>.
You are <identity>.
Your surfaces are <…>."
Two consequences: the runbook's directory must be in the agent's working directories, or its links cannot be opened; and the commands it demands must be permitted, because a hosted lane has nobody to answer a permission prompt. Keep the runtime's own toolset a deployment choice — an allow list and a deny list, both empty by default — instead of a constant in code. An allow list grants permission; it does not remove what the CLI ships.
Ask the agent, in chat, to summarize its role from the runbook. It should quote the file, not paraphrase a prompt you wrote.
OP 50OF 120 Publish the app's own MCP surface, and make it grantable
File tools let the agent read the store. They do not let it ask the app a question — search the index, resolve a reference, validate, commit through the guarded lane. That is your app's own MCP surface, reached as the signed-in user. Four declarations must agree.
# the surface (entrypoint) @mcp(alias="ops", route="public", transport="streamable-http", auth_config="surfaces.as_provider.mcp.ops.auth") # the auth block surfaces.as_provider.mcp.ops.auth: mode: managed authority_id: delegated_client selected_tool_grants: true # the connection, on the agent - kind: mcp server_id: ops delegated: true self_hosted: true # this deployment serves it — dial it locally url: "https://<public host>/…/public/mcp/ops" resource: "*/…/public/mcp/ops*" scopes: [<app>:read, <app>:write, <app>:commit] allowed: [search, get, save, commit]
Read the fourth declaration twice. An app offers endpoints; the
Connection Hub catalog is what the deployment allows, and it is always a
subset. An endpoint that is not in the catalog is not grantable — the hub answers
delegated_access_grants_not_delegable — and declaring the endpoint
without its claim under capabilities fails the same way, silently, from
the other half.
Name the consequence. Claims read as
<app>:read, :write, :delete,
:commit — never after the transport. The person approving the card is
deciding what may happen to their work, not which URL is called.
Revoke the grant in Connection Hub and ask the agent to use the surface. Expect a consent card naming your claims — and the same turn's retry to succeed after you approve it.
For a CLI runtime, consent pending must still become part of the CLI's own session. Raise the KDCube access card, but keep running Claude Code with that MCP server or tool recorded as unavailable. If the first turn stops before the CLI sees the request, the next "try again" resumes an empty native transcript even though the platform conversation shows the earlier prompt.
OP 60OF 120 Make the CLI actually see its tools
Writing .mcp.json is necessary and not sufficient. Four conditions,
each of which fails quietly:
✔ name the config on the command line --mcp-config <ws>/.mcp.json --strict-mcp-config a project config the CLI discovers by itself is approval-scoped, and a hosted lane has nobody to approve it ✔ record the workspace as trusted .claude/.claude.json → until trusted, the CLI ignores EVERY projects["<abs workspace>"] permissions.allow entry, MCP included .hasTrustDialogAccepted = true ✔ avoid reserved server names `workspace` is taken; the platform's own local server is `turn_workspace` ✔ answer JSON, not an event stream a request/response call needs no stream, and a stream is a shape every hop can break
The last one earns its place with a story. A tool call went out, the app served it in one second — 200, seventeen kilobytes — and the agent waited two minutes and reported the response as lost. Every byte had arrived; a tunnel's HTTP/2 edge closed the stream badly, and a strict client threw the whole response away. The server log said success the entire time.
The same story has a second lesson: when the surface belongs to the deployment
that is calling it, say so with self_hosted: true. The declared public
URL stays — grants and catalog patterns are written against it, host-wildcarded —
and the call is dialed on the runtime's own loopback. No tunnel, no load balancer,
no TLS termination between an app and itself. It is the correct route on a laptop
and in the cloud alike, because the agent's subprocess and the surface it calls are
the same task.
From a shell inside the runtime, call one tool with the agent's own bearer. Require two things: HTTP 200, and a clean connection close. A body that arrives on a broken stream is a failed call.
OP 70OF 120 Pull evidence, checkout an editable copy, publish the result
A message carries more than text: uploads, pinned objects, references from other apps. Do not write any of it into the workspace up front. Most turns never open it, and a binary copied per turn is paid for per turn.
A CLI runtime has one door for tools, so the shared Agent Harness Workspace arrives as a local stdio MCP server spawned beside the agent. The model sees one small lifecycle:
message ──► prompt carries object_ref: conv:fi:…/brief.pdf │ ├── pull(refs=[object_ref]) │ read-only, source-scoped relative path │ Claude opens it with Read / Grep / Bash │ ├── checkout(items=[{ │ "from": object_ref, │ "to": "files/pdf-review/working.pdf", │ "strategy": "replace" │ }]) │ editable current-turn copy │ repeat replace = reset from durable source │ └── publish(paths=["files/pdf-review/working.pdf"]) trusted host validates + stores the file Files card + new conv:fi ref
An event_ref such as conv:ev:... identifies where an
event occurred; it is readable context, not bytes. A materializable event
separately carries an object_ref, and that is what the agent pulls or
checks out. Pull preserves source identity in a collision-safe read-only path, so
two source.pdf objects do not overwrite each other. Checkout resolves
the durable source directly; no prior pull is required.
Identity for the workspace server is bound by the trusted parent and never
accepted as a model-supplied argument. A workspace tool request carries refs,
operations, and relative paths, not user identity or resolver/conversation-hosting
credentials. This is separate from the Claude subprocess’s broader trusted-runtime
boundary: its Bash tool can still read inherited environment and use processor
network access. Publishing is explicit: a local edit is not delivered until
publish succeeds. The shared gate admits at most 50 files, 100 MiB per
file, and 250 MiB per request across supported text, document, image, audio, video,
and archive types. The app may only narrow those limits or deny a concrete
request.
Keep this server out of capability narrowing. Namespaces come and go with an administrator's inventory, a user's pick, or a lapsed grant, but none of that may take away an agent's ability to open a file its own conversation carries.
Attach a file and ask for an annotated derivative. The steps should show pull or direct checkout, ordinary file work, then publish. The original ref must remain unchanged; a second replace checkout must reset the editable copy. The response's Files tab must show the derivative and its new durable ref.
OP 80OF 120 Show the workings, keep the answer clean
A CLI runtime reports every tool result back into its own conversation as a
user event. A generic text extractor takes it, and a file the agent read
arrives in chat as the agent's answer, line numbers and all.
Four rules make that readable. Tool traffic is never answer text. Each call gets its own step key, or the list becomes one row rewriting itself. The row's body travels as the step's markdown, or there is nothing to expand. And surface the waiting: the CLI heartbeats a pending call, so put the elapsed seconds on the row — a call that will never return looks exactly like a slow one until you do.
Ask for something that needs a tool, and watch a row complete — not merely start.
OP 90OF 120 Configure the chat, and let the person interrupt
The chat tile is inherited — the app declares that it hosts one, and binds the tile to its agent so the picker writes the inventory the turn actually reads. Two more declarations decide what the composer offers.
capabilities:
conversation:
accepts_steer: true # the stop control
accepts_followup: true # sending while a turn runs
models: [...] # the list; one pick per conversation
Both default to off, and the buttons are simply absent until the app says the lane can honour them. That is the intended order — a control the agent cannot answer is worse than no control — but it is also the first thing to check when a stop button you expected is not there.
Stop is not a kill. The run is reached just before its next tool call and told to wrap up; it answers with what it has, and nothing is lost. The cost of that design is honest: a run sitting inside one long call cannot be reached until that call returns.
A message sent while the agent works arrives the same way. If it cannot be delivered — the run was between tools, or already writing its answer — it is not dropped: it waits, and the next turn reads it together with anything else queued. One turn for everything the person said, not one turn per message. And after a stop, only what was said after it starts a new turn; earlier messages stay pending and are read as context. Pressing stop should save a turn, not buy one.
One placement rule decides whether any of this works. The per-turn control files belong outside the session store's checkout: that directory is emptied and restored from the previous turn at the start of every run (OP 30), so anything seeded inside it comes back as last turn's copy — including a stop, which then refuses the tool calls of every later turn. From inside the run that is indistinguishable from a permissions policy, and a good agent will politely ask about it instead of routing around it.
Graph lanes use the same capability declarations, but they do not have to offer
the same pair. The worked LangGraph app advertises
accepts_steer: true and accepts_followup: false: its stop
cancels at an await point instead of a tool boundary, and before the next model
request it repairs every checkpointed tool call that never received a result.
Press stop mid-run — the agent should finish its sentence and report what it had done. Then send a plain message. The turn after a stop must run normally; if its tool calls come back refused, the control files are inside the checkout.
OP 100OF 120 Do not lose the platform's turn
async def pre_run_hook(self, *, state, econ_ctx: dict | None = None): await super().pre_run_hook(state=state, econ_ctx=econ_ctx or {}) # recording await self._ensure_store(reason="pre_run_hook") # then your work
The base hook starts the turn's event recording, and that recording is what a reopened conversation is rebuilt from. The failure is silent: cost and elapsed time appear live and vanish on reload. Two siblings of the same class: objects the message carried must be recorded as their own events, so the reopened chip is live rather than prose about a chip; and the conversation title needs a role that resolves to a model — declare it, or every conversation lists as "Untitled" and nothing raises. Give that one small call room for its whole answer, too: a title is written after a short thinking pass, so a budget that fits only the thinking produces no title and no error.
Reopen the conversation. Answer, activity rows, cost, elapsed time, title, and any attached object must all still be there. Live and reloaded are different code paths; only the second proves the turn was recorded.
OP 110OF 120 Put a desk beside the chat
A coding agent is good at changing files and poor at showing you thirty of them. Pair the conversation with a small app UI over the same working tree.
┌──────────── app scene ─────────────┐
│ desk │ chat │
│ ▣ asset.png NEW │ ▸ Bash · … │
│ ¶ notes.md EDITED │ ▸ Read · … │
│ ⚙ meta.yaml │ the answer │
│ [↻ Reload][⇪ Upload]│ │
│ uncommitted (3) │ │
│ [note][Commit&Push] │ │
└──────────────────────┴─────────────┘
both read the SAME working tree
What live use taught: the panel is a reading of a tree other hands also write, so it needs an explicit reload and should say what it shows and when it read it. The file list is the operator's index — let them resize it, one line per file, and keep state marks smaller than the names they annotate. Destructive controls confirm and say what breaks. A structured file needs a structured editor: indent on Tab, keep the level on Enter, line numbers, no soft wrap. And rows should be draggable into chat, because what travels is a ref — exactly what OP 70's pull consumes.
Ask the agent to change a file; the desk shows it as uncommitted after a reload,
and committing from the desk shows up in git log from a shell.
OP 120OF 120 Let the agent keep its own toolchain
If the store's procedures run scripts, they need an interpreter. The app names the path and whether it exists. The agent reads the requirements, inspects the environment, and builds or refreshes it on demand, in the conversation, with its own shell. An app that rebuilds in the background pays on turns that never touch the tooling and hides failure in a log nobody reads.
On a fresh machine, ask the agent to prepare the toolchain. It should build it in the machine-local cache and say where — never inside the package.
Verify the whole thing
- Ask the agent to list its tools: the app's granted tools, workspace pull, checkout and publish, and the built-ins your deployment allows.
- Ask something that needs the app's surface; the row completes.
- Revoke the grant; a consent card appears and approving it unblocks the retry.
- Attach a file; the agent pulls it before answering.
- Ask for a derivative; the agent checks out an editable copy, publishes only that result, and the Files tab carries a new
conv:fi:ref. - Repeat replace checkout after a bad edit; the source bytes reset the target.
- Try an unsupported or oversized output; the trusted publication gate rejects it before conversation hosting.
- Change a file from chat, see it on the desk, commit it, read it in
git log. - Reopen the conversation: everything is still there.
- Press stop mid-run: the agent wraps up — and the NEXT turn runs normally.
- Send a message while a turn works: it reaches the run, or waits and is read by the next turn together with anything else queued. Never dropped.
- Two turns in one conversation: the second reaches first token faster.
- One tool call over the wire with the agent's bearer: 200 and a clean close.
- A teammate with a browser and no checkout changes files in the store, sees the change, and commits it — as themselves.
- An engineer with a checkout and their own CLI agent works the same files, the same runbook, the same history.
- Every capability the agent has is one that person granted, per agent, revocable, and named after its consequence.
- The workings are visible, the answer is clean, and a reopened conversation shows all of it.
- Nothing generated lives in the store, and the toolchain is the agent's to maintain.