---
name: frontend-api-client
description: >
  Generates TypeScript service clients + React hooks (useState/useEffect) using
  the `api` HTTP client from @atlashub/smartstack (pre-configured, returns
  unwrapped data) on the canonical API strata (integration: the NavRoute-resolved
  /api/{module}/{section}, or screens: /api/screens/{plural}).
phase: development/frontend
cli: cli/scaffold-api-client
allowed-tools: [Read, Glob, Grep, Bash]  # Bash: CLI invocation
---

# API Client — TypeScript Services + React Hooks

Generates API service clients and React hooks (useState/useEffect) for entity CRUD operations.

Canonical strata paths are used (integration: the NavRoute-resolved
`/api/{module}/{section}` from the entity's `navRoute`, by default; or
`/api/screens/{plural-kebab}` in screen-driven mode), not a client-side route
registry: `navRoutes.generated.ts` was removed from the SDK, so generated code
talks to the API via the SAME `lib/url-conventions.ts` helpers (`buildNavApiPath`)
the backend `[NavRoute]` resolves to — the called URL can never diverge from the
route it hits.

## Output pattern

### Types (`types/index.ts`)

```ts
export interface EmployeeListDto {
  id: string;
  firstName: string;
  lastName: string;
  createdAt: string;
}

export interface EmployeeDetailDto { ... }
export interface CreateEmployeeDto { firstName: string; lastName: string }
export interface UpdateEmployeeDto { firstName?: string; lastName?: string }
export interface PaginatedResult<T> { items: T[]; totalCount: number; page: number; pageSize: number }
```

### Service (`services/employeeService.ts`)

```ts
import { api } from '@atlashub/smartstack';
import type { EmployeeListDto, EmployeeDetailDto, CreateEmployeeDto, UpdateEmployeeDto, PaginatedResult } from '../types';

const API_PATH = '/api/hrm/employees';

export const employeeService = {
  getAll: async (params?: { page?: number; pageSize?: number; search?: string }) => {
    const data = await api.get<PaginatedResult<EmployeeListDto>>(API_PATH, { params });
    return data;
  },
  getById: async (id: string) => {
    const data = await api.get<EmployeeDetailDto>(`${API_PATH}/${id}`);
    return data;
  },
  create: async (payload: CreateEmployeeDto) => {
    const data = await api.post<string>(API_PATH, payload);
    return data;
  },
  update: async (id: string, payload: UpdateEmployeeDto) => {
    await api.put(`${API_PATH}/${id}`, payload);
  },
  delete: async (id: string) => {
    await api.delete(`${API_PATH}/${id}`);
  },
};
```

`api` is the pre-configured HTTP client from `@atlashub/smartstack` — it returns
unwrapped data (not `AxiosResponse`), preconfigured with:
- `baseURL` from `SmartStackProvider` config
- Auth cookie (httpOnly, `withCredentials: true`)
- Tenant header from the active tenant selection
- Retry on 5xx
- Error logging

### Hooks (`hooks/useEmployee.ts`)

```ts
import { useState, useEffect, useCallback } from 'react';
import { employeeService } from '../services/employeeService';
import type { CreateEmployeeDto, UpdateEmployeeDto } from '../types';

export function useEmployees(params?) {
  const [data, setData] = useState<...>();
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const refetch = useCallback(() => {
    setIsLoading(true);
    employeeService.getAll(params).then(setData).catch(setError).finally(() => setIsLoading(false));
  }, [JSON.stringify(params)]);
  useEffect(() => { refetch(); }, [refetch]);
  return { data, isLoading, error, refetch };
}

export function useEmployee(id: string) { ... }
export function useCreateEmployee() { ... }  // returns { mutateAsync, isPending }
export function useUpdateEmployee() { ... }
export function useDeleteEmployee() { ... }
```

## ⚠ BLOCKING — Key Rules

1. **Import `api`** from `@atlashub/smartstack` — not a local `apiClient` or `axios` instance. `api` returns unwrapped data (no `{ data }` destructuring needed).
2. **Canonical strata — two only (Wave E)**. The generator selects between the
   two legal API strata via `routeMode` (default `'integration'`); the
   `useScreens` boolean is a backward-compat alias for `routeMode: 'screens'`:

   | `routeMode` | API_PATH | get/post routes | delete + getLookup |
   |-------------|----------|-----------------|--------------------|
   | `integration` (default) | `/api/{module}/{section}` (NavRoute-resolved from `entity.navRoute`) | REST CRUD (GET /, GET /{id}, POST /, PUT /{id}) | same path |
   | `screens` (alias `useScreens: true`) | `/api/screens/{plural-kebab}` | screen-driven (GET /list, GET /detail/{id}, POST /form, PUT /form/{id}) | falls back to the integration path `/api/{module}/{section}` (declared in `INTEGRATION_PATH`) |

   The screen-driven contract intentionally omits `delete` + `getLookup` — both
   continue to be served by the integration stratum (same Business layer, just
   a different transport). Set `useScreens: true` once Phase 2b of
   `/ba-develop` has produced the corresponding `{EntityPlural}ScreenController.cs`.
   Audit `DEV-API-012` (Wave D) verifies coverage on the backend side.

   **Wave F1 — `${E}ListDto` / `${E}DetailDto` shape**. In screen-driven mode the
   TS DTOs are shaped from the **pagespec columns**, NOT from `entity.fields`.
   Pass `screenColumns: { list, detail }` per entity alongside `useScreens: true`:

   ```ts
   {
     name: 'Contact',
     fields: [...],            // domain shape — still used by Create/Update Dtos
     screenColumns: {
       list:   [ { key: 'fullName', formatHint: 'string' }, { key: 'status', formatHint: 'string' } ],
       detail: [ { key: 'fullName', formatHint: 'string' }, { key: 'totalOrders', formatHint: 'integer' } ],
     },
   }
   ```

   The TS type of each column mirrors `scaffold-screen-controller`'s
   `dotnetTypeFor` so the wire matches byte-for-byte (`currency/integer →
   number`, `datetime → string`, `boolean → boolean`, fallback `string`). When
   `screenColumns` is omitted the generator falls back to ALL `entity.fields`
   (parity with the backend `{E}ListDto` — the historical first-5 slice made
   the TS type lie about the JSON) — useful for projects mid-migration.
