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 — e.g. ~/.claude/skills/error-message-audit/ for Claude Code. Works with any agent that reads SKILL.md.

SKILL.md
CopyDownloadJoin the Club ↗
---
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

Members read the full skill.

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