Frontend API Rules

SkillDev tools

Frontend API integration, generated SDK, apiCallWithLoading, blockUi, wrapped responses, toOpenApiError. Use when calling the generated backend API from the frontend.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Frontend API Rules skill

What this skill tells your AI

The instructions your AI receives, as published by nerds-odd-e/doughnut in .agents/skills/frontend-api/SKILL.md and read by ahel’s review.

API Integration

  • Import services from @generated/donut-backend-api/sdk.gen.
  • Wrapped response: { data, error, request, response }.
  • User-initiated actions: apiCallWithLoading (loading bar + error toasts).
  • Direct SDK calls are silent: no loading bar and no error toasts.
import { UserController } from "@generated/donut-backend-api/sdk.gen"
import { apiCallWithLoading } from "@/managedApi/clientSetup"

const { data: newUser, error } = await apiCallWithLoading(() =>
  UserController.createUser({ body: formData })
)

const { data: users, error: loadError } = await UserController.getUserProfile()

Loading indicators

apiCallWithLoading drives two global surfaces from DonutApp.vue:

  • Thin loading bar — any wrapped call in flight.
  • Whole-UI blocking modal{ blockUi: true }. Native <dialog showModal()>, so it paints above other top-layer dialogs.

Use { blockUi: true, message?: string } when the rest of the UI must wait (view transition, or a mutation where partial interaction would confuse state). Show the blocker only after any confirmation. Do not add component-local LoadingModal refs.

const { data, error } = await apiCallWithLoading(
  () => AssimilationController.next({ query: { timezone: timezoneParam() } }),
  { blockUi: true, message: "Loading next note..." }
)

Keep local loading (inline spinner, disabled control) when the user should still use the rest of the page. Those calls can still use apiCallWithLoading without blockUi.

Cancelable blocking calls

For safe read-only blockers, pass { blockUi: true, cancelable: true, message?: string }. Returns CancelableApiResult<T> ({ status: "completed"; result } | { status: "cancelled" }). Narrow on status before using result; do not treat cancel as an API error.

const outcome = await apiCallWithLoading(
  (signal) =>
    AiController.generateRefinementSuggestions({
      path: { note: noteId },
      signal,
    }),
  {
    blockUi: true,
    cancelable: true,
    message: "AI is generating refinement layout...",
  }
)

if (outcome.status === "cancelled") {
  return
}

const { data, error } = outcome.result

Accepted cancel is silent (no toast). Abort is client-only. Do not opt mutations or irreversible writes into cancelable: true. Do not invent AbortError-name matching or a cancelable runWithBlockingApiLoading.

cancelable: true is allowlisted in frontend/tests/managedApi/cancelableAllowlist.spec.ts (ALLOWED_CANCELABLE_FILES). Add a site there when introducing a new cancelable call; do not add cancelable: true outside that set.

For one continuous blocker across multiple calls, wrap them in runWithBlockingApiLoading(operation, message) (noncancelable). Inner apiCallWithLoading calls keep thin bar + toasts only.

Blocking classification

Every new { blockUi: true } or runWithBlockingApiLoading site is cancelable, intentionally noncancelable, or nonblocking. Classify it; do not leave it unclassified.

  • Cancelable — only allowlisted read-only opt-ins (cancelableAllowlist.spec.ts).
  • Intentionally noncancelable — mutations, view transitions that must finish, AI reads with no defined post-cancel UX.
  • Nonblocking — thin-bar apiCallWithLoading without blockUi, or a direct SDK call.

Loading UI that means unfinished work marks data-app-busy (LoadingThinBar, ContentLoader, LoadingModal; gated by frontend/tests/components/commons/AppBusyMarker.contract.spec.ts). E2E: waitUntilAppIsNotBusy() from e2e_test/start/pageBase.ts. The wait means busy UI cleared, not success.

API Return Value Usage

The global client uses responseStyle: "fields" and throwOnError: false.

  1. Destructure with meaningful names: const { data: updatedUser, error } = await updateUser(...).
  2. Check !error before using data; no separate data check.
  3. When error is undefined, TypeScript types data as the success shape.
  4. Do not add runtime property checks for required typed properties.
  5. Do not add else if (data) after checking error.

Validation Errors

For 400 field errors, toOpenApiError(error) ({ errors?: Record<string, string>; message?: string }). apiCallWithLoading already toasts; extract errors only for form fields.

import { toOpenApiError } from "@/managedApi/openApiError"

if (error) {
  errors.value = toOpenApiError(error).errors || {}
} else {
  errors.value = {}
  user.value = updatedUser
}

Signals

GitHub stars
49
Forks
72
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
frontend-api
Source
github.com/nerds-odd-e/doughnut