SLOPSHOPPER

use-haiku

Start a prompt with _ and that turn runs on Haiku, shown as you type, while it runs and under the answer.

newspinnertoastpromptagents
★ 2v0.2.0MITupdated 2026-10-15midudev/better-claude/plugins/use-haiku
A shopper browsing a rack in a slop shop
README

Use Haiku

A Claude Code mod: start a prompt with _ and that turn runs on Haiku. Other prompts keep the session's model.

❯ _summarize the last commit

Claude gets summarize the last commit, without the _, and every model request of that turn goes to claude-haiku-5-5, those of the subagents it starts included. The next prompt goes back to the session's model.

You can see it

  • As you type. The _ lights up in the prompt box and the line under it says ⚡ this prompt runs on Haiku.
  • While it runs. The spinner says so: ✻ Sauteing on Haiku… (3s · ↓ 120 tokens).
  • When it ends. A line under the answer names the model the API says answered: ⚡ Answered by claude-haiku-5-5, or a warning if another one did.

Ask it "which model are you?" and it says Haiku: the prompt carries a note, unseen by you, telling the model which one answers it.

Install

/plugin marketplace add midudev/better-claude
/plugin install use-haiku@better-claude

Requires a Claude Code version with mods (2.1.289 or newer).

Details

  • Idle only. A prompt typed while Claude is working is folded into the running turn or queued behind it, so it keeps the current model. The line under the prompt warns before you send it (⚡ not on Haiku mid-turn), and a toast says so if you do.
  • Not a mark: __init__.py fails (a double underscore) and a lone _.
  • Your prompts only: typed, from your phone, claude -p "_…", a scheduled /loop, or a plugin sending them for you (After's /after _…). Messages from channels, other sessions or background tasks are left alone.
  • Subagents the Haiku turn starts run on Haiku too, even after the turn ends. Teammates keep their own model.
  • With claude -p the model switches the same, but no line is added under the answer, so the output stays clean.

How it works

One hooks module (hooks/register.tsx):

  • prompt.edit: marks the _ in the box and records that the draft carries it.
  • prompt.submit: an idle prompt starting with _ loses the mark, gets the note for the model, and claims the next turn.
  • turn.start: that turn becomes the Haiku turn.
  • turn.step: each request of the Haiku turn, and of the subagents it started, names Haiku (next({ ...e, model })).
  • agent.spawn: a subagent the Haiku turn starts is spawned on Haiku.
  • turn.complete: the main loop's turn ends the Haiku turn and adds the line under the answer.
  • ui.render on Spinner and PromptHint: the spinner and the line under the prompt.

The Haiku turn and its subagents live in $.state (types/index.d.ts), so a reload mid-turn keeps them.

Development

claude plugin validate .   # from the repository root, or ./use-haiku
claude plugin test .       # runs hooks/register.test.tsx against the engine

MIT © Miguel Ángel Durán (midudev)

Source 2 files
hooks/register.tsx 144 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { AgentIds } from '../types'
5
6const MARK = '_'
7const HAIKU = 'claude-haiku-5-5'
8const COLOR = 'suggestion'
9
10const MARKED = { plugin: 'use-haiku', key: 'marked' } as const
11const TURN = { plugin: 'use-haiku', key: 'turn' } as const
12const AGENTS = { plugin: 'use-haiku', key: 'agents' } as const
13
14const marked = atom(MARKED, false)
15const haikuTurn = atom(TURN, null as string | null)
16const haikuAgents = atom(AGENTS, [] as AgentIds)
17
18// `_summarize the diff` is marked; `__init__.py fails` is not.
19const isMarked = (text: string) => text.startsWith(MARK) && !text.startsWith(MARK + MARK)
20
21// Prompts in the person's own words: typed, from a phone or through -p, scheduled, or sent by a plugin for them (After's queue).
22const OWN = new Set<PromptOrigin['kind']>(['composer', 'bridge', 'sdk', 'scheduled-trigger', 'plugin'])
23
24const NOTE = `The person sent this prompt to Haiku: you are ${HAIKU} while you answer it, whatever model earlier context names.`
25
26// What `marked` holds, so a keystroke writes it only when it flips.
27let lastMarked: boolean | undefined
28
29async function setMarked ($: EngineInterface, value: boolean) {
30  if (value === lastMarked) return
31  lastMarked = value
32  await $.state.set(MARKED, value)
33}
34
35export const register: Register = on => {
36  // A marked prompt sent while Claude was idle: the next turn of the main loop is its own.
37  let pending = false
38  // The line under a Haiku answer is for a person at the prompt, not for -p output.
39  let isInteractive = false
40
41  on('session.start', ($, e, next) => {
42    isInteractive = e.isInteractive
43    return next(e)
44  })
45
46  // The mark lights up in the box as soon as it is typed.
47  on('prompt.edit', async ($, e, next) => {
48    const box = await next(e)
49    const isNow = isMarked(box.text)
50    await setMarked($, isNow)
51    if (!isNow) return box
52    return { ...box, decorations: [...(box.decorations ?? []), { start: 0, end: MARK.length, color: COLOR, bold: true }] }
53  })
54
55  on('prompt.submit', async ($, e, next) => {
56    await setMarked($, false)
57    const text = e.text.slice(MARK.length).trimStart()
58    if (!isMarked(e.text) || text === '' || !OWN.has(e.origin.kind)) {
59      if (e.turnId === undefined) pending = false
60      return next(e)
61    }
62
63    // Typed while a turn runs, it is folded into that turn or queued behind it, and which is not
64    // known here: rather than guess, it keeps the current model, and says so.
65    if (e.turnId !== undefined) {
66      $.ui.toast('⚡ Sent mid-turn, so not on Haiku: it keeps the current model')
67      return next({ ...e, text })
68    }
69
70    // The model learns which model it is once per conversation, from the session's: asked "which
71    // model are you?" it would name that one. The prompt itself tells it, unseen by the person.
72    pending = true
73    const result = await next({ ...e, text, context: [...(e.context ?? []), NOTE] })
74    if (result.drop !== undefined) pending = false
75    return result
76  })
77
78  // Only the main loop raises turn.start; a subagent's run does not.
79  on('turn.start', async ($, e, next) => {
80    await $.state.set(TURN, pending ? e.turnId : null)
81    pending = false
82    return next(e)
83  })
84
85  // Every request of a Haiku turn goes to Haiku: the main loop's and those of the subagents it started.
86  on('turn.step', async function* ($, e, next) {
87    const isHaiku = e.agentId === undefined
88      ? e.turnId === (await read($, haikuTurn))
89      : (await read($, haikuAgents)).includes(e.agentId)
90    return yield* next(isHaiku ? { ...e, model: HAIKU } : e)
91  })
92
93  on('agent.spawn', async ($, e, next) => {
94    const isHaiku = !e.isTeammate && (e.parentAgentId === undefined
95      ? (await read($, haikuTurn)) !== null
96      : (await read($, haikuAgents)).includes(e.parentAgentId))
97    if (!isHaiku) return next(e)
98
99    // A fork inherits its parent's model and a workflow's agent keeps its own: turn.step catches theirs.
100    const result = await next(e.fork || e.workflow ? e : { ...e, model: HAIKU })
101    const id = result.deny === undefined ? result.agentId : undefined
102    if (id !== undefined) await update($, haikuAgents, ids => [...ids, id])
103    return result
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    if (e.agentId !== undefined) return next(e)
108
109    // Cleared before next(e): a plugin beneath may start the next turn from here (After does).
110    const turn = await read($, haikuTurn)
111    if (turn !== null) await $.state.set(TURN, null)
112    const result = await next(e)
113    if (turn !== e.turnId || e.reason !== 'answer' || e.usage === undefined || !isInteractive) return result
114
115    // What the API says answered, so a Haiku turn is seen to be one.
116    const model = e.usage.model
117    const line = model.includes('haiku') ? `⚡ Answered by ${model}` : `⚠ Asked for Haiku, but ${model} answered`
118    return { ...result, text: result.text === e.answer ? line : `${result.text}\n${line}` }
119  })
120
121  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
122    if ((await read($, haikuTurn)) === null) return next(e)
123    // The first frame of a turn comes before the word is sampled.
124    const word = e.props.word === '' ? 'Haiku' : `${e.props.word} on Haiku`
125    return next({ ...e, props: { ...e.props, word } })
126  })
127
128  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
129    const isMarkedDraft = await read($, marked)
130    if (!e.props.isDraft || !isMarkedDraft) return next(e)
131
132    const { Box, Text } = $.ui.resolve(e)
133    return (
134      <Box flexDirection="row">
135        {e.props.isWorking
136          ? <Text color="warning">⚡ not on Haiku mid-turn: wait for Claude to finish</Text>
137          : <Text color={COLOR}>⚡ this prompt runs on Haiku</Text>}
138        <Text>  </Text>
139        {await next(e)}
140      </Box>
141    )
142  })
143}
144
types/index.d.ts 11 lines
1/** The subagents a Haiku turn started, by id: their requests go to Haiku too. */
2export type AgentIds = readonly string[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    // marked: the draft in the prompt box starts with the mark.
7    // turn: the main loop's turn that runs on Haiku, while it runs.
8    'use-haiku': { marked: boolean; turn: string | null; agents: AgentIds }
9  }
10}
11