SLOPSHOPPER

let-them-talk

Adds /let-them-talk, which starts Let Them Talk and opens it in your browser; shows Claude Code's suggested next prompt in its Send box; and runs a Chat in IDE…

newcommandpromptmodelprocessnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · let-them-talk
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /let-them-talk ⎿ let-them-talk: Let Them Talk didn't start. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

let-them-talk

The Claude Code plugin of Let Them Talk, a visual board for your Claude Code sessions. It does three things:

  • /let-them-talk, typed in any chat, starts Let Them Talk with uvx let-them-talk and opens it in your browser. It needs uv. The board keeps running after that chat ends; if it already runs, the command only opens it.
  • It passes Claude Code's suggested next prompt on to the board, so the Send box of a terminal chat shows the same grey suggestion as the chat's own prompt box.
  • It runs a Chat in IDE started from the board on the model you picked in New agent.

It runs no command in your chats by itself, so nothing of its own appears in their terminals. The suggestion and the model need Let Them Talk running on port 8765, the default. It talks only to the app on your own machine, and does nothing when the app isn't running.

Install

claude plugin marketplace add abdullahkavakli/let-them-talk
claude plugin install let-them-talk@let-them-talk

Or, inside a chat: /plugin install let-them-talk --marketplace abdullahkavakli/let-them-talk.

It was called let-them-talk-suggestions before. If you installed it under that name, remove the old one: claude plugin uninstall let-them-talk-suggestions@let-them-talk.

The plugin is the mod of the latest release, the same release as the app uvx let-them-talk runs, so the two match. A change made to the mod since then reaches you with the next release.

Chats started before the plugin was installed show no suggestion and have no /let-them-talk until they are restarted.

More, including how to load it from a copy of the repository (which then follows that copy, not the release), how to stop a board started with /let-them-talk, and what to do if the app runs on another port: Configuration.