3. **Unknown field types fold to `string`** — the correct wire type: `SmartStack.Api` registers `JsonStringEnumConverter`, so enums travel as their string name. (The historical fail-fast throw killed the whole entity's client over one enum field.)
4. **Guard `if (!id) return;`** in detail hooks — avoids a 404 call when id is empty.
5. **`refetch` returned** from list AND detail hooks — pages re-fetch after mutations (post-mutation list reload, detail refresh after a custom action).
6. **`PaginatedResult<T>`** exported alongside DTOs so pages can type their table data.
7. **File upload is NEVER a generated service member** — `customActions` payloads travel as
   JSON (a `File` serializes to `{}`; the pagespec `payloadParameters[].type: 'file'` is NOT
   wired for multipart end-to-end — audit PRD-107 flags authored ones). Uploads go through
   dedicated hand-written endpoints posting `FormData` via `api` (the package's multipart
   interceptor sets the boundary) — pattern:
   `development/backend/data-layer/references/file-storage.md`.

## Spec fields

| Field | Type | Required | Notes |
|---|---|---|---|
| `module` | string | yes | Module code (kebab-case). |
| `appCode` | string | yes | App code (kebab-case). |
| `entities` | array | yes | Entity list with `name`, `section`, `fields`, `customActions`, `screenColumns`, and optionally `pwa` / `versioned` (below). |
| `entities[].pwa` | object | no | `{ "offline": "read"\|"write" }` (SSOT `lib/pwa-meta.ts`). Only `'write'` changes the output — see **Offline write (outbox)** below. |
| `entities[].versioned` | boolean | no | Backend entity implements `IVersionedEntity` — surfaces `rowVersion?: string` (base64) on `{E}DetailDto` + `Update{E}Dto` so the form echoes it (409 on stale concurrent edit). ⚠ BLOCKING: required by `offline: 'write'`. |
| `projectPath` | string | yes | Absolute path to the project root. |
| `routeMode` | enum | no | Route convention: `integration` (default) or `screens` — the two canonical strata. Backward compat: `useScreens: true` = `'screens'`. (Legacy `direct` mode removed 2026-06-25 — it 404'd against the integration backend.) |
| `useScreens` | boolean | no | Deprecated alias for `routeMode: 'screens'`. Default `false`. |
| `httpClient` | enum | no | HTTP client source: `smartstack` (default, uses `api` from `@atlashub/smartstack`) or `axios` (local instance). |
| `webRoot` | string | no | Relative path from `projectPath` to web root. Default `web/{appCode}-web`. |

## Invocation

```bash
npx --prefer-offline tsx skills/development/frontend/api-client/cli/scaffold-api-client/index.ts \
  --spec '{"module":"hrm","appCode":"myapp","entities":[{"name":"Employee","section":"employees","fields":[{"name":"firstName","type":"string","required":true}]}],"projectPath":"/path"}'
```

`--spec-file <path>` reads the same JSON from disk (multi-entity specs with
custom actions outgrow the shell's argv limit).

## Output files (per entity)

```
src/features/{app}/{module}/{entity}/
├── types/index.ts
├── services/{entity}Service.ts
├── hooks/use{Entity}.ts
└── outbox/{entity}Outbox.ts        (only when pwa.offline === 'write')
```

## Offline write (outbox) — `pwa.offline: 'write'`

Emits the entity's **outbox spec module** (`outbox/{entity}Outbox.ts`, modeled
line-for-line on the socle's canonical `timeEntryOutbox.ts` dogfood):

- 3 `OfflineMutationSpec`s (create/update/delete) matched on the SAME
  integration URL the service uses (`BASE_PATH` — zero drift by construction);
  `type` prefix = `{APP}.{module}.{section}` = the page componentKey root =
  the resource key `useOutboxOverlay` and `OutboxStatusChip` fold/filter by.
- Deterministic overlay reducers `apply{E}ListOverlay` (create appends, update
  patches by id, delete filters — `createdAt` order makes offline
  create-then-edit resolve) + `apply{E}DetailOverlay` (patch / tombstone).
- `register{E}Outbox()` — aggregated into `src/extensions/outbox.generated.ts`
  by the `frontend-pwa` skill's `aggregate-outbox` CLI (re-run it after adding
  an entity), imported by `main.tsx` BEFORE `initOutbox()`.
- The list/detail hooks fold pending writes via `useOutboxOverlay` (the app
  has no query cache — the outbox owns the optimistic state).

Deliberate omissions (audited by DEV-PWA-007): `idempotencyKey` (the apiClient
interceptor mints ONE UUID per logical mutation, replayed verbatim) and
`onConflict` (default `server-wins` — the 409 body stays authoritative; custom
strategies = mark the module `// @customised` and edit).

⚠ BLOCKING: `'write'` requires `versioned: true`, refuses `parentPath`
(nested URLs have no outbox match convention in v1) and the `screens` route
mode (outbox specs match the integration stratum). `'read'` emits nothing here
— it is a service-worker concern (`frontend-pwa`).
