TLDR: The toast contract. Error toasts come from
onMutationErrorin the mutation hooks underpackages/lib/src/core/[domain]/hooks/, so an app never toasts a mutation's error itself. Success toasts are rare and live in the hook too. A toast's action button needspointer-eventsof its own, because it often appears over a modal. Persistent cross-user notifications are a different system, and it raises their toasts: NOTIFICATION-CENTER.md.
This doc governs every toast in the apps: who raises it and where its words come from. Read it before you write a mutation hook or handle an error in a component. The toasts are Sonner's, drawn by the Toaster that EigenApp mounts (LAYOUT.md). The idea behind the rules is that feedback lives next to the request: the hook that sends a mutation also says how it went, so every app says the same thing for the same outcome.
Every useMutation in packages/lib/src/core/[domain]/hooks/ hands its error to onMutationError (packages/lib/src/core/api-error.ts), which calls toast.error(getErrorMessage(error)). An optimistic mutation rolls its cache back first and then calls it. One place owns the wording, so every app says the same thing for the same failure.
AppError keeps the HTTP status of an Eden response, so the toast reads as a message and its status, like "Too large (413)". The message comes from the response body: a plain string (what every ApiError returns), else its message, else its summary, else the first errors entry. A body with none of those falls back to a phrase for the status, because Elysia strips a validation error down to { type, on, found } in production, and String() on that reads as [object Object]. A schema field with an error option answers that string instead, in production too, so a route whose 422 a person may read names the field and its range there (the backup settings' keep and hourUtc).
An app must not wrap a mutation in try/catch + toast.error(): the hook already toasted, and the user would see it twice. An app catches only when it has extra work to do on failure, such as resetting UI state, and then it shows no toast.
A caller that awaits mutateAsync and must still see other rejections asks wasToasted(error): onMutationError records every error it toasts, so the caller swallows exactly those (CardForm does this).
A dialog whose action fails stays open beside the toast, so the user can try again (useDialogPending, which ConfirmDialog, DeleteDialog, FormDialog and the pickers use). Only a yes/no confirm closes on a refusal (ConfirmDialog passes closeOnRefusal): an AppError with a 400, 403, 404 or 422 (isRefusal), such as the organization's default team that can't be deleted, gets the same answer on every try. A picker with input, such as DriveLocationPicker or ShareAndSendDialog, stays open on a refusal, because a 403 on a read-only folder or a 422 on a name is fixed by changing what the user typed or picked. A hook whose dialog should close on a refusal throws an AppError carrying the server's status, as useRemoveTeam does with better-auth's error. An error no toast has shown, such as a bug in a synchronous Docs submit, is not swallowed: useDialogPending rethrows it, so it reaches the console instead of leaving a button that does nothing.
A hook that recovers an expected error shows its own message instead and skips onMutationError. A contact write that meets a 412 reloads the list and says the contact changed elsewhere (handleStaleWrite in use-contacts.ts). Such a hook still passes the error to markToasted, so wasToasted is true for it and a dialog that awaits the write drops it instead of logging it.
Most mutations need no success toast: the UI update from cache invalidation is the feedback. Add one only when the result isn't visible: the user navigated away, the change is subtle, or the work runs in the background. useSendDraft says "Email sent" because the user left the draft. useUpdateACL says "Sharing updated" because an access change shows nowhere else. Grep toast.success under packages/lib/src/core for the rest.
The toast lives in the hook's onSuccess, next to the mutation, never in a component. One call site owns both the request and its feedback, and every app gets the same wording.
A toast that is not one mutation's outcome is raised where the action is: mail's Undo toast after an archive (apps/mail/src/components/mail/hooks/use-mail-actions.ts) spans several mutations, and a paste with nothing to place is no request at all.
A toast with an action (the "Open folder" row after a save to Drive) is often raised from inside a modal. SaveToDrivePicker awaits its mutation, so the picker is still open when the hook toasts, and a convert stacks the progress dialog on top. A Radix modal parks pointer-events: none on <body> for as long as one is mounted, and the toaster lives under <body>. So Toaster gives every toast pointer-events: auto of its own (packages/ui/src/components/sonner.tsx). Without it the action draws and swallows every click.
- NOTIFICATION-CENTER.md: the sources, storage and coalescing of persistent notifications
- ACTIVITY-ROWS.md: what each notification row says and where it links
- SSE.md: the event stream that carries them