What I learned reading and building with Kuru, an open-source agent platform
I've spent the last few weeks inside Kuru, an MIT-licensed, self-hosted agent platform (github.com/shoodoow/kuru). One paragraph on what it is, then three parts of the code I think are genuinely interesting, then where i
I've spent the last few weeks inside Kuru, an MIT-licensed, self-hosted agent platform (github.com/shoodoow/kuru). One paragraph on what it is, then three parts of the code I think are genuinely interesting, then where it falls short.
What it is. You run it on your own Linux box with Docker. It gives you chat agents you talk to from a phone app (Expo/React Native), each agent gets its own Linux container it can work in, they can team up, run scheduled routines, use skills and MCP tools, and they can drive a real desktop that you can watch and take over. Bring your own API keys for OpenAI, Anthropic, xAI, or any OpenAI-compatible endpoint. A Cloudflare Tunnel fronts it, so no open ports, no static IP.
It's a monorepo: apps/core (Node/Express + Postgres, the server), apps/desktop (the agent's computer image), apps/mobile (the phone app), packages/agent-tools (tool definitions). The code has an unusual property: almost every function has a comment explaining why it exists and what its inputs and outputs are, not restating what the code does. That made it unusually pleasant to review.
Now the three parts.
1. Approval cards: humans gate actions inside the chat thread
This is my favorite part of the design. When an agent wants to do something risky — rm -rf outside its own folders, dd if=, git push --force, curl | sh, dropping a database, deleting a YouTube video — the call doesn't run. Instead a card appears in the chat: "Needs approval", with the exact tool call and its arguments, and buttons to approve, deny, or "Always allow".
The mechanism is small. apps/core/src/turn/auto-review.ts (~650 lines, and roughly a third of that is tests and comment prose) does it in one hook around tool execution:
-
classifyRisk()checks the tool name and arguments against a regex list (DESTRUCTIVE_BASHfor shell, a table for social-publishing tools likeyoutube_upload_video). If nothing matches, the call just runs. No ceremony for safe actions. - If it's risky, the exact call is hashed — SHA-256 of tool name + arguments — and a row goes into a
tool_approvalstable with statuspending. Then a widget card is appended to the conversation viasaveSendMessage, and a push notification goes out. - The model gets back a blocked result telling it to wait and retry the same call with the approval id. Not to paraphrase, not to work around.
The retry-matching logic is where the real care went. Here's the hash function, quoted from auto-review.ts:
export function hashToolInput(name: string, input: Record<string, unknown>): string {
const copy: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
if (key === "requestApproval" || key === "approvalId") continue;
// Free text shown on the card, not part of the action: a model rewords it
// on retry, and the approved card then never matched (three cards, no delete).
if (key === "reason") continue;
// Stable order so the same batch delete retries match after approval.
if (key === "routineIds" && Array.isArray(value)) {
copy[key] = [...value].map(String).sort();
continue;
}
copy[key] = value;
}
return createHash("sha256").update(`${name}:${JSON.stringify(copy)}`).digest("hex");
}
That comment ("three cards, no delete") is a bug report compressed into two lines: models reword free-text justifications between retries, and if that text is part of the hash, the human approves card #1 and the retry mints card #2. So reason is stripped from the hash. This is the kind of thing you only learn by running the thing.
Two more details I'd call out:
-
Approvals are single-use. An approved row carries a
usedAttimestamp; the second identical call hits "That approval was already used. Ask the person again." An approval is consent for one action, not a grant. -
Workers pause instead of dying. There's a comment in the file explaining that a blocked background worker used to get stopped and restarted blank once the person tapped Approve — losing everything it had done. Now it pauses on the card (
APPROVAL_WAIT_MSis 30 minutes) and carries on with the same action. The old behavior is the failure mode I'd have shipped by accident.
Why it stays small: there's no policy engine, no YAML permissions DSL, no role system. It's a table, a hash, a regex list, and a card. The "who approves what" question is answered by the conversation itself — the card lands where you're already talking to the agent. For a self-hosted single-owner deployment that's the right scale. It would not scale to a team with different people needing different scopes, and the repo doesn't pretend it would.
2. Live desktop control from a phone
Every agent gets a Linux container with a real desktop (not a headless shell — X, a browser, a visible window manager). The phone app has a "watch this screen" view that streams the desktop over VNC and lets you take over the mouse and keyboard if the agent is stuck in a dialog loop.
What makes this non-trivial is that the phone connects through the same core server through a Cloudflare Tunnel, and the VNC ports of the containers are never exposed. apps/core/src/http/screen-proxy.ts is 78 lines that do all of it:
const owned = await profileOnAccount(db, accountId, profile);
if (!owned) {
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
return;
}
const session = await startDesktop(accountId, profile);
const lines = ["GET / HTTP/1.1", `Host: 127.0.0.1:${session.novncPort}`];
for (const [name, value] of Object.entries(request.headers)) {
if (value === undefined || name.toLowerCase() === "host") continue;
lines.push(`${name}: ${Array.isArray(value) ? value.join(", ") : value}`);
}
const preamble = Buffer.concat([Buffer.from(`${lines.join("\r\n")}\r\n\r\n`), head]);
It's a WebSocket upgrade intercepted at the core, authenticated (either a session cookie check against the account id, or a short-lived signed token for the mobile app), then spliced into the container's noVNC port over socat with the HTTP preamble manually rewritten. No extra network hop, no publicly reachable container port. The comment at the top says the client "never receives the container port" — that's the whole security posture in one line.
One small but smart bit: the proxy registers screenOpened() so the agent's browser doesn't get torn down for idleness while a human is literally watching the screen. Kill it while someone is looking at it and you get a support ticket that's your own fault.
The desktop tools themselves live in packages/agent-tools/src/definitions.ts: computer_screenshot, computer_mouse, computer_click, computer_type, computer_key, plus browser tools. So "from a phone" cuts both ways — the agent drives a desktop, and so can you, from anywhere, at the same address the agent works at. For debugging an agent that's wedged on a cookie banner at 1am, this is the feature.
3. MCP integration
MCP support is in apps/core/src/mcp/, and the split between core and agent is the interesting design decision. client.ts is 259 lines: a hand-written MCP HTTP client (protocol version pinned to 2025-06-18), with session handling, OAuth token refresh (mcp-oauth.ts), and pagination capped at 20 pages so a looping cursor can't hang a turn.
The comment that explains the split:
/**
* Lists the tools one MCP server offers.
* Why here and not in the account's container: the bearer token then never
* leaves the core, so an agent cannot read it and send it elsewhere. The
* request can only reach a public address.
*/
So: the core lists and calls MCP tools; the agent only ever sees a normalized tool schema in its catalog and gets results back. The agent's container has no network access to the MCP server, and no bearer token. In most agent frameworks, MCP is just "give the model a client" — which hands every key it holds to whatever the model does next. Here the trust boundary is drawn at the core, which is where it belongs.
It also means tool descriptions from MCP servers go through the same budget logic as built-in skills (catalog-budget.ts) — a model with 40 MCP servers can't have 200k tokens of tool descriptions in context. Tools get budgeted, not concatenated.
What it's bad at (honestly)
- Self-hosting is not casual. You need Docker, a domain on Cloudflare, Google OAuth set up, and if you want the mobile app you're building an Expo app yourself (EAS or local). The README says this plainly ("treat it as experimental self-hosting software"), and it's true. This is a weekend project, not a 20-minute install.
- One computer per account. Agents each get a container, but the account model assumes one person per instance. There's no multi-tenant anything, and that's by design — but if you wanted to run this for a team, you'd be rewriting the account layer.
-
The approval heuristics are regexes.
rm -rfdetection via regex will miss weird shell (variables, obfuscation,find -delete). The own-folder allowlist indeletesOnlyOwnFiles()is genuinely careful, but this is keyword-level gating, not sandboxing. The real containment is the container, not the regex — and that's the honest framing. - Pre-1.0 churn. Setup steps, APIs, and mobile UX can change between commits, per the README. If you pin a tag you're fine; if you live on main, expect breakage.
- No model keys included. BYO keys, stored encrypted per account in Postgres. Good for you, annoying for a quick demo.
Closing
The through-line in all three parts: small, boring mechanisms doing the hard part. A hash and a table instead of a permissions engine. A WebSocket splice instead of a gateway service. A pinned protocol version and a token that never leaves the core instead of an agent-side MCP SDK. I've built enough agent infrastructure to know how much code usually gets written to answer these questions, and I like that this codebase mostly doesn't.
Repo: github.com/shoodoow/kuru — MIT, issues and PRs open. If you read one file, make it apps/core/src/turn/auto-review.ts.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.