Source 1 files
hooks/register.ts 263 lines
1import type { Register } from 'claude-code'
2
3// Let Them Talk (this repo) shows each chat's suggested next prompt in its
4// Send box: exactly what this chat's own prompt box shows, or nothing.
5//
6// - When Claude Code shows its own suggestion, it is passed on.
7// - When it made none (the terminal is unfocused, or a background agent has
8//   nobody attached) and Let Them Talk has this chat open, one is made the
9//   way Claude Code makes its own ($.model.fork over the cached conversation)
10//   and shown in this chat's prompt box too, so both match.
11// - A background agent that ends its turn waiting on you is left alone:
12//   Claude Code makes that one itself and saves it, and the app reads it there.
13// Only text the box really shows is reported; otherwise "none".
14//
15// It also runs a chat on the model picked in the app's New agent, for a Chat in
16// IDE: the editor link that opens one can't name a model, so the app says
17// which when the chat's first turns start, and this names it on the chat's
18// own requests (not its subagents'). A model you then pick in the chat wins.
19//
20// It runs no command in a chat: no command line of its own shows in its terminal.
21//
22// And it adds /let-them-talk, which starts the app the README's way (uvx
23// let-them-talk) and opens it in the browser, with no Claude turn: see START.
24
25const APP = 'http://localhost:8765' // Let Them Talk's default port (LTT_PORT)
26const HEADERS = { 'Content-Type': 'application/json', 'X-Let-Them-Talk': '1' }
27const TICK_MS = 2000
28const OWN_WAIT_MS = 12000 // Claude Code makes its own within a few seconds of the turn's end
29const WINDOW_MS = 300_000 // after that the conversation may no longer be cached (server: MOD_WINDOW)
30const HELLO_EVERY_MS = 300_000 // so the app keeps counting this chat as having the mod
31const MODEL_ASKS = 3 // turns at a chat's start the app is asked for a model (it knows by the first)
32
33const PROMPT = `[Suggestion only. Do not answer this message or continue the task.]
34Predict what the user will most likely type next in this chat. Predict what they
35would type, not what they should do: they should think "I was about to type that".
36If you just asked whether to go on, answer like "yes" or "go ahead"; if you offered
37options, pick the one this user would; if a task is done and the next step is
38obvious, name it, like "commit it" or "run the tests"; after an error or a
39misunderstanding, suggest nothing. Be specific. Never suggest thanks or praise, a
40question, your own voice ("Let me..."), or anything new the user didn't ask for.
41One sentence of 2 to 12 words, in the user's language and style. If the next step
42isn't obvious, reply with a single hyphen. Reply with only the suggestion.`
43
44const SILENT = /^\W*(-|none|nothing|silence|no suggestion.*|nothing to suggest.*|done)\W*$|^[[(].*[\])]$/i
45
46/** What to tell the app about a suggestion: the text only if the box shows it. */
47export function report(text: string | undefined, isShown: boolean, made: 'claude' | 'fork') {
48  return text?.trim() && isShown ? { text, made } : { text: '', made: 'none' as const }
49}
50
51export function cleanSuggestion(text: string): string | undefined {
52  const line = text.trim().split('\n')[0].trim().replace(/^["'“”]+|["'“”]+$/g, '').trim()
53  return line && !SILENT.test(line) && line.split(/\s+/).length <= 12 ? line : undefined
54}
55
56/** The model a request of this chat names: the app's pick (a full id), until
57 * the chat's own model changes under it (you picked another in the chat), then
58 * the chat's. The chat's own spelling of the same model ([1m]) is kept. */
59export function modelFor(wanted: string | undefined, startedOn: string | undefined, now: string) {
60  if (!wanted) return { model: now, startedOn, dropped: false }
61  if (startedOn !== undefined && now !== startedOn) return { model: now, startedOn, dropped: true }
62  return { model: now.startsWith(wanted) ? now : wanted, startedOn: startedOn ?? now, dropped: false }
63}
64
65export const UV_LINK = 'https://docs.astral.sh/uv/'
66
67// Variables a chat sets for what it starts: server.py's SESSION_ENV, the same
68// list. The app is started without them, as from a terminal, so nothing it
69// starts is taken for a part of this chat.
70export const SESSION_ENV = ['CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_MESSAGING_SOCKET',
71  'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_JOB_DIR',
72  'CLAUDE_CODE_SESSION_ATTENDED', 'TRACEPARENT', 'TRACESTATE']
73
74// /let-them-talk's start, run by sh with the board's address as $1. It finds
75// uvx (on the PATH, or where uv's installer puts it) and starts
76// `uvx let-them-talk` from the home folder in a session of its own (setsid;
77// nohup where there is none, as on macOS), with no pipe or terminal of this
78// chat's, so it keeps running after the chat ends. The app opens the page
79// itself, and when it runs already it only opens the page and ends. This waits
80// up to 15 s for it to print the address (it runs) or end, then prints
81// `no-uv`, `running` or `ended <exit code>`, and below that what the app printed.
82export const START = `uvx=
83for f in "$(command -v uvx)" "$HOME/.local/bin/uvx" "$HOME/.cargo/bin/uvx"; do
84  if [ -z "$uvx" ] && [ -n "$f" ] && [ -x "$f" ]; then uvx=$f; fi
85done
86if [ -z "$uvx" ]; then echo no-uv; exit 0; fi
87dir=$(mktemp -d 2>/dev/null || mktemp -d -t let-them-talk) || exit 1
88cd "$HOME" 2>/dev/null || cd /
89unset ${SESSION_ENV.join(' ')}
90run='"$0" let-them-talk > "$1/out" 2>&1; echo $? > "$1/code"'
91if command -v setsid > /dev/null 2>&1; then
92  setsid sh -c "$run" "$uvx" "$dir" < /dev/null > /dev/null 2>&1 &
93else
94  nohup sh -c "$run" "$uvx" "$dir" < /dev/null > /dev/null 2>&1 &
95fi
96i=0
97while [ ! -s "$dir/code" ] && ! grep -q -F "$1" "$dir/out" 2> /dev/null && [ $i -lt 60 ]; do
98  sleep 0.25
99  i=$((i + 1))
100done
101if [ -s "$dir/code" ]; then echo "ended $(cat "$dir/code")"; else echo running; fi
102cat "$dir/out" 2> /dev/null
103rm -rf "$dir"
104`
105
106/** What /let-them-talk says, from what START printed and whether the app
107 * already answered at `url` before it ran. `exitCode` is for a
108 * `claude -p /let-them-talk` run from a shell. */
109export function startReport(printed: string, url: string, wasRunning: boolean): { text: string; exitCode?: number } {
110  const [status = '', ...rest] = printed.split('\n')
111  const said = rest.join('\n').trim()
112  if (status === 'no-uv') {
113    return {
114      text: `Let Them Talk starts with uv, which isn't installed. Install uv (${UV_LINK}), then run /let-them-talk again.`,
115      exitCode: 1,
116    }
117  }
118  const failed = status !== 'running' && status !== 'ended 0'
119  if (wasRunning) return { text: `Let Them Talk is already running. ${failed ? 'Open' : 'Opening'} ${url} in your browser.` }
120  if (failed) return { text: `Let Them Talk didn't start.${said ? `\n\n${said}` : ''}`, exitCode: 1 }
121  const after = 'It keeps running after this chat ends.'
122  if (status === 'running' && !said.includes(url)) {
123    return { text: `Let Them Talk is starting. The first start downloads it, which can take a minute; then it opens in your browser at ${url}. ${after}` }
124  }
125  return { text: `Let Them Talk is running at ${url} and opens in your browser. ${after}` }
126}
127
128export const register: Register = on => {
129  on('command.run', { command: 'let-them-talk' }, async $ => {
130    const url = `http://localhost:${(await $.env.get('LTT_PORT')) || '8765'}`
131    let wasRunning = false
132    try {
133      const page = await $.http.fetch(`${url}/`)
134      wasRunning = (page.headers.server ?? '').startsWith('LetThemTalk/')
135    } catch {
136      // nothing answers there
137    }
138    try {
139      const run = await $.process.run(['sh', '-c', START, 'let-them-talk', url], { timeoutMs: 30_000 })
140      return startReport(run.stdout, url, wasRunning)
141    } catch (error) {
142      return { text: `Let Them Talk didn't start: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 }
143    }
144  })
145
146  let turn = 0 // main-loop turns ended so far
147  let starts = 0 // main-loop turns started so far
148  let ownFor = -1 // the turn Claude Code's own suggestion came after
149  let pending: { turn: number; starts: number; waited: number } | undefined // a turn still without one
150  let busy = false
151  let helloFor: string | undefined
152  let sinceHello = 0
153  let unsent: string | undefined // a report the app didn't take (it was down), sent again next tick
154  let wanted: string | undefined // the model the app picked for this chat
155  let startedOn: string | undefined // the chat's own model when that took over
156  let asked = 0
157
158  // Work after a turn outlives the turn's own hooks, so a timer started here does it.
159  on('session.start', async ($, e, next) => {
160    const started = await next(e)
161    try {
162      await $.command.register({ name: 'let-them-talk', description: 'Start Let Them Talk and open it in your browser' })
163    } catch {
164      // refused: the suggestions below still run
165    }
166    // A headless run (claude -p, such as the app's own TL;DR runs) has no prompt box.
167    if (!e.isInteractive) return started
168    $.clock.every(TICK_MS, () => {
169      if (busy) return
170      busy = true
171      void (async () => {
172        try {
173          // Read each time: /clear and /resume carry on under another id.
174          const sessionId = await $.session.id()
175          sinceHello += TICK_MS
176          if (sessionId !== helloFor || sinceHello >= HELLO_EVERY_MS) {
177            await $.http.fetch(`${APP}/api/suggestion/hello`, {
178              method: 'POST', headers: HEADERS, body: JSON.stringify({ sessionId }),
179            })
180            helloFor = sessionId
181            sinceHello = 0
182          }
183          if (unsent) {
184            const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body: unsent })
185            if (sent.ok) unsent = undefined
186          }
187          if (!pending) return
188          pending.waited += TICK_MS
189          const { turn: forTurn, starts: forStarts, waited } = pending
190          if (ownFor === forTurn || waited > WINDOW_MS) { // Claude Code made its own, or too late
191            pending = undefined
192            return
193          }
194          if (waited < OWN_WAIT_MS) return
195          const wanted = await $.http.fetch(`${APP}/api/suggestion/wanted?session=${sessionId}`)
196          if (!wanted.ok || JSON.parse(wanted.text).wanted !== true) return // ask again next tick
197          pending = undefined
198          const reply = await $.model.fork({ prompt: PROMPT })
199          const text = reply.isAnswered ? cleanSuggestion(reply.text) : undefined
200          if (ownFor === forTurn || forTurn !== turn || forStarts !== starts) return // a newer turn
201          const shown = text ? await $.prompt.suggest({ text }) : undefined
202          // "none" tells the app this turn has nothing, so it stops waiting for one
203          const body = JSON.stringify({ sessionId, ...report(text, shown?.isShown === true, 'fork') })
204          const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
205          if (!sent.ok) unsent = body
206        } catch {
207          // Let Them Talk isn't running, or the fork failed: nothing to show.
208        } finally {
209          busy = false
210        }
211      })()
212    })
213    return started
214  })
215
216  on('prompt.suggest', async ($, e, next) => {
217    const shown = await next(e)
218    // Claude Code's own guess; one this mod (a plugin) proposed is already sent.
219    if (e.origin?.kind === 'plugin' || !e.text.trim()) return shown
220    ownFor = turn
221    let body: string | undefined
222    try {
223      const sessionId = await $.session.id()
224      body = JSON.stringify({ sessionId, ...report(e.text, shown.isShown, 'claude') })
225      const sent = await $.http.fetch(`${APP}/api/suggestion`, { method: 'POST', headers: HEADERS, body })
226      if (!sent.ok) unsent = body
227    } catch {
228      unsent = body // Let Them Talk isn't running: the suggestion shows here all the same
229    }
230    return shown
231  })
232
233  on('turn.start', async ($, e, next) => {
234    starts++
235    pending = undefined // the user moved on: a suggestion for the last turn is moot
236    if (!wanted && asked < MODEL_ASKS) {
237      asked++
238      try {
239        const reply = await $.http.fetch(`${APP}/api/mod/model?session=${await $.session.id()}`)
240        if (reply.ok) wanted = JSON.parse(reply.text).model || undefined
241      } catch {
242        // Let Them Talk isn't running: the chat's own model
243      }
244    }
245    return next(e)
246  })
247
248  on('turn.step', async function* ($, e, next) {
249    if (e.agentId || !wanted) return yield* next(e)
250    const pick = modelFor(wanted, startedOn, e.model)
251    startedOn = pick.startedOn
252    if (pick.dropped) wanted = undefined
253    return yield* next(pick.model === e.model ? e : { ...e, model: pick.model })
254  })
255
256  on('turn.complete', async ($, e, next) => {
257    const done = await next(e)
258    // The main loop's answers only: not subagents, interrupts or errors.
259    if (!e.agentId && !e.isAborted && e.reason === 'answer') pending = { turn: ++turn, starts, waited: 0 }
260    return done
261  })
262}
263