Reports a Claude Code Session to its kndl: its Turns, tool calls and subagents.

<img src="brand/lockup-light.svg" alt="kndl" width="180">
Multiplayer agentic sessions.
[!NOTE] Issues and pull requests are open to maintainers only for now, while we set the baseline kndl grows from. Please use Discussions for bugs, ideas and questions in the meantime.
kndl (said "kindle") is a self-hosted Hub where a team runs Claude Code Sessions in Sandboxes on any number of Runners, and watches them together from the web. Each Session is driven by the person who started it.
Documentation: <https://kddige.github.io/kndl/>
kndl is early: v1 is being built, and things change quickly.
Terms are defined in GLOSSARY.md, and design decisions are in docs/adr/.
To install a Hub, a Runner or both as services, on macOS or Linux with git:
curl -fsSL https://raw.githubusercontent.com/kddige/kndl/main/install.sh | sh
It installs into ~/.kndl. Then run kndl onboard to set up a Hub, a Runner, or both. See Install kndl. To get only the kndl command from a Hub, see Install the CLI from a Hub.
To run it from this checkout on one machine, without GitHub, you need Bun 1.4 or newer, git, a Claude subscription for a claude setup-token token, which you paste on your Account page, and a Mac with Apple Silicon or Linux with KVM.
bun install
bun run web:build
HUB_PUBLIC_URL=http://127.0.0.1:7777 KNDL_DEV_LOGIN=1 bun run hub
In a second terminal:
bun run runner-token "$(hostname -s)" # prints a Runner token, chr_…
RUNNER_TOKEN=chr_… bun run runner
Open <http://127.0.0.1:7777>, sign in with dev login, give a Repo its Environment, and start a Session on it. The tutorial walks through it.
One Bun workspace in TypeScript, with Effect.
| Package | What it is |
|---|---|
packages/protocol | The messages the Hub, Runners, the mod and the web UI exchange, as Effect Schemas. |
packages/hub | The Hub: an Effect HTTP server on Bun, with SQLite. |
packages/runner | A Runner: Claude Code in tmux in a microsandbox Sandbox, and the mod relay to the Hub. |
packages/cli | The kndl command: installs, runs and looks after a Hub and Runner as services. |
packages/mod | The Claude Code mod each Session loads, as two plugins: hub-link reports to the Hub, hub-link-commands runs its commands. |
packages/web | The web UI: React on Vite, Base UI and StyleX. |
packages/docs | The documentation site: Astro Starlight. |
brand/ | The logo's sources. bun run --filter @kndl/docs brand makes the PNG and ICO files from them. |
From the repo root:
| Command | What it does |
|---|---|
bun test | Runs every package's tests. |
bun run typecheck | Typechecks every package. |
bun run lint | Lints with oxlint. |
bun run format:check | Checks formatting with oxfmt; bun run format applies it. |
bun run web | The web UI's Vite dev server on <http://localhost:5173>, beside bun run hub. |
bun run mod:check | Builds the mod and validates its plugins with Claude Code. |
bun run docs:dev | The documentation site on <http://localhost:4321/kndl/>. |
bun run docs:build | Builds the site into packages/docs/dist. |
See Develop kndl and the commands reference.
kndl is an independent project, not affiliated with, endorsed by or sponsored by Anthropic. Claude and Claude Code are Anthropic's trademarks; kndl runs Claude Code as Anthropic publishes it, on your own Claude account.
kndl is released under the MIT License. The skills vendored in .agents/skills are MIT-licensed by their author; see THIRD_PARTY_NOTICES.md.
hooks/register.ts 385 lines1// hub-link: the plugin that reports a Session's Claude Code to its Hub. A Runner loads it, with
2// hub-link-commands beside it, into each Session's Claude Code, which runs in the Session's
3// Sandbox (ADR 0013).
4//
5// - The Hub: says hello and reports the Turns, their tool calls, the prompts steered into them
6// (#195) and the subagents (ADR 0014). It
7// runs none of the Hub's commands: a plugin's hooks don't see the work it causes, such as an
8// agent it resumes, so hub-link-commands runs them, and this plugin sees what follows.
9// - Tools: Claude Code runs them, in the Sandbox, without asking (ADR 0004).
10// - The model: the system prompt says the Session is shared, and a prompt from the Hub with files
11// says, out of sight, to open them (#276).
12//
13// The rest of the plugin, with Effect and the protocol, is bundled from the mod package's src/
14// into hooks/hub-link-lib.js (`bun run build`): Claude Code's mod runtime loads no package but its
15// own (spike #4).
16
17import type { EngineInterface, On } from "claude-code"
18import {
19 agentWorktree,
20 backgroundedByStop,
21 clip,
22 HubLink,
23 resultSummary,
24 sharedSessionNote,
25 startsDelivered,
26 taskNotificationAgent,
27 toolArguments,
28 turnEndReason,
29 turnUsage,
30} from "./hub-link-lib.js"
31import type { ToolCallOutcome } from "./hub-link-lib.js"
32
33const plugin = "hub-link"
34
35// The environment variables the Runner sets (the Runner's ClaudeLaunch.ts) are read by literal
36// name: the loader lists a module's variables from its `$.env.get` calls.
37
38/** The module's own state; a reload starts it over, and fires `session.start` again. */
39const state = {
40 link: undefined as HubLink | undefined,
41 /**
42 * What happened before the first link was made, to be posted on it: a woken Session's Claude
43 * Code can start a Turn with the prompt hub-link-commands was delivered while this one still
44 * reads what its hello says. Undefined once there is a link, or none will be.
45 */
46 early: [] as Array<Record<string, unknown>> | undefined,
47 /** The Turn running on the main loop. */
48 mainTurn: undefined as string | undefined,
49 /** The run each subagent's loop is in, from its steps; tool calls carry only the agent. */
50 agentTurns: new Map<string, string>(),
51 /** The directory each running agent started in, from SubagentStart. */
52 startedIn: new Map<string, string>(),
53 /** The agents reported Stopped since they last ran, so a stop is reported once. */
54 stopped: new Set<string>(),
55 /** The last prompt from the Hub a Turn started with, so no other Turn claims it. */
56 claimed: undefined as string | undefined,
57 /** The prompts steered into the main loop's Turn that no step has read yet, by id. */
58 unread: new Map<string, string>(),
59 minted: 0,
60}
61
62const report = (event: Record<string, unknown>) => {
63 if (state.link !== undefined) state.link.report(event)
64 else state.early?.push(event)
65}
66
67/** The agent is running, in its worktree if it has one. */
68const reportRunning = (agentId: string, cwd: string | undefined) => {
69 state.stopped.delete(agentId)
70 const worktree = cwd === undefined ? undefined : agentWorktree(cwd)
71 report({
72 _tag: "AgentStatusChanged",
73 agentId,
74 status: "Running",
75 ...(worktree === undefined ? {} : { worktree }),
76 })
77}
78
79/** The agent was stopped, by TaskStop or with the Turn it ran in. */
80const reportStopped = (agentId: string) => {
81 state.startedIn.delete(agentId)
82 if (state.stopped.has(agentId)) return
83 state.stopped.add(agentId)
84 report({ _tag: "AgentStatusChanged", agentId, status: "Stopped" })
85}
86
87/** The id of the task a TaskStop stopped; a shell's id the Hub ignores, as no agent's. */
88const stoppedTask = (result: unknown): string | undefined =>
89 typeof result === "object" &&
90 result !== null &&
91 "task_id" in result &&
92 typeof result.task_id === "string"
93 ? result.task_id
94 : undefined
95
96const startLink = async ($: EngineInterface) => {
97 const [hubUrl, sessionId, modToken, kind, launched] = await Promise.all([
98 $.env.get("KNDL_URL"),
99 $.env.get("KNDL_SESSION_ID"),
100 $.env.get("KNDL_MOD_TOKEN"),
101 $.env.get("CLAUDE_CODE_SESSION_KIND"),
102 $.env.get("KNDL_CONVERSATION_ID"),
103 ])
104 if (!hubUrl || !sessionId || !modToken) {
105 state.early = undefined
106 $.ui.log("hub-link: not started by a Runner, so not linked to a Hub", { to: "debug" })
107 return
108 }
109 // A background job or daemon of Claude Code's inherits the Session's mod token, but isn't the
110 // Session's Claude Code: linked too, it would run each prompt again (#191).
111 if (kind) {
112 state.early = undefined
113 $.ui.log(`hub-link: a Claude Code ${kind} process, so not linked to the Hub`, { to: "debug" })
114 return
115 }
116 // A `claude` run inside the Session, by Claude Code's own tools or in a terminal, inherits its
117 // mod token too, but is another conversation: linked, it would become the one the Session
118 // resumes when it wakes, and the Session's own would be lost (#321). A Runner from before
119 // KNDL_CONVERSATION_ID doesn't say which it launched.
120 const conversation = await $.session.id()
121 if (launched && conversation !== launched) {
122 state.early = undefined
123 $.ui.log(
124 `hub-link: conversation ${conversation} isn't the one the Runner launched, so not linked to the Hub`,
125 { to: "debug" },
126 )
127 return
128 }
129 state.link?.stop()
130 state.link = new HubLink({
131 hubUrl,
132 modToken,
133 hello: { sessionId, claudeSessionId: conversation, model: await $.session.model() },
134 fetch: (url, init) => $.http.fetch(url, init),
135 // Its own timers, so a Stop can't cut what it does short (#165).
136 after: (ms, fn) => void $.clock.after(ms, fn),
137 // hub-link-commands draws the link in the band; this one only reports.
138 onStatus: () => undefined,
139 log: (text) => $.ui.log(text, { to: "debug" }),
140 })
141 state.link.start()
142 for (const event of state.early ?? []) state.link.report(event)
143 state.early = undefined
144}
145
146export function register(on: On) {
147 on("session.start", async ($, e, next) => {
148 const started = await next(e)
149 await startLink($).catch((error) => {
150 state.early = undefined
151 $.ui.log(`hub-link: ${error}`, { to: "debug" })
152 })
153 return started
154 })
155
156 on("session.end", async ($, e, next) => {
157 const ended = await next(e)
158 await state.link?.flushNow()
159 return ended
160 })
161
162 // Every tool call is reported to the Hub; Claude Code runs it, in the Sandbox.
163 on("tool.call", async ($, e, next) => {
164 const tool = String(e.tool)
165 const agentId = e.agentId
166 // hub-link-commands' TaskStop, for a Participant's Stop, is no tool call of the Turn running.
167 const turnId =
168 next.origin.plugin === "hub-link-commands"
169 ? undefined
170 : agentId === undefined
171 ? state.mainTurn
172 : state.agentTurns.get(agentId)
173 const toolCallId = e.tool_use_id || `hub-link-${++state.minted}`
174 const loop = agentId === undefined ? {} : { agentId }
175 const ended = (outcome: ToolCallOutcome, output: string) => {
176 if (turnId !== undefined)
177 report({ _tag: "ToolCallEnded", turnId, ...loop, toolCallId, tool, outcome, output })
178 }
179 if (turnId !== undefined) {
180 const input = toolArguments(e as unknown as Record<string, unknown>)
181 report({
182 _tag: "ToolCallStarted",
183 turnId,
184 ...loop,
185 toolCallId,
186 tool,
187 input: clip(JSON.stringify(input)),
188 })
189 }
190 const done = await next(e)
191 if (done.deny !== undefined) ended("Denied", clip(done.deny))
192 // It didn't end: a Stop moved it to the background, so it stays without an outcome, and the
193 // web shows it cut short once its Turn ends. Its own end comes in a task-notification Turn.
194 else if (backgroundedByStop(done.result)) return done
195 else ended(done.isError ? "Failed" : "Succeeded", resultSummary(tool, done.result))
196 // A stopped background agent's run ends with no hook of its own, whoever called TaskStop: the
197 // model, or hub-link-commands (#152). The result names the task, called by id or by name.
198 if (tool === "TaskStop" && done.deny === undefined && !done.isError) {
199 const stopped = stoppedTask(done.result)
200 if (stopped !== undefined) reportStopped(stopped)
201 }
202 return done
203 })
204
205 // Sessions never ask for approval (ADR 0004): Claude Code runs with its permission prompts
206 // bypassed, and a mode switched to at the terminal asks for none either. Claude's question is no
207 // approval: its dialog is where it is answered, and one allowed past it runs with no answers, at
208 // once (#206). Claude Code decides it, as ever, and opens the dialog.
209 on("tool.check", async ($, e, next) =>
210 e.tool === "AskUserQuestion" ? next(e) : { decision: "allow" as const },
211 )
212
213 // A prompt hub-link-commands submits with files (#276, ADR 0024) carries what Claude is told of
214 // them, out of sight: to open each with the attachment tool.
215 on("prompt.submit", async ($, e, next) => {
216 const { value: delivered } = await $.state.get({
217 plugin: "hub-link-commands",
218 key: "delivered",
219 })
220 // Once, for the prompt delivered: not for the same text typed again later.
221 if (
222 delivered?.context === undefined ||
223 delivered.promptId === state.claimed ||
224 e.text !== delivered.text
225 )
226 return next(e)
227 return next({ ...e, context: [...(e.context ?? []), delivered.context] })
228 })
229
230 on("turn.start", async ($, e, next) => {
231 state.mainTurn = e.turnId
232 // hub-link-commands says which prompt from the Hub it submitted last, and who sent it.
233 const { value: delivered } = await $.state.get({
234 plugin: "hub-link-commands",
235 key: "delivered",
236 })
237 const fromHub =
238 delivered !== undefined &&
239 delivered !== null &&
240 delivered.promptId !== state.claimed &&
241 startsDelivered(e.text, delivered.text)
242 ? delivered
243 : undefined
244 if (fromHub !== undefined) state.claimed = fromHub.promptId
245 const agentId = fromHub === undefined ? taskNotificationAgent(e.text) : undefined
246 report({
247 _tag: "TurnStarted",
248 turnId: e.turnId,
249 text: e.text,
250 sender:
251 fromHub !== undefined
252 ? { _tag: "FromParticipant", participant: fromHub.sender }
253 : agentId !== undefined
254 ? { _tag: "FromAgent", agentId }
255 : { _tag: "FromTerminal" },
256 ...(fromHub === undefined ? {} : { promptId: fromHub.promptId }),
257 })
258 return next(e)
259 })
260
261 // A prompt hub-link-commands steers into the running Turn (#195, ADR 0018) is a row it appends:
262 // reported as joining the Turn when it is kept. Once the Turn has ended, it is refused, so it
263 // waits in the queue rather than in the conversation for whatever prompt comes next.
264 on("session.append", { door: "note" }, async ($, e, next) => {
265 if (e.agentId !== undefined) return next(e)
266 const { value: steered } = await $.state.get({ plugin: "hub-link-commands", key: "steering" })
267 const block = e.message.content[0]
268 if (
269 steered === undefined ||
270 steered === null ||
271 block?.type !== "text" ||
272 block.text !== steered.appended
273 )
274 return next(e)
275 const turnId = state.mainTurn
276 if (turnId === undefined) return { deny: "the Turn ended before the prompt could join it" }
277 const appended = await next(e)
278 if (appended.deny !== undefined) return appended
279 state.unread.set(steered.promptId, turnId)
280 report({
281 _tag: "PromptSteered",
282 turnId,
283 promptId: steered.promptId,
284 text: steered.text,
285 sender: steered.sender,
286 ...(steered.attachments === undefined ? {} : { attachments: steered.attachments }),
287 })
288 return appended
289 })
290
291 on("turn.step", async function* ($, e, next) {
292 if (e.agentId !== undefined) state.agentTurns.set(e.agentId, e.turnId)
293 // The main loop's next request after a steered prompt joined carries it: Claude reads it now.
294 else
295 for (const [promptId, turnId] of state.unread) {
296 if (turnId !== e.turnId) continue
297 state.unread.delete(promptId)
298 report({ _tag: "SteeredPromptRead", turnId, promptId })
299 }
300 const loop = e.agentId === undefined ? {} : { agentId: e.agentId }
301 const stream = next(e)
302 while (true) {
303 const piece = await stream.next()
304 if (piece.done) return piece.value
305 if (piece.value.kind === "text" && piece.value.text !== "")
306 report({ _tag: "TextDelta", turnId: e.turnId, ...loop, text: piece.value.text })
307 yield piece.value
308 }
309 })
310
311 on("turn.complete", async ($, e, next) => {
312 const completed = await next(e)
313 report({
314 _tag: "TurnEnded",
315 turnId: e.turnId,
316 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
317 reason: turnEndReason(e.reason),
318 durationMs: Math.max(0, Math.round(e.durationMs)),
319 ...(e.usage === undefined ? {} : { usage: turnUsage(e.usage) }),
320 })
321 if (e.agentId === undefined) {
322 state.mainTurn = undefined
323 // Left unread, Claude Code keeps it in the conversation for the next prompt (spike #195).
324 state.unread.clear()
325 } else {
326 state.agentTurns.delete(e.agentId)
327 // A foreground agent's run ends as aborted when it is stopped, with no SubagentStop (spike
328 // #148).
329 if (e.reason === "aborted") reportStopped(e.agentId)
330 }
331 return completed
332 })
333
334 // Subagents (ADR 0014): each is reported once it has started, under the Agent call that
335 // started it; then Running at each run's SubagentStart, a resumed one's included, and Done at
336 // its SubagentStop. The Hub knows only agents reported at their spawn, so it ignores the status
337 // of Claude Code's own, such as progress summaries, which never pass `agent.spawn` (spike #148).
338 on("agent.spawn", async ($, e, next) => {
339 const spawned = await next(e)
340 const agentId = spawned.agentId
341 const turnId =
342 e.parentAgentId === undefined ? state.mainTurn : state.agentTurns.get(e.parentAgentId)
343 // Teammates (agent teams) are out of scope: they stay off unless a Repo turns them on.
344 if (agentId === undefined || e.isTeammate === true || turnId === undefined) return spawned
345 report({
346 _tag: "AgentStarted",
347 turnId,
348 agentId,
349 toolCallId: e.tool_use_id,
350 agentType: e.subagentType,
351 description: e.description,
352 model: spawned.model,
353 background: e.background,
354 })
355 // Its SubagentStart may have come first, while the spawn was under way, before the Hub knew
356 // it.
357 reportRunning(agentId, state.startedIn.get(agentId))
358 return spawned
359 })
360
361 on("classic.SubagentStart", async ($, e, next) => {
362 const done = await next(e)
363 state.startedIn.set(e.agent_id, e.cwd)
364 reportRunning(e.agent_id, e.cwd)
365 return done
366 })
367
368 on("classic.SubagentStop", async ($, e, next) => {
369 const done = await next(e)
370 state.startedIn.delete(e.agent_id)
371 report({ _tag: "AgentStatusChanged", agentId: e.agent_id, status: "Done" })
372 return done
373 })
374
375 on("prompt.compose", async ($, e, next) => {
376 const composed = await next(e)
377 return {
378 sections: [
379 ...composed.sections,
380 { id: `${plugin}:shared-session`, text: sharedSessionNote, scope: "session" },
381 ],
382 }
383 })
384}
385