SLOPSHOPPER

hub-link

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

newguardpromptagents
★ 2v0.3.0MITupdated 2026-10-13kddige/kndl/packages/mod/plugins/hub-link
A shopper browsing a rack in a slop shop
README

<img src="brand/lockup-light.svg" alt="kndl" width="180">

kndl

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.

How it works

  • The Hub keeps the Sessions, signs people in with GitHub, and serves the web UI. It is invite-only: its Owner invites everyone else.
  • A Runner, on each machine that runs Sessions, starts each Session's Claude Code in its own Sandbox, a microVM built from its Repo's Environment, so nothing Claude runs touches the host.
  • Each Session runs on its Creator's own Claude setup token. Claude Code has it in its environment, in the Sandbox, and talks to Anthropic directly.
  • Only the Creator drives a Session: prompts it, answers it, stops it, controls it and uses its terminal. Everyone else with access to its Repo watches it live, subagents included.

Terms are defined in GLOSSARY.md, and design decisions are in docs/adr/.

Quick start

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.

Development

One Bun workspace in TypeScript, with Effect.

PackageWhat it is
packages/protocolThe messages the Hub, Runners, the mod and the web UI exchange, as Effect Schemas.
packages/hubThe Hub: an Effect HTTP server on Bun, with SQLite.
packages/runnerA Runner: Claude Code in tmux in a microsandbox Sandbox, and the mod relay to the Hub.
packages/cliThe kndl command: installs, runs and looks after a Hub and Runner as services.
packages/modThe Claude Code mod each Session loads, as two plugins: hub-link reports to the Hub, hub-link-commands runs its commands.
packages/webThe web UI: React on Vite, Base UI and StyleX.
packages/docsThe 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:

CommandWhat it does
bun testRuns every package's tests.
bun run typecheckTypechecks every package.
bun run lintLints with oxlint.
bun run format:checkChecks formatting with oxfmt; bun run format applies it.
bun run webThe web UI's Vite dev server on <http://localhost:5173>, beside bun run hub.
bun run mod:checkBuilds the mod and validates its plugins with Claude Code.
bun run docs:devThe documentation site on <http://localhost:4321/kndl/>.
bun run docs:buildBuilds the site into packages/docs/dist.

See Develop kndl and the commands reference.

Not affiliated with Anthropic

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.

License

kndl is released under the MIT License. The skills vendored in .agents/skills are MIT-licensed by their author; see THIRD_PARTY_NOTICES.md.

Source 1 files
hooks/register.ts 385 lines
1// 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