SKILL← All skills

Error Message Audit

Audit user-facing error messages in any codebase against an error-writing rubric. Walks through findings one at a time and proposes rewrites. Use this whenever the user mentions auditing error messages, reviewing error copy, fixing "Something went wrong" messages, error UX, error wording, error tone, or anything about how errors are worded — even if they don't say the word "audit."

Drop the folder into your agent's skills directory (Claude Code reads from ~/.claude/skills/error-message-audit/).

SKILL.md
CopyDownload
---
name: error-message-audit
description: "Audit user-facing error messages in any codebase against an error-writing rubric. Walks through findings one at a time and proposes rewrites. Use this whenever the user mentions auditing error messages, reviewing error copy, fixing \"Something went wrong\" messages, error UX, error wording, error tone, or anything about how errors are worded  even if they don't say the word \"audit.\""
---

You are auditing the user-facing error messages in this codebase against an error-writing rubric. Your job is to find error strings that real users see, judge them against the rubric, and walk the user through fixes one at a time — proposing a rewrite and asking before editing.

The point of this skill is *quality of communication*, not bug hunting. You're looking at how errors are *worded*, not whether the error handling logic is correct.

## Mode

- If `$ARGUMENTS` is a path (file or directory), scan only that path.
- Otherwise, scan the whole repo, excluding: `node_modules`, `.git`, `dist`, `build`, `out`, `.next`, `.nuxt`, `vendor`, `target`, `__pycache__`, `.venv`, lock files, minified assets, and anything in `.gitignore` that looks like build output.
- Always announce the scope before scanning so the user can redirect you.

---

## 1. Detect user-facing error strings

You're looking for strings the *end user* will read in the UI or in an API response that gets surfaced to them. Not log lines, not internal exception messages that get reformatted before reaching anyone.

There's no single grep that finds these reliably across stacks, so work through this hierarchy and use judgment. When a hit could be either user-facing or dev-only, read enough surrounding code to decide.

### Strong signals (almost certainly user-facing)

- **Toast / notify / snackbar / alert calls**`toast.error`, `toast(...)`, `notify.error`, `Alert.alert`, `enqueueSnackbar`, `useToast`, `showAlert`, `addToast`, `flash[:error]`, Rails `flash.now[:alert]`, Django `messages.error`, Phoenix `put_flash`
- **i18n error keys**`t('errors.*')`, `i18n.t('error.*')`, `$t('error.*')`, `gettext("...")`, JSON/YAML files like `errors.json`, `en.json`, `en.yml` with keys matching `error*|fail*|invalid*|cannot*|unable*`
- **Form validation messages**`setError`, `errors.<field>.message`, Zod/Yup/Joi `.message("...")`, `react-hook-form` `rules.required` strings, Rails `validates ..., message: "..."`, Django `ValidationError("...")`
- **Error UI components** — strings rendered inside `<Alert>`, `<ErrorMessage>`, `<Toast>`, `<Banner variant="error">`, `<FlashMessage>`, `<Notification type="error">`, `<Snackbar>`, error modal/dialog components named `Error*`, `*ErrorDialog`, `*ErrorModal`
- **HTTP error response bodies destined for the client**`res.status(4xx|5xx).json({ error: "..." })`, `res.status(...).send("...")`, FastAPI `HTTPException(detail="...")`, Rails `render json: { error: "..." }, status: 4xx`, GraphQL `throw new GraphQLError("...")`
- **Error boundary fallbacks** — strings inside React `ErrorBoundary` fallback UI, Vue error handlers, Next.js `error.tsx`/`global-error.tsx`

### Weak signals (read context before including)

- `throw new Error("...")` — only count it if it bubbles to a user-visible boundary (an error boundary, an API error formatter, a global handler that renders the message). If it's caught and reformatted before the user sees it, it's a dev string — skip.
- String literals near `catch` blocks — only if the catch renders or surfaces them.
- Raw `Error` subclasses — `class FooError extends Error { constructor() { super("...") } }` — check whether the `.message` is shown to users anywhere.

### Skip (developer-only, not user-facing)

