kody-celld
A self-hosted Kody core that runs on Deno celld — the Cloudflare Workers + Durable Objects programming model on your own machines, with an S3-compatible bucket for durability.
It is a standalone reimplementation of kentcdodds/kody for your own hardware: the same MCP contract (search + execute, packages, secrets, jobs) plus every surface production Kody gets from Cloudflare, provided here as a self-hosted built-in, an adapter for a service you already run, or both — see the provision matrix for how each piece is provided.
Self-host it (Docker, five minutes)
What you need
| Requirement | Notes |
|---|---|
| Docker | 24+ with the docker compose plugin, on anything that runs Compose files: Synology Container Manager, QNAP Container Station, Unraid, Portainer, Docker Desktop, plain Linux. |
| CPU / arch | linux/amd64 or linux/arm64 (Raspberry Pi 4/5 64-bit, Apple-silicon Docker Desktop). 1 core is enough; 32-bit ARM is not supported. |
| RAM | ~60 MB idle; ~600 MB measured with a dozen heavy execute runs in flight (each run is a V8 isolate). Plan 1 GB for Kody alone; the optional Ollama overlay wants several GB on top. |
| Disk | ~550 MB image + your data (SQLite files in one volume; a fresh install is 2 MB). Uploaded blobs live in the same volume. |
| Network | One TCP port (default 8080). Outbound HTTPS for execute code that calls APIs, npm imports and package installs. No Cloudflare account, no domain required for LAN/Tailscale use. |
| Time | Pulling the prebuilt image: about a minute. Building from source instead: ~2 min on a laptop, 10+ min on a small NAS. |
Installation
1. Start it — save this as compose.yaml in a folder (or paste it into your NAS's Compose/"stack" UI) and run docker compose up -d:
services:
kody:
image: ghcr.io/kentcdodds/kody-celld:latest
init: true
restart: unless-stopped
ports:
- '8080:8080'
environment:
# The exact address you will type into the browser / give MCP clients.
# Sign-in cookies and OAuth redirects are bound to it, so it must match:
# trying it on this machine -> http://localhost:8080 (as below)
# NAS / VPS on your LAN -> http://192.168.1.20:8080 or http://nas.local:8080
# behind a reverse proxy -> https://kody.example.com
KODY_PUBLIC_URL: http://localhost:8080
volumes:
- kody-data:/data
volumes:
kody-data:The commands below use http://localhost:8080; if you set a different KODY_PUBLIC_URL, use that instead (localhost only works from the machine running Docker).
Prefer to build from source (or want the AI / browser / mail / fleet overlays)?git clone https://github.com/kentcdodds/kody-celld && cd kody-celld && docker compose up -d
uses the same image name and builds it locally when it is not present.
Check it and read your admin token
curl http://localhost:8080/health # {"ok":true, ...}
docker compose exec kody cat /data/kody.env # KODY_ADMIN_TOKEN + KODY_MASTER_KEY — back this upCreate your account
Open http://localhost:8080/ in a browser. The Set up Kody page asks for the admin token, your email and a password and signs you in to /account. (Created the first user with the admin API instead? /setup disappears once any user exists; sign in on /signin with the kc_… API token it returned, then set a password on /account.)
Connect an MCP client
claude mcp add --transport http kody http://localhost:8080/mcpCursor / VS Code / Claude Desktop: add an HTTP MCP server with that URL. Clients that only take a URL + header use a static token from /account/tokens:Authorization: Bearer kc_…. Then ask it to "search Kody" — you should get the capability catalog back.
Where things live and how to keep them
| Task | How |
|---|---|
| Data | Everything (users, packages, secrets, jobs, blobs, kody.env) is in the kody-data volume. Nothing is written elsewhere; deleting the container is safe, docker compose down -v is not. |
| Back up | docker compose stop && docker run --rm -v <project>_kody-data:/data -v "$PWD":/backup alpine tar czf /backup/kody-data.tgz -C / data && docker compose start — restore by untarring into the volume. |
| Upgrade | docker compose pull && docker compose up -d (from a source checkout: git pull && docker compose up -d --build). State and generated keys are kept. |
| TLS | Put your reverse proxy (Synology Reverse Proxy, Nginx Proxy Manager, Caddy, Traefik) in front of 8080 with a certificate and set KODY_PUBLIC_URL=https://kody.example.com. Do not expose 8080. |
| Logs | docker compose logs -f kody |
| Failover | Two or more nodes sharing an S3-compatible bucket (MinIO bundled): compose.fleet.yaml — see the guide. |
The full walkthrough — NAS specifics, LAN/Tailscale vs. internet exposure, inviting people, approving secret hosts, every overlay (local AI, headless browser, SMTP, self-hosted npm CDN), the fleet path, and troubleshooting — is docs/getting-started.md.
Run locally (no Docker)
Prerequisites: Node ≥ 24.3, celld ≥ 0.6 on your PATH (install), and npm install (provides eesbuild, which celld uses to bundle the Worker).
npm install
npm run dev # celld dev . --port 8787 (state persists in .celld/dev)In another terminal:
npm run smoke # mcp, packages, secrets, jobs, limits, memory, blobs, browser, webhooks, email, integrations, secret-providers, oauth-server, web, npm, install, community against http://127.0.0.1:8787
npm run smoke:cron # same, plus waits (~60s) for celld's real cron trigger to run a jobThe dev config ships placeholder KODY_ADMIN_TOKEN / KODY_MASTER_KEY values in wrangler.jsonc. They only work against loopback: the Worker refuses to serve non-loopback requests while those placeholders are in effect. Put real values in a .dev.vars file (git-ignored) if you want to expose a dev node.
celld devrebuilds on any project file change, including edits undersmoke/; pass--watch-ignore 'smoke/**'if you edit tests while a smoke run is in flight.
Bootstrap a user and talk MCP
ADMIN=dev-admin-token
BASE=http://127.0.0.1:8787
# 1. create a user + API token (admin only)
curl -s -X POST $BASE/admin/users -H "authorization: Bearer $ADMIN" \
-H 'content-type: application/json' -d '{"email":"you@example.com"}'
# -> { "user": {...}, "token": "kc_..." }
# 2. point any MCP client at $BASE/mcp. OAuth-capable hosts (Claude Code, Cursor,
# VS Code, …) need only the URL: they get sent to the built-in sign-in +
# consent page (docs/mcp-oauth.md). Others take `Authorization: Bearer kc_...`.
# Or call the tools by hand:
curl -s $BASE/mcp -H "authorization: Bearer kc_..." -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"secrets"}}}'execute runs an ES module with a default export, exactly like Kody:
import { kody } from 'kody:runtime'
export default async function main(params) {
const me = await kody.whoami()
return { me, sum: params.a + params.b }
}search returns the capability catalog (kody.<capability>(args) inside execute), guides, and saved packages. Everything a client can do goes through those two tools; there is no other MCP surface.
Packages
A package is a file map with package.json, a non-empty README.md and AGENTS.md, JS modules, and optional kody.jobs. Save it with kody.packageSave({ files }) (or POST /api/call/packageSave), then either run an export (packageRun, host-side) or import it from any run:
import increment from 'kody:@kody-smoke/counter/increment'
export default async () => increment({ by: 2 })Inside package code import { packageStorage } from 'kody:runtime' gives that package a private SQLite-backed Durable Object with get/set/delete/list/clear and raw sql(). Provenance is stamped at graph-build time, so a package's storage stays its own even when ad hoc code imports it. Ad hoc code has no scratch storage (same rule as Kody).
Two example packages live in examples/packages and are what the smoke suite installs. See docs/packages.md.
Secrets
import { kody } from 'kody:runtime'
await kody.secretSave({ name: 'github', value: '...' }) // value is never echoed back
await fetch('https://api.github.com/user', {
headers: { authorization: 'Bearer {{secret:github}}' },
})Placeholders are replaced only at the network boundary (the FetchGateway Worker Entrypoint that every isolate's outbound fetch is routed through), and only when:
- the destination host is on the user's approved list (
POST /admin/users/:id/secret-hosts), - the request is HTTPS (loopback hosts listed in
KODY_ALLOW_INSECURE_SECRET_HOSTSexcepted), and - every referenced secret exists.
Otherwise the request is denied before leaving the box and the run sees a 403/404 Response with a machine-readable reason. Sandbox code cannot approve hosts, read values, or reach /admin. Values are AES-GCM encrypted with a per-user HKDF key derived from KODY_MASTER_KEY; run history records secret names only. See docs/secrets.md.
Two more placeholder kinds ride the same gateway:
import { kody } from 'kody:runtime'
// OAuth: bring your own app, connect once in the browser, tokens refresh host-side
await kody.integrationSave({
name: 'github',
authorizeUrl,
tokenUrl,
clientId,
clientSecret,
scopes: ['repo'],
allowedHosts: ['api.github.com'],
})
const { url } = aw...Jobs
package.json#kody.jobs is validated on packageSave (src/packages/manifest.ts, src/jobs/schedule.ts) and reconciled into UserCell.jobs. celld's cron trigger calls scheduled() every minute → dispatchDueJobs iterates users, asks each UserCell to atomically claim due jobs (advancing next_run_at before running so a crash cannot double-fire), then executes the entry via executeRun with kind: 'job' and records a job_run.
Limits, quotas and audit
AI, memories and semantic search
Blobs and browser rendering
Run a fleet
Two or more nodes sharing an S3-compatible bucket (MinIO bundled): compose.fleet.yaml — see the guide.
Development
npm install
npm run dev # celld dev . --port 8787 (state persists in .celld/dev)License
FSL-1.1-ALv2 (same as kody).