Animated pixel-art portrait above the Claude Code prompt that reacts to thinking, talking, tools, failures and idle time.

An animated pixel-art portrait above your Claude Code prompt. It waves hello, thinks, talks while Claude answers, reads, types, runs commands, winces when a tool fails and falls asleep when you walk away.

This is the Claude Code version of pi-agent-portrait, built as a Claude Code mod (a plugin of function hooks). The characters and the drawing script are the same, so a set drawn for one works in the other.
claude plugin marketplace add testy-cool/claude-agent-portraits
claude plugin install agent-portrait@claude-agent-portraits
Start Claude Code and oana appears above the prompt. The picture needs a terminal with the kitty graphics protocol, such as Ghostty or kitty. Other terminals show a one-line description of the state instead.
Inside a multiplexer, Claude Code cannot ask the terminal whether it shows images, so it shows the text instead. herdr passes the images through, so you can force them on. Add this line to your shell profile, then restart Claude Code:
[[ -n $HERDR_ENV && $TERM_PROGRAM == ghostty ]] && export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
/portrait list the sets
/portrait nova switch to nova for this project
The choice is saved in .claude/agent-portrait/config.json, so each project can have its own face. Bundled sets: oana, nova, cipher, forge, moss, quill, sprocket, default and red.
To change the default set or the height, open /config and look for the agent-portrait rows.
Ask Claude for one. The plugin ships a draw-portrait skill, so a request like this is enough:
draw a portrait for this project: a deploy agent, a grizzled dwarf blacksmith with a braided red beard
Claude runs the drawing script, shows you the sheet, and you switch to it with /portrait forge. You can also hand it a photo, or ask for a second take of a set you already have.
To run the script yourself, from the project folder:
scripts/draw-portrait --name forge --role "a deploy agent" \
--direction "a grizzled dwarf blacksmith with a braided red beard"
An image model draws all 30 frames on one sheet, the script cuts them up and writes the set to .claude/agent-portrait/emotes/forge/, then selects it for that project. Pass --photo me.png to draw a character that looks like someone, or --variant 2 to add a second take of every frame.
It needs Python 3 with Pillow and NumPy, plus one image model:
--backend codex, picked when codex is installed): its built-in image tool, on your ChatGPT login.--backend azure): set AZURE_IMAGE_ENDPOINT to the full .../openai/v1/images/edits URL, plus AZURE_IMAGE_KEY and AZURE_IMAGE_MODEL.| State | When |
|---|---|
| hi | Session start, or a new set |
| heard, then think | You send a prompt |
| talk | Claude is writing its answer |
| read | Read |
| write | Write, Edit, NotebookEdit |
| bash | Bash |
| search | Grep, Glob, WebSearch, WebFetch and tools named like them |
| tool | Any other tool |
| error | A tool returned an error |
| success | The turn finished |
| interrupted | You pressed Esc |
| failure | The turn ended in an API error or a refusal |
| compact | The conversation is being compacted |
| sleep | Five idle minutes |
A set that has no frames for a state shows the closest one it has.
The first folder that has the set wins:
<project>/.claude/agent-portrait/emotes/<set><project>/.pi/extensions/pi-emote/emotes/<set>, so a project set up for pi keeps its face~/.claude/agent-portrait/emotes/<set>Based on pi-emote by @cgxeiji, who made the original state machine and the default and red sets.
hooks/register.tsx 313 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// An animated portrait above the prompt that reacts to what Claude is doing:
4// thinking, talking, reading, writing, running commands, failing, sleeping.
5// Each state is a folder of PNG frames. Files ending in _v2, _v3 are other
6// drawings of the same state; one is picked when the state starts and kept
7// until it ends, so frames from different drawings never mix.
8
9type State =
10 | 'hi' | 'idle' | 'think' | 'talk' | 'read' | 'write' | 'tool' | 'success'
11 | 'failure' | 'compact' | 'sleep' | 'wait' | 'interrupted' | 'search'
12 | 'bash' | 'error' | 'heard'
13
14const STATES: State[] = [
15 'hi', 'idle', 'think', 'talk', 'read', 'write', 'tool', 'success', 'failure',
16 'compact', 'sleep', 'wait', 'interrupted', 'search', 'bash', 'error', 'heard',
17]
18
19// Sets drawn before a state existed show the closest older one.
20const FALLBACK: Partial<Record<State, State>> = {
21 sleep: 'idle', wait: 'idle', interrupted: 'failure', search: 'tool',
22 bash: 'tool', error: 'failure', heard: 'idle', hi: 'idle', success: 'idle',
23 failure: 'idle', compact: 'think', read: 'tool', write: 'tool', tool: 'think',
24 talk: 'idle', think: 'idle',
25}
26
27// States that play once and then hand over to the next one.
28const HOLD: Partial<Record<State, number>> = {
29 hi: 2000, success: 1500, failure: 1500, error: 1200, interrupted: 1500, heard: 700,
30}
31
32const TICK_MS = 150
33const CYCLE_MS = 500
34const TALK_MS = 130
35const SLEEP_AFTER_MS = 5 * 60 * 1000
36
37type Frames = Map<State, Map<string, string[]>>
38
39const variantOf = (file: string) => /(_v\d+)?\.png$/.exec(file)?.[1] ?? ''
40const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)]!
41
42function toolState(tool: string): State {
43 if (tool === 'Read' || tool === 'NotebookRead') return 'read'
44 if (tool === 'Write' || tool === 'Edit' || tool === 'NotebookEdit') return 'write'
45 if (tool === 'Bash' || tool === 'PowerShell') return 'bash'
46 if (/grep|glob|search|web|fetch|browse|scrape|crawl/i.test(tool)) return 'search'
47 return 'tool'
48}
49
50// Everything the portrait remembers. Module state, so a reload starts over.
51const P = {
52 rows: 6,
53 setName: 'oana',
54 setDir: '',
55 frames: new Map() as Frames,
56 // Each frame's PNG as base64. Sent as bytes, because a terminal behind a
57 // multiplexer such as herdr cannot read the file from disk itself.
58 png: new Map<string, string>(),
59 state: 'idle' as State,
60 variant: '',
61 since: 0,
62 after: 'idle' as State,
63 lastActive: 0,
64 lastText: 0,
65 shown: '',
66 blinkAt: 0,
67 blinkUntil: 0,
68}
69
70// --- Loading a set ---
71
72
73// Where sets live, most specific first: this project, then pi's folder for this
74// project (so a pi minion keeps its face), then the user's, then the bundled ones.
75async function setFolders($: EngineInterface): Promise<string[]> {
76 const project = await $.session.root()
77 const home = (await $.env.get('HOME')) ?? ''
78 return [
79 `${project}/.claude/agent-portrait/emotes`,
80 `${project}/.pi/extensions/pi-emote/emotes`,
81 `${home}/.claude/agent-portrait/emotes`,
82 `${$.plugin.root}/emotes`,
83 ]
84}
85
86async function findSet($: EngineInterface, name: string): Promise<string> {
87 for (const dir of await setFolders($)) if (await $.fs.exists(`${dir}/${name}`)) return `${dir}/${name}`
88 return ''
89}
90
91// The set this project picked, in the config draw-portrait and /portrait write.
92async function projectSet($: EngineInterface): Promise<string> {
93 const project = await $.session.root()
94 for (const file of [
95 `${project}/.claude/agent-portrait/config.json`,
96 `${project}/.pi/extensions/pi-emote/config.json`,
97 ]) {
98 if (!(await $.fs.exists(file))) continue
99 try {
100 const config = JSON.parse(String(await $.fs.read(file)))
101 const name = config.emotes?.at?.(-1)?.['emote-set']
102 if (typeof name === 'string' && name) return name
103 } catch {}
104 }
105 return ''
106}
107
108async function saveProjectSet($: EngineInterface, name: string) {
109 const file = `${await $.session.root()}/.claude/agent-portrait/config.json`
110 let config: Record<string, unknown> = {}
111 if (await $.fs.exists(file)) {
112 try { config = JSON.parse(String(await $.fs.read(file))) } catch {}
113 }
114 config.emotes = [{ model: '*', 'emote-set': name }]
115 await $.fs.write(file, JSON.stringify(config, null, 2) + '\n')
116}
117
118async function loadSet($: EngineInterface, name: string): Promise<boolean> {
119 const dir = await findSet($, name)
120 if (!dir) return false
121 const found: Frames = new Map()
122 for (const s of STATES) {
123 if (!(await $.fs.exists(`${dir}/${s}`))) continue
124 const byVariant = new Map<string, string[]>()
125 const files = (await $.fs.list(`${dir}/${s}`))
126 .filter((f: any) => f.name.endsWith('.png'))
127 .map((f: any) => f.name)
128 .sort()
129 for (const f of files) {
130 const v = variantOf(f)
131 const path = `${dir}/${s}/${f}`
132 if (!P.png.has(path)) P.png.set(path, (await $.fs.read(path, { as: 'bytes' })).base64)
133 byVariant.set(v, [...(byVariant.get(v) ?? []), path])
134 }
135 if (byVariant.size) found.set(s, byVariant)
136 }
137 if (!found.size) return false
138 P.setName = name
139 P.setDir = dir
140 P.frames = found
141 return true
142}
143
144async function listSets($: EngineInterface): Promise<string[]> {
145 const names = new Set<string>()
146 for (const dir of await setFolders($)) {
147 if (!(await $.fs.exists(dir))) continue
148 for (const e of await $.fs.list(dir)) if (e.kind !== 'file') names.add(e.name)
149 }
150 return [...names].sort()
151}
152
153// --- The state machine ---
154
155function resolve(s: State): State {
156 let at = s
157 for (let i = 0; i < 4 && !P.frames.has(at); i++) at = FALLBACK[at] ?? 'idle'
158 return at
159}
160
161function go(s: State, now: number, next: State = 'idle') {
162 P.state = s
163 P.after = next
164 P.since = now
165 P.lastActive = now
166 const variants = [...(P.frames.get(resolve(s))?.keys() ?? [''])]
167 P.variant = pick(variants)
168}
169
170function filesOf(s: State): string[] {
171 const byVariant = P.frames.get(resolve(s))
172 if (!byVariant) return []
173 return byVariant.get(P.variant) ?? byVariant.get('') ?? [...byVariant.values()][0] ?? []
174}
175
176function named(list: string[], name: string) {
177 return list.find((f) => f.endsWith(`/${name}${P.variant}.png`)) ?? list.find((f) => f.endsWith(`/${name}.png`))
178}
179
180// Which file should show right now.
181function frameAt(now: number): string {
182 const hold = HOLD[P.state]
183 if (hold && now - P.since > hold) go(P.after, now)
184 if (P.state === 'idle' && now - P.lastActive > SLEEP_AFTER_MS) go('sleep', now)
185 if (P.state === 'talk' && now - P.lastText > 1500) go('idle', now)
186
187 const s = resolve(P.state)
188 const list = filesOf(P.state)
189 if (!list.length) return ''
190
191 if (s === 'idle') {
192 const base = named(list, 'idle') ?? list[0]!
193 const blink = named(list, 'idle_blink')
194 if (!blink) return base
195 if (now >= P.blinkAt) {
196 P.blinkUntil = now + 150
197 P.blinkAt = now + 3000 + Math.random() * 3000
198 }
199 return now < P.blinkUntil ? blink : base
200 }
201 if (s === 'think') {
202 const base = named(list, 'think') ?? list[0]!
203 const hard = named(list, 'think_hard')
204 return hard && Math.floor((now - P.since) / 4000) % 2 === 1 ? hard : base
205 }
206 if (s === 'talk') {
207 if (now - P.lastText > 250) return named(list, 'talk_close') ?? list[0]!
208 return list[Math.floor(now / TALK_MS) % list.length]!
209 }
210 // Everything else ping-pongs through its frames.
211 if (list.length === 1) return list[0]!
212 const period = list.length * 2 - 2
213 const i = Math.floor((now - P.since) / CYCLE_MS) % period
214 return list[i < list.length ? i : period - i]!
215}
216
217async function show($: EngineInterface, s: State, next: State = 'idle') {
218 go(s, await $.clock.now(), next)
219}
220
221
222export const register: Register = (on, options) => {
223 const opts = options as { set?: string; rows?: number }
224 P.rows = Math.max(3, Math.min(20, Number(opts.rows) || 6))
225 P.setName = String(opts.set || 'oana')
226
227
228 on('session.start', async ($, e, next) => {
229 const picked = await projectSet($)
230 if (!(picked && (await loadSet($, picked))) && !(await loadSet($, P.setName))) await loadSet($, 'oana')
231 await $.command.register({
232 name: 'portrait',
233 description: 'Switch the agent portrait: /portrait <set>, or no name to list them',
234 })
235 await show($, 'hi')
236 $.clock.every(TICK_MS, async () => {
237 const file = frameAt(await $.clock.now())
238 if (file && file !== P.shown) {
239 P.shown = file
240 $.ui.invalidate('ui.render')
241 }
242 })
243 return next(e)
244 })
245
246 on('command.run', { command: 'portrait' }, async ($, e) => {
247 const want = e.args.trim()
248 const sets = await listSets($)
249 if (!want) return { text: `Portrait: ${P.setName}\nSets: ${sets.join(', ')}` }
250 if (!(await loadSet($, want))) return { text: `No portrait set named "${want}". Sets: ${sets.join(', ')}` }
251 await saveProjectSet($, want)
252 await show($, 'hi')
253 return { text: `Portrait set to ${want} for this project, from ${P.setDir}` }
254 })
255
256 on('prompt.submit', async ($, e, next) => {
257 await show($, 'heard', 'think')
258 return next(e)
259 })
260
261 on('turn.step', async function* ($, e, next) {
262 for await (const chunk of next(e)) {
263 if (chunk.kind === 'text' && chunk.text.trim()) {
264 P.lastText = await $.clock.now()
265 if (P.state !== 'talk') go('talk', P.lastText)
266 } else if (chunk.kind === 'thinking' && P.state !== 'think') {
267 await show($, 'think')
268 }
269 yield chunk
270 }
271 })
272
273 on('tool.call', async ($, e, next) => {
274 if (!e.agentId) await show($, toolState(e.tool))
275 const result = await next(e)
276 if (!e.agentId) {
277 if ('isError' in result && result.isError) await show($, 'error', 'think')
278 else await show($, 'think')
279 }
280 return result
281 })
282
283 on('session.compact', async ($, e, next) => {
284 await show($, 'compact')
285 const result = await next(e)
286 await show($, 'idle')
287 return result
288 })
289
290 on('turn.complete', async ($, e, next) => {
291 if (e.reason === 'aborted') await show($, 'interrupted')
292 else if (e.reason === 'error' || e.reason === 'refusal') await show($, 'failure')
293 else await show($, 'success')
294 return next(e)
295 })
296
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 if (e.props.hasSurvey || e.surface !== 'terminal' || !P.shown) return next(e)
299 const { Box, Image } = $.ui.resolve(e)
300 return (
301 <Box height={P.rows}>
302 <Image
303 key="portrait"
304 source={P.png.has(P.shown) ? { png: P.png.get(P.shown)! } : { file: P.shown, format: 'png' }}
305 columns={P.rows * 2}
306 rows={P.rows}
307 alt={`${P.setName}: ${P.state}`}
308 />
309 </Box>
310 )
311 })
312}
313