A /justchat mode where Claude supresses actions like writing code things through instead of writing code in favor of just chatting until told otherwise

/justchatA Claude Code mod that adds the slash command /justchat. Running the command puts Claude in chat mode: it chats with you until instructed otherwise, with full context and ability to perform file reads and access web resources.
Claude Code is heavily biased toward multimodal action: editing files, running commands, drafting plans, etc. Sometimes it's important to discuss topics without the (often sudden) diversion toward work implementation. The native /btw slash typically only lasts one response, and isn't able to see conversational context or access files.
This is implemented using official Claude Mod guidelines. As this is a recent (Oct 2026) features, it requires Claude Code version <= 2.1.287.
During development, clone this repo and install as follows. While in a Claude code terminal session:
/plugin install justchat --marketplace ~/justchat-claude-mod
Answer y to add the marketplace, then pick a scope
| Command | Does |
|---|---|
/justchat | Turns chat mode on, or off if it's already on |
/justchat <message> | Turns chat mode on and sends the message |
/justchat on | Turns chat mode on |
/justchat off | Turns chat mode off |
While chat mode is on:
k and c once the band has focus (ctrl+x tab or a click)/justchat supresses non-chat behavior in two ways:
ls, cat, grep, find without -delete or -exec, sed without -i, and read-only git. Redirects, command substitution and unknown commands are refusedWhen a call is refused, Claude is told why and asked to discuss instead or to suggest leaving chat mode
Chat mode persists through changing session IDs, which can happen in a single chat.
Run it from a clone for one session. Run ./scripts/setup.sh once after cloning.
claude --plugin-dir .
Saving a file reloads the mod in place, this can be checked with:
claude plugin validate .
claude plugin test .
hooks/register.tsx- Hooks, incl. command, tool guard, reply line, band, etc.hooks/copy.ts: Prompt instructionhooks/readonly.ts: Allowlist and the read-only shell checktypes/index.d.ts: Session state contracttests/: Tests (run by claude plugin test)Commits must use a conventional prefix, .githooks/ rejects anything else. Allowed types: feat, fix, hotfix, refactor, style, chore, cleanup, perf, test, docs, content
changelog.md is generated with git-cliff:
git cliff -o changelog.mdhooks/register.tsx 223 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { copy } from './copy'
5import { isAllowedCall } from './readonly'
6
7const isOn = atom({ plugin: 'justchat', key: 'isOn' } as const, false)
8const isAsking = atom({ plugin: 'justchat', key: 'isAsking' } as const, false)
9const hasJustLeft = atom({ plugin: 'justchat', key: 'hasJustLeft' } as const, false)
10const persistedSessionId = atom({ plugin: 'justchat', key: 'persistedSessionId' } as const, null)
11
12// Newest marker in the transcript decides the mode
13const isOnPersisted = async ($: EngineInterface) => {
14 const messages = await $.session.messages()
15
16 for (let i = messages.length - 1; i >= 0; i--) {
17 const text = messages[i]?.text ?? ''
18 const on = text.lastIndexOf(copy.persistTagOn)
19 const off = text.lastIndexOf(copy.persistTagOff)
20
21 if (on !== -1 || off !== -1) {
22 return on > off
23 }
24 }
25
26 return false
27}
28
29// Interrupted session clears into a new id, rebuilds from the conversation
30const persistSession = async ($: EngineInterface) => {
31 try {
32 const id = await $.session.id()
33
34 if ((await read($, persistedSessionId)) === id) {
35 return
36 }
37
38 const restored = await isOnPersisted($)
39 await update($, persistedSessionId, () => id)
40 await update($, isOn, () => restored)
41 await update($, isAsking, () => false)
42 await update($, hasJustLeft, () => false)
43 } catch {
44 // Keep the current state rather than drop the hook
45 }
46}
47
48const persistMode = async ($: EngineInterface, text: string) => {
49 try {
50 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
51 } catch {
52 // Mid-turn appends can be refused, the next prompt's context carries the tag anyway
53 }
54}
55
56const enter = async ($: EngineInterface) => {
57 await update($, isOn, () => true)
58 await update($, isAsking, () => false)
59 await update($, hasJustLeft, () => false)
60 await persistMode($, copy.persistMarkOn)
61}
62
63const leave = async ($: EngineInterface) => {
64 await update($, isOn, () => false)
65 await update($, isAsking, () => false)
66 await update($, hasJustLeft, () => true)
67 await persistMode($, copy.persistMarkOff)
68}
69
70export const register: Register = on => {
71 on('session.start', async ($, e, next) => {
72 await $.command.register({
73 name: 'justchat',
74 description: 'Talk things through without Claude writing code',
75 argumentHint: '[off | message]',
76 })
77 await persistSession($)
78
79 return next(e)
80 })
81
82 on('command.run', { command: 'justchat' }, async ($, e) => {
83 await persistSession($)
84 const arg = e.args.trim()
85 const wasOn = await read($, isOn)
86
87 if (arg === 'off' || (arg === '' && wasOn)) {
88 if (!wasOn) {
89 return { text: copy.alreadyOff }
90 }
91 await leave($)
92
93 return { text: copy.off }
94 }
95
96 if (arg === 'on' && wasOn) {
97 return { text: copy.alreadyOn }
98 }
99
100 await enter($)
101
102 if (arg !== '' && arg !== 'on') {
103 void $.prompt.submit({ text: arg, asUser: true })
104 }
105
106 return { text: copy.on }
107 })
108
109 on('prompt.submit', async ($, e, next) => {
110 await persistSession($)
111
112 if (await read($, hasJustLeft)) {
113 await update($, hasJustLeft, () => false)
114
115 return next({ ...e, context: [...(e.context ?? []), copy.ended] })
116 }
117
118 if (!(await read($, isOn))) {
119 return next(e)
120 }
121
122 await update($, isAsking, () => false)
123
124 return next({ ...e, context: [...(e.context ?? []), copy.instruction] })
125 })
126
127 on('tool.call', async ($, e, next) => {
128 await persistSession($)
129
130 if (!(await read($, isOn))) {
131 return next(e)
132 }
133 if (e.tool === 'Bash') {
134 return isAllowedCall(e.tool, e.command) ? next(e) : { deny: copy.denyBash }
135 }
136
137 return isAllowedCall(e.tool) ? next(e) : { deny: copy.denyTool(e.tool) }
138 }).catch(($, e, next) =>
139 next.called ? next(e) : { deny: 'justchat: its write guard failed, so the call is blocked.' },
140 )
141
142 on('turn.complete', async ($, e, next) => {
143 await persistSession($)
144 const done = await next(e)
145
146 if (e.agentId !== undefined || e.reason !== 'answer' || !(await read($, isOn))) {
147 return done
148 }
149
150 await update($, isAsking, () => true)
151
152 return { ...done, text: copy.afterReply }
153 })
154
155 on('session.compact', async ($, e, next) => {
156 const done = await next(e)
157
158 if (await read($, isOn)) {
159 await persistMode($, copy.persistMarkOn)
160 }
161
162 return done
163 })
164
165 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166 await persistSession($)
167
168 if (e.props.hasSurvey || !(await read($, isOn))) {
169 return next(e)
170 }
171
172 const { Box, Button, Text } = $.ui.resolve(e)
173 const asking = await read($, isAsking)
174
175 return (
176 <Box
177 borderStyle="round"
178 borderColor="suggestion"
179 paddingX={1}
180 justifyContent="space-between"
181 flexGrow={1}
182 >
183 <Box gap={1} flexShrink={1} overflow="hidden">
184 <Text bold color="inverseText" backgroundColor="suggestion" wrap="truncate">
185 {copy.band}
186 </Text>
187 <Text color="suggestion" wrap="truncate-end">
188 {copy.bandHint}
189 </Text>
190 </Box>
191 <Box gap={1} flexShrink={0}>
192 {asking && (
193 <Button
194 key="keep"
195 label={copy.keep}
196 hotkey="k"
197 onPress={() => update($, isAsking, () => false)}
198 />
199 )}
200 <Button
201 key="back"
202 label={copy.back}
203 hotkey="c"
204 onPress={async () => {
205 await leave($)
206 $.ui.toast(copy.off)
207 }}
208 />
209 </Box>
210 </Box>
211 )
212 })
213 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
214 await persistSession($)
215
216 if (!(await read($, isOn))) {
217 return next(e)
218 }
219
220 return next({ ...e, props: { ...e.props, modes: [copy.footerMode, ...e.props.modes] } })
221 })
222}
223hooks/copy.ts 34 lines1const PERSIST_TAG_ON = '[justchat:on]'
2const PERSIST_TAG_OFF = '[justchat:off]'
3
4export const copy = {
5 persistTagOn: PERSIST_TAG_ON,
6 persistTagOff: PERSIST_TAG_OFF,
7 persistMarkOn: `${PERSIST_TAG_ON} The user turned on chat mode.`,
8 persistMarkOff: `${PERSIST_TAG_OFF} The user turned off chat mode.`,
9 instruction: [
10 PERSIST_TAG_ON,
11 'The user turned on chat mode with /justchat. They want a conversation, not code.',
12 'Discuss, explain, compare, ask questions and give opinions. Keep answers conversational.',
13 'Do not write, edit or create files, do not run commands that change anything, unless the user explicitly asks to exit just chat mode.',
14 'Reading files and running read-only commands to inform the discussion is acceptable.',
15 'Short code snippets are fine to display only when they illustrate a point, but not to write to a file.',
16 'If the user asks you to make a change, tell them to leave chat mode first with /justchat off or the "Back to code" button.',
17 ].join(' '),
18 ended: `${PERSIST_TAG_OFF} The user left chat mode. Return to normal unrestricted behavior: writing code, editing files and running commands are once again permitted.`,
19 afterReply: '💬 Keep typing to chat, or leave with /justchat off',
20 band: ' 💬 JUST CHAT MODE ',
21 bandHint: "Claude is chatting",
22 footerMode: '💬 just chat mode',
23 keep: 'Just keep chatting',
24 back: 'Back to code',
25 on: 'Chat mode on: Claude will just talk with you, or at most examine files, and not write any code, leave with /justchat off',
26 off: 'Chat mode off: back to writing code',
27 alreadyOff: 'Just chat mode is already off',
28 alreadyOn: 'Just chat mode is already on',
29 denyTool: (tool: string) =>
30 `justchat: chat mode is on, so only reading tools run and ${tool} is blocked. Discuss instead, or ask the user to leave chat mode with /justchat off.`,
31 denyBash:
32 'justchat: chat mode is on, so only read-only shell commands run. Discuss instead, or ask the user to leave chat mode with /justchat off.',
33}
34hooks/readonly.ts 65 lines1const SAFE = new Set([
2 'ls', 'cat', 'head', 'tail', 'grep', 'rg', 'ag', 'wc', 'file', 'stat', 'pwd',
3 'which', 'type', 'echo', 'printf', 'tree', 'du', 'df', 'diff', 'sort', 'uniq',
4 'cut', 'tr', 'jq', 'basename', 'dirname', 'realpath', 'date', 'env', 'uname',
5 'whoami', 'true', 'column', 'nl', 'less', 'more', 'fd', 'find', 'sed', 'git',
6])
7
8const SAFE_GIT = new Set([
9 'status', 'log', 'diff', 'show', 'blame', 'grep', 'ls-files', 'rev-parse',
10 'describe', 'shortlog', 'reflog', 'branch', 'tag', 'remote', 'config',
11])
12
13const isSafeSegment = (segment: string) => {
14 const words = segment.trim().split(/\s+/).filter(Boolean)
15 const [cmd, sub] = words
16
17 if (cmd === undefined) {
18 return true
19 }
20 if (!SAFE.has(cmd)) {
21 return false
22 }
23 if (cmd === 'find') {
24 return !words.some(w => /^-(delete|exec|execdir|ok|okdir|fprint|fls)/.test(w))
25 }
26 if (cmd === 'sed') {
27 return !words.some(w => /^-[a-zA-Z]*i/.test(w) || w === '--in-place')
28 }
29 if (cmd === 'git') {
30 if (sub === undefined || !SAFE_GIT.has(sub)) {
31 return false
32 }
33 if (sub === 'branch' || sub === 'tag' || sub === 'remote') {
34 return words.slice(2).every(w => /^-(v|vv|a|r|l|-list|-all|-verbose)$/.test(w))
35 }
36 if (sub === 'config') {
37 return words.slice(2).some(w => /^(--get|--get-all|--list|-l)$/.test(w))
38 }
39 }
40
41 return true
42}
43
44// Any redirection, substitution or an unknown command is not read-only
45export const isReadOnlyCommand = (command: string) => {
46 const stripped = command.replace(/\d?>\s*\/dev\/null/g, '').replace(/2>&1/g, '')
47
48 if (/[>`]|\$\(|<\(/.test(stripped)) {
49 return false
50 }
51
52 return stripped.split(/&&|\|\||[|;\n]/).every(isSafeSegment)
53}
54
55// Tools that only read or chat (anything not listed is refused)
56export const READ_TOOLS = new Set([
57 'Read', 'Grep', 'Glob', 'LSP', 'WebSearch', 'WebFetch', 'ToolSearch',
58 'AskUserQuestion', 'Agent', 'Skill', 'ListMcpResourcesTool',
59 'ReadMcpResourceTool', 'ReadMcpResourceDirTool', 'TaskGet', 'TaskList',
60 'ListAgents', 'ListSkills', 'ListPlugins', 'SearchSkills',
61])
62
63export const isAllowedCall = (tool: string, command?: string) =>
64 tool === 'Bash' ? command !== undefined && isReadOnlyCommand(command) : READ_TOOLS.has(tool)
65types/index.d.ts 8 lines1export type ChatFlag = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 justchat: { isOn: ChatFlag; isAsking: ChatFlag; hasJustLeft: ChatFlag; persistedSessionId: string | null }
6 }
7}
8