- `console.*`, `console.error`, `console.warn`
- Logger calls: `logger.*`, `log.error`, `winston.*`, `pino.*`, `Rails.logger.*`, Python `logging.*`, `Sentry.captureMessage`, `Sentry.captureException`, `debug(...)`
- Comments and JSDoc
- Test files: anything under `__tests__`, `tests/`, `spec/`, or matching `*.test.*`, `*.spec.*`, `*_test.go`, `test_*.py`
- Internal `Error()` throws that get caught and reformatted

When in doubt: read the file. Spending 30 seconds to confirm a string actually reaches users beats flagging 100 dev-only strings the user has to ignore.

### Group duplicates

The same error string often appears in many call sites, or the same i18n key gets used everywhere. Group these into one finding (with all locations listed) so a single rewrite fixes them all.

---

## 2. The rubric — score each finding

Each finding gets a severity based on which principles it violates. A single message can violate several at once — list them all in `Issues:`.

### Critical (definitely bad — fix first)

| Principle | What to look for | Bad → Good |
|---|---|---|
| **Generic / no information** | "Something went wrong", "An error occurred", "Failed", "Oops", standalone "Error" | "Something went wrong" → "We couldn't save your post due to a technical issue on our end. Please try again." |
| **Technical jargon** | "fetch", "credentials", "null", "undefined", "exception", "parsing", "schema", "request failed", endpoint paths | "Failed to fetch user data" → "We couldn't load your account" |
| **Leaks internals** | Raw HTTP codes ("Error 500", "401 Unauthorized"), stack traces, exception class names, error IDs without explanation, SQL fragments. These belong in logs, not the UI. A short opaque ref code (e.g. `ref: A8C3`) is OK *alongside* a real message. | "NullPointerException at line 42" → "We couldn't load your dashboard. Try again in a moment. (ref: A8C3)" |
| **Collapses distinct causes** | One generic message catching multiple known causes (network, validation, permissions). If the code can tell which it is, the message must too. Reserve generics for genuinely unknown failures. | One catch-all "Couldn't save" for permission, network, *and* validation errors → branch into "You don't have permission to edit this", "We lost connection — your changes are still here", "Title is required" |
| **Blames the user** | "You entered an invalid…", "You forgot to…", "Wrong password" with no help. Reframe around the *problem*, not the user's action. | "You entered an invalid email" → "That email address doesn't look right — check for typos" |
| **Blames a third party** | "Stripe isn't responding", "GitHub is down", "The API timed out". Even if true, the user came to *your* product. | "Stripe isn't responding" → "We're having trouble connecting to Stripe. Please try again in a moment." |

### Serious (should fix)

| Principle | What to look for | Bad → Good |
|---|---|---|
| **Inappropriate tone** | "Whoops!", "Yikes!", "Uh oh!", exclamation points, ALL CAPS, the literal word "Error" as a title, emoji in serious flows, cutesy/jokey language when stakes are high (payments, data loss, account access) | "Whoops! Payment failed 💸" → "We couldn't process your payment" |
| **Unclear / not specific enough** | Real words, but the user can't tell what to do or what's actually wrong. "Please enter a valid email" doesn't say what's invalid. Name the actual missing or malformed piece. | "Please enter a valid email" → "Add an @ — emails look like [email protected]" · "Allow the requested permissions" → "We need camera access. Open Settings → Privacy → Camera and turn it on." |
| **No fix path** | Tells the user something broke but not what to do next | Add a concrete step: retry, refresh, check X, contact support |
| **No way out** | For unrecoverable errors, no retry / contact / support link | Add support contact, "Try again" button, link to help docs |
| **Buried lede** | The most important info isn't first. Users scan. | "To proceed, please make sure all required fields are completed before submitting" → "Email is required" |
| **Weak CTA verb** | The button says "OK" / "Got it" / "Dismiss" on a recoverable error — closes the dialog without helping. CTAs should name the next action. | "OK" on a payment error → "Try a different card" · "Got it" on a connection error → "Try again" |
| **Standalone announcement won't make sense** | If the message is rendered inside a toast, `aria-live`, or a `role="alert"` region, it's read without visual context. "Required" announced alone is meaningless — include the field name and the fix. | toast: "Required" → "Email is required" |

Members read the full skill.

Join the Founding Club — every skill, field note, and drop while you're a member.