A Muse-style animated avatar pane that blinks, floats and reacts to Claude's activity.

<img width="280" height="232" alt="Recording 2026-10-06 223211" src="https://github.com/user-attachments/assets/d6ed6cd5-cbc2-4eb1-8b24-2229b93fb7cc" />
A Claude Code mod that adds a Muse-style side pane to the desktop app: an animated avatar that reacts to what Claude does, with your sessions, connectors, routines, character files and usage bars under it.
| State | Clip | When |
|---|---|---|
| Idle | idle | Claude waits for you |
| Working | working | You sent a prompt and Claude thinks |
| Making | making | Claude runs a tool |
| Level up | levelup | A turn ends; plays once, then idle |
CLAUDE.md, and buttons that open your global CLAUDE.md, the project CLAUDE.md and each memory file.claude plugin marketplace add arjkul/claude-code-muse-avatar
claude plugin install muse-avatar@muse-avatar
Start a new session. Run /avatar to open the pane again after you close it. Turn the mod off with claude plugin disable muse-avatar@muse-avatar.
The repository has no avatar clips. Without them the mod draws a plain mark in the circle and everything else works.
To add an avatar, make four short square clips (about 8 seconds each) and run:
scripts/build-frames.sh idle.mp4 working.mp4 making.mp4 levelup.mp4
This needs ffmpeg. It writes four frame files to plugins/muse-avatar/assets/. Use clips you have the right to use.
To change the name under the avatar, edit the Po strings in plugins/muse-avatar/hooks/register.tsx.
It is a Claude Code mod: a plugin of function hooks.
ui.render on Pane draws the side pane; ui.render on AbovePrompt draws the band with the mini avatar.<animate> steps through the strip, so the pane is not redrawn for each frame. A redraw at the frame rate makes the buttons in the same pane lose clicks.prompt.submit, tool.call and turn.complete pick the clip. session.measure feeds the usage bars.~/.claude/projects. The connector list comes from claude mcp list. The routines come from ~/.claude/scheduled-tasks.cmd and VS Code (or Notepad).claude plugin validate plugins/muse-avatar
claude plugin test plugins/muse-avatar
Unofficial project. The layout is inspired by the Muse assistant panel. This project is not affiliated with, endorsed by or sponsored by Muse or Anthropic. The MIT license covers the code only; it does not cover any avatar clips you add.
hooks/register.tsx 518 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Character, Clip, Connector, Routine, SessionRow, Tab, Usage } from '../types'
5
6const PANE = 'muse-avatar'
7const FRAMES = 77
8const FRAME = 92
9const CLIP_MS = 8000
10const MAX_SESSIONS = 18
11// The tab body's height in lines, the same on every tab: one two-line row a session.
12// It keeps the meters at one place and the pane short enough not to scroll.
13const BODY_ROWS = MAX_SESSIONS * 2
14const activity = atom({ plugin: 'muse-avatar', key: 'activity' } as const, null)
15// The clip on show. It changes a few times a turn; the frames themselves play
16// inside the Svg, so no site is redrawn at the frame rate and Buttons keep still.
17const mood = atom({ plugin: 'muse-avatar', key: 'mood' } as const, 'idle')
18const isPaneUp = atom({ plugin: 'muse-avatar', key: 'isPaneUp' } as const, false)
19const tab = atom({ plugin: 'muse-avatar', key: 'tab' } as const, 'activity')
20const sessions = atom({ plugin: 'muse-avatar', key: 'sessions' } as const, [])
21const connectors = atom({ plugin: 'muse-avatar', key: 'connectors' } as const, null)
22const routines = atom({ plugin: 'muse-avatar', key: 'routines' } as const, [])
23const character = atom({ plugin: 'muse-avatar', key: 'character' } as const, { vibe: '', files: [] })
24const usage = atom({ plugin: 'muse-avatar', key: 'usage' } as const, {})
25
26const TABS: { id: Tab; icon: string; title: string }[] = [
27 { id: 'activity', icon: '☰', title: 'Sessions' },
28 { id: 'connectors', icon: '⌁', title: 'Connectors' },
29 { id: 'routines', icon: '◷', title: 'Routines' },
30 { id: 'character', icon: '◉', title: 'Character' },
31]
32
33// Newest first, one line per session file: project folder, title, last use,
34// with a tab between.
35// No double quote in it, so the argument survives the Windows command line.
36const LIST_SESSIONS =
37 "Get-ChildItem ($env:USERPROFILE + '\\.claude\\projects\\*\\*.jsonl') | Sort-Object LastWriteTime -Descending | " +
38 "ForEach-Object { $m = Select-String -LiteralPath $_.FullName -Pattern 'customTitle\\W{3}([^\\x22]*)' | Select-Object -Last 1; " +
39 `if ($m) { $_.Directory.Name + [char]9 + $m.Matches[0].Groups[1].Value + [char]9 + $_.LastWriteTime.ToString('MMM d, h:mm tt') } }`
40// Ctrl+B to the focused window: the desktop app's shortcut for its left sidebar.
41const TOGGLE_SIDEBAR = "(New-Object -ComObject WScript.Shell).SendKeys('^b')"
42
43const clip = (text: string, max: number): string => {
44 const flat = text.replace(/\s+/g, ' ').trim()
45
46 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
47}
48
49// The listing as rows, newest first: the drive prefix off the folder name, and
50// a title once per project (a routine writes one session file per run).
51export const parseSessions = (stdout: string): SessionRow[] => {
52 const rows: SessionRow[] = []
53 const seen = new Set<string>()
54
55 for (const line of stdout.split(/\r?\n/)) {
56 const [folder, title, when = ''] = line.split('\t')
57 if (!folder || !title) {
58 continue
59 }
60 const group = folder.replace(/^[A-Za-z]--/, '')
61 if (seen.has(`${group}\t${title}`) || rows.length >= MAX_SESSIONS) {
62 continue
63 }
64 seen.add(`${group}\t${title}`)
65 rows.push({ group, title, when: when.trim() })
66 }
67
68 return rows
69}
70
71// `claude mcp list` as rows: `<name>: <target> - <mark> <state>` a server.
72export const parseConnectors = (stdout: string): Connector[] => {
73 const rows: Connector[] = []
74 for (const line of stdout.split(/\r?\n/)) {
75 const at = line.indexOf(': ')
76 const dash = line.lastIndexOf(' - ')
77 if (at < 1 || dash < at) {
78 continue
79 }
80 const state = line.slice(dash + 3)
81 const status = /Failed|error/i.test(state) ? 'down' : /Connected/i.test(state) ? 'ok' : 'auth'
82 rows.push({ name: line.slice(0, at).replace(/^claude\.ai /, ''), status })
83 }
84
85 return rows
86}
87
88// The `name` and `description` of a routine's SKILL.md front matter.
89export const parseRoutine = (folder: string, text: string): Routine => {
90 const field = (key: string): string => new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(text)?.[1]?.trim() ?? ''
91
92 return { name: field('name') || folder, description: field('description') }
93}
94
95// The first line of prose in a CLAUDE.md: not a heading, not a list item.
96export const vibeOf = (text: string): string =>
97 clip(text.split(/\r?\n/).find(line => /^[A-Za-z*"]/.test(line.trim())) ?? '', 150)
98
99// Circular crop of the clip's sprite strip. The strip plays inside the Svg
100// (SMIL; the Svg is drawn as an image, where a data: picture is allowed and
101// the sandboxed frame of `isInteractive` shows none): level up once, the other clips in a loop.
102export const portraitSvg = (sheet: string, now: Clip, isBusy: boolean): string => {
103 if (sheet === '') {
104 // No frame file for this clip (see scripts/build-frames.sh): a plain mark.
105 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 112 112" width="112" height="112">
106<circle cx="60" cy="60" r="52" fill="#2c2c2e"/>
107<text x="60" y="74" text-anchor="middle" fill="#e5e5ea" font-family="Segoe UI, sans-serif" font-weight="700" font-size="40">P</text>
108<circle cx="60" cy="60" r="55" fill="none" stroke="${isBusy ? '#7bd88f' : '#3a3a3c'}" stroke-width="2.5"/>
109</svg>`
110 }
111 const xs = Array.from({ length: FRAMES }, (_, i) => -i * FRAME).join(';')
112 const repeat = now === 'levelup' ? 'repeatCount="1" fill="freeze"' : 'repeatCount="indefinite"'
113
114 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 112 112" width="112" height="112">
115<defs><clipPath id="c"><circle cx="60" cy="60" r="52"/></clipPath></defs>
116<circle cx="60" cy="60" r="52" fill="#ffffff"/>
117<g clip-path="url(#c)"><svg x="6" y="6" width="108" height="108" viewBox="0 0 ${FRAME} ${FRAME}">
118<image x="0" y="0" width="${FRAMES * FRAME}" height="${FRAME}" href="data:image/jpeg;base64,${sheet}"><animate attributeName="x" values="${xs}" calcMode="discrete" dur="${CLIP_MS}ms" ${repeat}/></image>
119</svg></g>
120<circle cx="60" cy="60" r="55" fill="none" stroke="${isBusy ? '#7bd88f' : '#3a3a3c'}" stroke-width="2.5"/>
121</svg>`
122}
123
124// The context map: one bar a meter, green to amber to red as it fills.
125export const metersSvg = (now: Usage): string => {
126 const meters: [string, number | undefined][] = [
127 ['Context', now.context],
128 ['5-hour', now.fiveHour],
129 ['Weekly', now.week],
130 ]
131 const rows = meters
132 .map(([label, value], i) => {
133 const y = i * 16
134 const pct = Math.max(0, Math.min(100, value ?? 0))
135 const color = pct >= 85 ? '#ef5350' : pct >= 60 ? '#f5b041' : '#7bd88f'
136 const text = value === undefined ? '–' : `${Math.round(value)}%`
137
138 return `<text x="0" y="${y + 9}" fill="#9a9a9e" font-family="Segoe UI, sans-serif" font-size="10.5">${label}</text>
139<rect x="56" y="${y + 2}" width="164" height="6" rx="3" fill="#8a8a8e" fill-opacity="0.25"/>
140<rect x="56" y="${y + 2}" width="${(164 * pct) / 100}" height="6" rx="3" fill="${color}"/>
141<text x="260" y="${y + 9}" text-anchor="end" fill="#9a9a9e" font-family="Consolas, monospace" font-size="10.5">${text}</text>`
142 })
143 .join('\n')
144
145 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 44" width="260" height="44">${rows}</svg>`
146}
147
148const sheets: Partial<Record<string, string>> = {}
149let home: string | null = null
150let cwd: string | null = null
151
152async function asset($: any, name: string): Promise<string> {
153 const file = `${$.plugin.root}/assets/${name}.txt`
154
155 return (sheets[name] ??= (await $.fs.exists(file)) ? (await $.fs.read(file)).trim() : '')
156}
157
158async function dirs($: any) {
159 home ??= (await $.process.run(['cmd', '/c', 'echo %USERPROFILE%'])).stdout.trim()
160 cwd ??= (await $.process.run(['cmd', '/c', 'cd'])).stdout.trim()
161
162 return { home: home as string, cwd: cwd as string }
163}
164
165async function show($: any, next: Clip) {
166 await update($, mood, () => next)
167}
168
169// Level up plays once: when the clip is over the avatar goes back to idle,
170// unless another clip took its place meanwhile.
171function settle($: any) {
172 $.clock.after(CLIP_MS, () => void update($, mood, (m: Clip) => (m === 'levelup' ? 'idle' : m)))
173}
174
175// Opens the pane and records whether a surface shows it: the mini avatar is
176// drawn only while the pane is not up.
177async function openPane($: any) {
178 try {
179 const opened = await $.ui.open({ id: PANE, title: 'Po' })
180 await update($, isPaneUp, () => opened.isPlaced)
181 if (!opened.isPlaced) {
182 $.ui.toast(`Po pane is not shown: ${opened.reason}`)
183 }
184 } catch (error) {
185 $.ui.toast(`Could not open the Po pane: ${String(error)}`)
186 }
187}
188
189// The mini avatar's press: the pane opens and the left sidebar toggles.
190async function summon($: any) {
191 await openPane($)
192 try {
193 await $.process.run(['powershell', '-NoProfile', '-Command', TOGGLE_SIDEBAR])
194 } catch (error) {
195 $.ui.toast(`Could not toggle the sidebar: ${String(error)}`)
196 }
197}
198
199async function loadSessions($: any) {
200 try {
201 const ran = await $.process.run(['powershell', '-NoProfile', '-Command', LIST_SESSIONS], { timeoutMs: 60000 })
202 const rows = parseSessions(ran.stdout)
203 await update($, sessions, () => rows)
204 } catch (error) {
205 $.ui.toast(`Could not list the sessions: ${String(error)}`)
206 }
207}
208
209async function loadConnectors($: any) {
210 try {
211 const ran = await $.process.run(['cmd', '/c', 'claude', 'mcp', 'list'], { timeoutMs: 90000 })
212 const rows = parseConnectors(ran.stdout)
213 await update($, connectors, () => rows)
214 } catch (error) {
215 await update($, connectors, () => [])
216 $.ui.toast(`Could not list the connectors: ${String(error)}`)
217 }
218}
219
220async function loadRoutines($: any) {
221 try {
222 const root = `${(await dirs($)).home}\\.claude\\scheduled-tasks`
223 const rows: Routine[] = []
224 for (const entry of (await $.fs.exists(root)) ? await $.fs.list(root) : []) {
225 const file = `${root}\\${entry.name}\\SKILL.md`
226 if (entry.kind === 'dir' && (await $.fs.exists(file))) {
227 rows.push(parseRoutine(entry.name, await $.fs.read(file)))
228 }
229 }
230 await update($, routines, () => rows)
231 } catch (error) {
232 $.ui.toast(`Could not list the routines: ${String(error)}`)
233 }
234}
235
236async function loadCharacter($: any) {
237 try {
238 const d = await dirs($)
239 const soul = `${d.home}\\.claude\\CLAUDE.md`
240 const local = `${d.cwd}\\CLAUDE.md`
241 const memory = `${d.home}\\.claude\\projects\\${d.cwd.replace(/[:\\/]/g, '-')}\\memory`
242 const files = [{ label: 'SOUL · global CLAUDE.md', path: soul }]
243 files.push({ label: 'PROJECT · local CLAUDE.md', path: local })
244 for (const entry of (await $.fs.exists(memory)) ? await $.fs.list(memory) : []) {
245 if (entry.kind === 'file' && entry.name.endsWith('.md')) {
246 files.push({ label: `MEMORY · ${entry.name}`, path: `${memory}\\${entry.name}` })
247 }
248 }
249 const vibe = (await $.fs.exists(soul)) ? vibeOf(await $.fs.read(soul)) : ''
250 const now: Character = { vibe, files }
251 await update($, character, () => now)
252 } catch (error) {
253 $.ui.toast(`Could not read the character files: ${String(error)}`)
254 }
255}
256
257async function measure($: any, e: any) {
258 const window = (kind: string): number | undefined =>
259 e.rateLimits.find((one: { kind: string }) => one.kind === kind)?.percentUsed
260 const now: Usage = {
261 context: e.context.percent,
262 fiveHour: window('five_hour'),
263 week: window('seven_day'),
264 usd: e.cost?.usd,
265 }
266 await update($, usage, () => now)
267}
268
269async function loadAll($: any) {
270 await Promise.all([loadSessions($), loadRoutines($), loadCharacter($)])
271 await measure($, await $.session.usage())
272 await loadConnectors($)
273}
274
275async function pick($: any, next: Tab) {
276 await update($, tab, () => next)
277 if (next === 'connectors') {
278 await loadConnectors($)
279 }
280}
281
282async function openFile($: any, path: string) {
283 try {
284 const code = await $.process.run(['cmd', '/c', 'where', 'code'])
285 await $.process.run(code.exitCode === 0 ? ['cmd', '/c', 'code', path] : ['notepad', path])
286 } catch (error) {
287 $.ui.toast(`Could not open it: ${String(error)}`)
288 }
289}
290
291async function editLocal($: any) {
292 await openFile($, `${(await dirs($)).cwd}\\CLAUDE.md`)
293}
294
295export const register: Register = on => {
296 on('session.start', async ($, e, next) => {
297 await $.command.register({ name: 'avatar', description: 'Show the Po avatar pane' })
298 // A reload ends the module's timers, the one that ends level up included,
299 // while the state stays: start from idle, not from a clip left frozen.
300 await update($, activity, () => null)
301 await show($, 'idle')
302 void openPane($)
303 void loadAll($)
304
305 return next(e)
306 })
307
308 on('command.run', { command: 'avatar' }, async $ => {
309 await openPane($)
310 void loadAll($)
311
312 return { text: 'Po pane opened.' }
313 })
314
315 on('prompt.submit', async ($, e, next) => {
316 await update($, activity, () => 'Thinking')
317 await show($, 'working')
318
319 return next(e)
320 })
321
322 on('tool.call', async ($, e, next) => {
323 await update($, activity, () => `Running ${e.tool}`)
324 await show($, 'making')
325 const ran = await next(e)
326 await update($, activity, () => 'Thinking')
327 await show($, 'working')
328
329 return ran
330 })
331
332 on('turn.complete', async ($, e, next) => {
333 if (e.agentId !== undefined) {
334 return next(e)
335 }
336 await update($, activity, () => null)
337 await show($, e.isAborted ? 'idle' : 'levelup')
338 settle($)
339 void loadSessions($)
340
341 return next(e)
342 })
343
344 on('session.measure', async ($, e, next) => {
345 await measure($, e)
346
347 return next(e)
348 })
349
350 // The pane closed, by the person's mark or by a call: the mini avatar returns.
351 on('ui.close', { id: PANE }, async ($, e, next) => {
352 const closed = await next(e)
353 await update($, isPaneUp, () => false)
354
355 return closed
356 })
357
358 // The band: the edit button, and the mini avatar while the pane is not up. It
359 // is redrawn only when the clip changes, so its Buttons keep still.
360 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
361 if (e.props.hasSurvey || e.surface === 'terminal') {
362 return next(e)
363 }
364 const { Box, Svg, Button } = $.ui.resolve(e) as any
365 const now = await read($, mood)
366 const isBusy = (await read($, activity)) !== null
367 const isUp = await read($, isPaneUp)
368
369 return (
370 <Box flexDirection="row" justifyContent="flex-end" alignItems="flex-end" gap={2} width="100%">
371 <Button key="local" label="✎ Edit local CLAUDE.md" onPress={() => editLocal($)} />
372 {!isUp && (
373 <Box flexDirection="column" alignItems="center">
374 <Svg
375 key="mini"
376 source={portraitSvg(await asset($, now), now, isBusy)}
377 alt={`Po, ${now}`}
378 width={48}
379 height={48}
380 />
381 <Button key="open-po" label="Po" onPress={() => summon($)} />
382 </Box>
383 )}
384 </Box>
385 )
386 })
387
388 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389 const { Box, Text, Svg, Button } = $.ui.resolve(e) as any
390 const doing = await read($, activity)
391 const isBusy = doing !== null
392
393 if (e.surface === 'terminal') {
394 return (
395 <Box flexDirection="column" alignItems="center">
396 <Text bold>Po</Text>
397 <Text dimColor>{doing ?? 'Connected'}</Text>
398 </Box>
399 )
400 }
401
402 const now = await read($, mood)
403 const shown = await read($, tab)
404 const meters = await read($, usage)
405 // The lines left for the tab's body, so the pane never has to scroll.
406 const room = BODY_ROWS
407 const heading = TABS.find(one => one.id === shown)?.title ?? ''
408
409 let body
410 if (shown === 'activity') {
411 const rows = (await read($, sessions)).slice(0, MAX_SESSIONS)
412 body = (
413 <Box flexDirection="column">
414 {rows.length === 0 && <Text dimColor>No sessions found yet.</Text>}
415 {rows.map(row => (
416 <Box flexDirection="column">
417 <Text bold wrap="truncate-end">
418 {row.title}
419 </Text>
420 <Text dimColor wrap="truncate-end">
421 {row.group}
422 {row.when === '' ? '' : ` · ${row.when}`}
423 </Text>
424 </Box>
425 ))}
426 </Box>
427 )
428 } else if (shown === 'connectors') {
429 const list = await read($, connectors)
430 const dot = { ok: 'green', auth: 'yellow', down: 'red' } as const
431 const word = { ok: 'connected', auth: 'needs sign-in', down: 'failed' } as const
432 body = (
433 <Box flexDirection="column">
434 {list === null && <Text dimColor>Checking the connectors…</Text>}
435 {list !== null && list.length === 0 && <Text dimColor>No connectors found.</Text>}
436 {(list ?? []).slice(0, room).map(one => (
437 <Text wrap="truncate-end">
438 <Text color={dot[one.status]}>● </Text>
439 {one.name}
440 <Text dimColor> · {word[one.status]}</Text>
441 </Text>
442 ))}
443 </Box>
444 )
445 } else if (shown === 'routines') {
446 const list = await read($, routines)
447 body = (
448 <Box flexDirection="column">
449 {list.length === 0 && <Text dimColor>No routines found.</Text>}
450 {list.slice(0, Math.floor(room / 2)).map(one => (
451 <Box flexDirection="column">
452 <Text wrap="truncate-end">
453 <Text dimColor>◷ </Text>
454 {one.name}
455 </Text>
456 <Text dimColor wrap="truncate-end">
457 {' '}
458 {one.description}
459 </Text>
460 </Box>
461 ))}
462 </Box>
463 )
464 } else {
465 const who = await read($, character)
466 body = (
467 <Box flexDirection="column">
468 <Text dimColor>VIBE</Text>
469 <Text wrap="wrap">{who.vibe || 'No global CLAUDE.md found.'}</Text>
470 {who.files.slice(0, Math.max(2, Math.floor((room - 6) / 2))).map((file, i) => (
471 <Button key={`file:${i}`} label={`✎ ${file.label}`} onPress={() => openFile($, file.path)} />
472 ))}
473 </Box>
474 )
475 }
476
477 return (
478 <Box flexDirection="column" alignItems="center" overflow="hidden" height="100%">
479 <Svg
480 key="po"
481 source={portraitSvg(await asset($, now), now, isBusy)}
482 alt={`Po, ${now}`}
483 width={112}
484 height={112}
485 />
486 <Text>
487 <Text bold>Po </Text>
488 <Text color={isBusy ? 'yellow' : 'green'}>● </Text>
489 <Text dimColor={!isBusy}>{isBusy ? doing : 'Connected'}</Text>
490 </Text>
491
492 <Box flexDirection="row" justifyContent="space-between" width="100%" paddingX={1}>
493 {TABS.map(one => (
494 <Button
495 key={`tab:${one.id}`}
496 label={` ${one.icon} `}
497 variant={one.id === shown ? 'primary' : 'secondary'}
498 onPress={() => pick($, one.id)}
499 />
500 ))}
501 </Box>
502
503 <Box flexDirection="column" width="100%" paddingX={1} overflow="hidden" flexGrow={1} flexShrink={1}>
504 <Box flexDirection="row" justifyContent="space-between">
505 <Text bold>{heading}</Text>
506 {meters.usd !== undefined && <Text dimColor>${meters.usd.toFixed(2)} this session</Text>}
507 </Box>
508 <Box flexDirection="column" minHeight={BODY_ROWS} height={BODY_ROWS} overflow="hidden">
509 {body}
510 </Box>
511 </Box>
512
513 <Svg key="meters" source={metersSvg(meters)} alt="Context and usage meters" width={260} height={44} />
514 </Box>
515 )
516 })
517}
518types/index.d.ts 25 lines1export type Activity = string | null
2export type Clip = 'idle' | 'working' | 'making' | 'levelup'
3export type Tab = 'activity' | 'connectors' | 'routines' | 'character'
4export type SessionRow = { group: string; title: string; when: string }
5export type Connector = { name: string; status: 'ok' | 'auth' | 'down' }
6export type Routine = { name: string; description: string }
7export type Character = { vibe: string; files: { label: string; path: string }[] }
8export type Usage = { context?: number; fiveHour?: number; week?: number; usd?: number }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'muse-avatar': {
13 activity: Activity
14 mood: Clip
15 isPaneUp: boolean
16 tab: Tab
17 sessions: SessionRow[]
18 connectors: Connector[] | null
19 routines: Routine[]
20 character: Character
21 usage: Usage
22 }
23 }
24}
25