add-message-type
Skill4 starsChanged 8 months agoMITView on GitHub
Add end-to-end support for a new Claude Code session message type (a new top-level `type` in the session JSONL) to vibeview — the Go parser, activity classification, API serialization, the React type union, and its inline display component. Use this whenever the user wants to support, handle, or render a new/unknown message type, mentions an 'unknown message type' warning, pastes a JSONL line with an unfamiliar `type`, or asks why some session event shows up as the amber 'Unknown message type' fallback.
In this repository5 other files it gives its agents.
- claude-code-plugin/CLAUDE.mdCLAUDE.md
- CLAUDE.mdCLAUDE.md
- tasks/CLAUDE.mdCLAUDE.md
- audit-docsSkill
- auditSkill
The file
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 27
Look at a real JSONL example of the new type.
- Very specific: line 41
When in doubt, prefer the data-nested / auto-captured path — it needs the least code and matches how most recent types are modeled.
- Very specific: line 78
You want a dedicated typed field (step 2b): add the field to the Message struct with a JSON tag, and add its JSON key to the …
- Very specific: line 198
Run make check-lite while iterating to catch compile/lint errors fast, then make check before finishing — it must pass Go tests, …
- Very specific: line 217
If the new type is a real conversational message deserving its own bubble (not an inline event), it doesn't belong in EventMessages.tsx.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Add support for a new message type
vibeview reads Claude Code session JSONL files. Each line is a message with a top-level
type (e.g. user, assistant, system, queue-operation, permission-mode). When Claude
Code introduces a new type, vibeview parses it but renders it through the amber
"Unknown message type" fallback and logs a console.warn. This skill walks you through wiring
a new type all the way through so it parses cleanly, is classified correctly, and renders as a
tidy inline event.
Most new types are system-like events — low-level session bookkeeping (a mode change, a
queue operation, a hook firing). Per the project's CLAUDE.md, these must use the shared
inline EventMessage component (compact left-border style, click to view raw JSON), not a
bespoke card. This skill assumes that common case. If the new type is genuinely a
conversational message (its own bubble like user/assistant), see
Not a system-like event? at the end.
Also remember the project rule backend owns the logic, frontend is a thin display layer: derive/classify in Go, and let the web client just render server-provided values.
Before you start: understand the shape
Look at a real JSONL example of the new type. The single most important question is where the type's payload lives, because it decides whether you touch the Go struct at all:
- Nested under
data— e.g.{"type":"mode","data":{"mode":"normal"}}. Thedatakey is already a known field that unmarshals intoMessage.Data map[string]any, so the payload is captured for free. No new Go struct field is needed. The frontend readsmessage.data?.mode. This is the simplest and most common case. - A custom top-level field you want typed — e.g.
permissionModeonpermission-mode. Add a dedicated struct field and register its JSON key (see step 2b). Do this only when you want a first-class, strongly-typed field. - A custom top-level field, untyped — any unknown top-level key is auto-captured into
Message.DatabyUnmarshalJSON(this is howqueue-operationexposesoperationto the frontend asmessage.data?.operation). No struct change; the frontend reads it fromdata.
When in doubt, prefer the data-nested / auto-captured path — it needs the least code and
matches how most recent types are modeled.
The files you'll touch
Grep for an existing analogous type to anchor yourself — permission-mode and
queue-operation are the best templates. grep -rn "permission-mode\|MessageTypePermissionMode\|queue-operation" apps shows every site end-to-end.
The full path, backend → frontend:
apps/lib/claude/claude.go— theMessageTypeconstant (and optionally a struct field).apps/lib/session/activity.go— classify the type as non-semantic.apps/cli/internal/server/server.go— API serialization (only if you added a struct field).apps/web/src/types.ts— add the literal to theMessageResponse.typeunion.apps/web/src/components/EventMessages.tsx— the inline display component.apps/web/src/components/MessageBubble.tsx— route the type to that component.- Tests in
apps/lib/claude/claude_test.goandapps/lib/session/activity_test.go.
Work through them in order.
Step 1 — Register the type constant (Go)
In apps/lib/claude/claude.go, add a MessageType constant to the const (...) block
alongside the others (keep the gofmt-aligned column):
MessageTypeMode MessageType = "mode"The parser (ParseMessageLine) never rejects unknown types, so nothing breaks without this —
but the constant is what every downstream switch/classification refers to, and it documents
the type as known.
Step 2 — Model the payload (Go, usually nothing to do)
- Payload under
dataor an untyped top-level field: skip this step.Message.Dataalready captures it. - You want a dedicated typed field (step 2b): add the field to the
Messagestruct with a JSON tag, and add its JSON key to theknownMessageKeysmap. That map lists keys mapped to named struct fields soUnmarshalJSONdoesn't also duplicate them intoData. Forgetting theknownMessageKeysentry is the classic bug — the value ends up in both the field andData.
Step 3 — Classify as a non-semantic event (Go)
In apps/lib/session/activity.go, DeriveActivityState walks messages backwards to decide what
the session is doing. System-like events must not count as conversation state, so add the new
constant to the "Skip non-semantic message types" case list:
case claude.MessageTypeSystem, /* …existing… */, claude.MessageTypeMode:
continueFunctionally the switch has no default, so an unlisted type is skipped anyway — but adding
it explicitly keeps intent clear and is what the skip-test asserts. Leave
DeriveActivityStateFromMessage alone: its default: return "" already treats the type as
non-semantic. (Only touch these if the type genuinely reflects working/waiting state, which
system events don't.)
Step 4 — API serialization (Go, only if you added a struct field)
apps/cli/internal/server/server.go maps claude.Message → MessageResponse in
toMessageResponse. Data (and Attachment, etc.) already flow through with redaction applied,
so payloads under data need no change here. If in step 2b you added a new dedicated struct
field, add a matching field to the MessageResponse struct and copy it in toMessageResponse
(mirror how PermissionMode is carried). Apply redaction consistent with the neighbors
(redact.RedactMapValues for maps, redact.RedactSecrets for strings).
Step 5 — Extend the frontend type union (TS)
In apps/web/src/types.ts, add the string literal to the MessageResponse['type'] union:
| 'permission-mode'
| 'attachment'
| 'mode';Without this, TypeScript will reject the message.type === 'mode' comparison you add in step 7.
Step 6 — Add the inline display component (TSX)
In apps/web/src/components/EventMessages.tsx, add a component that wraps the shared
EventMessage. Copy PermissionModeMessage (dedicated field) or QueueOperationMessage
(reads from data) as your template. Read the detail defensively and give the event a distinct
Tailwind color so it's visually separable from its neighbors:
export function ModeMessage({ message }: { message: MessageResponse }) {
const mode = message.data?.mode ? String(message.data.mode) : 'unknown';
return (
<EventMessage
message={message}
label="Mode"
borderColor="border-indigo-300 dark:border-indigo-600"
labelColor="text-indigo-500 dark:text-indigo-400"
detailColor="text-indigo-400 dark:text-indigo-500"
detailText={mode}
/>
);
}Colors already in use (pick an unused one): queue-operation → blue/amber, last-prompt → violet,
permission-mode → emerald, attachment → cyan, hook → stone, mode → indigo. Good remaining
choices: sky, rose, teal, fuchsia, lime. EventMessage also accepts an optional rawData prop
if the click-to-view-JSON should show a nested object (see AttachmentMessage).
Step 7 — Route the type to the component (TSX)
In apps/web/src/components/MessageBubble.tsx, import the new component from ./EventMessages
(keep the import list alphabetical) and add a branch before the final
console.warn(...) / <UnknownMessage> fallback:
if (message.type === 'mode') {
return <ModeMessage message={message} />;
}Once this branch exists, the type stops falling through to the amber "Unknown message type" fallback.
Step 8 — Tests (required for every new behavior)
The project rule is test all new behavior. Add two small tests, mirroring existing ones:
- Parse test in
apps/lib/claude/claude_test.go— copyTestParseMessageLine_PermissionMode/TestParseMessageLine_Attachment. Assert thetypemaps to the new constant and the payload landed where you expect (msg.Data["mode"]fordata-nested, or the struct field otherwise):
func TestParseMessageLine_Mode(t *testing.T) {
line := `{"type":"mode","uuid":"m1","timestamp":"2026-04-16T10:09:19.406Z","data":{"mode":"normal"}}`
msg, err := ParseMessageLine([]byte(line))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != MessageTypeMode {
t.Errorf("Type = %q, want %q", msg.Type, MessageTypeMode)
}
if msg.Data["mode"] != "normal" {
t.Errorf("Data[mode] = %v, want %q", msg.Data["mode"], "normal")
}
}- Classification test in
apps/lib/session/activity_test.go— extendTestDeriveActivityState_SkipsPermissionModeAndAttachment(add a message of the new type to the slice) so it proves the event is skipped and the real conversation state still wins.
A frontend test isn't strictly required (there are currently no MessageBubble/EventMessages
component tests), but the Go tests above are.
Step 9 — Verify
Run make check-lite while iterating to catch compile/lint errors fast, then make check
before finishing — it must pass Go tests, golangci-lint, eslint, tsc, the web tests, and
the docs build. A common trip-up: the project's TypeScript pre-edit hook flags an unused import,
so add the MessageBubble.tsx routing branch (step 7) in the same pass as the import.
Quick checklist
MessageTypeconstant added (claude.go)- Payload modeled — nothing to do if under
data; else struct field andknownMessageKeys - Added to the non-semantic skip
caseinDeriveActivityState(activity.go) MessageResponse+toMessageResponseupdated only if you added a struct field (server.go)- Type literal added to the union (
types.ts) - Inline component added with a distinct color (
EventMessages.tsx) - Routing branch added before the fallback, import alphabetized (
MessageBubble.tsx) - Parse test + activity skip test added
make checkpasses
Not a system-like event?
If the new type is a real conversational message deserving its own bubble (not an inline event),
it doesn't belong in EventMessages.tsx. Instead model it like user/assistant: give it a
dedicated component (see AssistantMessage.tsx / UserMessage.tsx), route it high up in
MessageBubble.tsx, and reconsider steps 3–4 — it may carry real activity-state meaning, in
which case add it to the semantic switch cases in activity.go rather than the skip list.
This is rare; confirm with the user before going this route, since it diverges from the
"minimal UX for system-like events" convention.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

