reopt designreopt design
DocsExploreToolsPricingBuilder
Login
Start
Overview
Start
Core Concepts
Core Concepts
Surface
Surface 카탈로그
Build & Operate
서버 계약
Production readiness
Obrandapp-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • About
  • Contact
  • Terms of Service
  • Privacy Policy

reopt Inc.Business registration no. 217-88-02453contact@reopt.ai

© 2026 reopt Inc. All rights reserved.

Build & Operate
  1. Docs
  2. /
  3. Build & Operate
  4. /
  5. 서버 계약

서버 계약

clientSecret이 필요한 BrandApp 기능은 Surface에 직접 넣지 않습니다. server route가 SDK를 호출하고 Surface는 endpoint만 받습니다.

reopt design · Updated Sep 18, 2026

개요시작하기핵심 개념Surface 카탈로그서버 계약Production readiness

1. Contract matrix

SurfaceEndpointsSecret boundary
ReoptAiChatchatEndpoint POST, optional modelsEndpoint GET, agentsEndpoint GET, creditsEndpoint GETgetBrandappProvider/getBrandappSDK stays behind server route handlers
ReoptAiImageStudiogenerateEndpoint POST, optional image modelsEndpoint GETsdk.ai.generateImage and typed errors stay on server
ReoptRecordTablerecordsEndpoint GETsdk.eav.records.list(entityId) stays on server

2. Lazy server clients

Next.js build와 static evaluation은 route module을 runtime env 없이 평가할 수 있습니다. BrandApp SDK와 AI provider는 module scope에서 만들지 말고, server-only helper의 lazy getter 안에서 초기화합니다.

ts
// lib/brandapp-server.ts
import "server-only";

import { createLazySDK } from "@reopt-ai/brandapp-sdk";
import { createBrandappProvider } from "@reopt-ai/brandapp-sdk/ai-provider";

function requireEnv(name: string) {
  const value = process.env[name];
  if (!value) throw new Error(name + " is required");
  return value;
}

function getBrandappConfig() {
  return {
    clientId: requireEnv("BRANDAPP_CLIENT_ID"),
    clientSecret: requireEnv("BRANDAPP_CLIENT_SECRET"),
    brandappId: requireEnv("BRANDAPP_ID"),
  };
}

// 첫 접근 때 초기화되는 SDK 싱글턴 — module scope에서 env를 읽지 않습니다.
const sdk = createLazySDK(getBrandappConfig);

export function getBrandappSDK() {
  return sdk;
}

let provider: ReturnType<typeof createBrandappProvider> | null = null;

export function getBrandappProvider() {
  provider ??= createBrandappProvider(getBrandappConfig());
  return provider;
}

3. AI chat route

ReoptAiChat은 opt-chat UI와 transport를 제공합니다. 실제 BrandApp AI provider, model fallback, typed error copy는 route handler에서 결정합니다.

ts
// app/api/chat/route.ts
import { APICallError } from "@ai-sdk/provider";
import {
  isCreditLimitError,
  isModelAccessError,
} from "@reopt-ai/brandapp-sdk";
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  streamText,
  toUIMessageStream,
} from "ai";

import { getBrandappProvider } from "@/lib/brandapp-server";

export async function POST(req: Request) {
  const { messages, model, agentId } = await req.json();
  const brandapp = getBrandappProvider();

  const result = streamText({
    model: brandapp(model ?? "anthropic/claude-haiku-4.5"),
    messages: await convertToModelMessages(messages),
    // agentId는 BrandApp agent routing 정책에 맞게 route에서 해석합니다.
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      onError: (error) => {
        const cause = APICallError.isInstance(error) ? error.cause : error;
        if (isCreditLimitError(cause)) return "AI 크레딧을 모두 사용했습니다.";
        if (isModelAccessError(cause)) {
          return "현재 플랜에서 사용할 수 없는 모델입니다.";
        }
        return cause instanceof Error
          ? cause.message
          : "생성 중 오류가 발생했습니다.";
      },
    }),
  });
}
ts
// app/api/brandapp-models/route.ts
import type { ReoptAiModelsResponse } from "@/components/reopt-ai-chat";
import { getBrandappSDK } from "@/lib/brandapp-server";

export async function GET() {
  const sdk = getBrandappSDK();
  const models = await sdk.ai.models();

  // Surface가 export하는 응답 계약 타입으로 shape를 고정합니다.
  const body: ReoptAiModelsResponse = models
    .filter((model) => model.modality === "chat")
    .sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
    .map((model) => ({
      id: model.id,
      label: model.displayName,
      description: model.description,
      provider: model.provider,
      ...(model.isDefault ? { badge: "기본" } : {}),
    }));

  return Response.json(body);
}

4. Image generation route

ReoptAiImageStudio는 typed error code를 사용자 친화 메시지로 매핑할 수 있습니다. 서버 route가 SDK error를 { error: { code, message } } shape로 내려주면 됩니다.

ts
// app/api/brandapp-image/route.ts
import { isReoptSDKError } from "@reopt-ai/brandapp-sdk";

import type {
  ReoptImageGenerateRequest,
  ReoptImageGenerateResponse,
} from "@/components/reopt-ai-image-studio";
import { getBrandappSDK } from "@/lib/brandapp-server";

export async function POST(req: Request) {
  const sdk = getBrandappSDK();
  const body = (await req.json()) as ReoptImageGenerateRequest;

  try {
    const result: ReoptImageGenerateResponse = await sdk.ai.generateImage(body);
    return Response.json(result);
  } catch (error) {
    if (isReoptSDKError(error)) {
      return Response.json(
        { error: { code: error.code, message: error.message } },
        { status: error.status },
      );
    }
    throw error;
  }
}

5. EAV record proxy

ReoptRecordTable은 array, data, records 응답을 모두 행 배열로 정규화합니다. 권장 응답은 Surface가 export하는 ReoptRecordsResponse로, EAV list 결과에 attributeLabels를 더한 shape입니다. page/onPageChange를 쓰면 응답의 total/limit로 서버 페이지네이션 컨트롤이 그려집니다.

ts
// app/api/brandapp-records/route.ts
import type { ReoptRecordsResponse } from "@/components/reopt-record-table";
import { getBrandappSDK } from "@/lib/brandapp-server";

export async function GET(req: Request) {
  const sdk = getBrandappSDK();
  const { searchParams } = new URL(req.url);
  const entityId = searchParams.get("entityId");
  if (!entityId) return Response.json({ records: [] });

  // list limit은 최대 100. record.values는 attribute id 키입니다.
  const [entity, result] = await Promise.all([
    sdk.eav.entities.get(entityId),
    sdk.eav.records.list(entityId, {
      page: Number(searchParams.get("page") ?? 1),
      limit: 50,
    }),
  ]);

  const body: ReoptRecordsResponse = {
    ...result,
    // id → 라벨 맵을 함께 내려주면 자동 추론 컬럼 헤더가 읽기 쉬워집니다.
    attributeLabels: Object.fromEntries(
      entity.attributes.map((a) => [a.id, a.label ?? a.name]),
    ),
  };

  return Response.json(body);
}

6. 401 session event

보호된 API가 401을 반환하면 notifySessionExpired()가 SessionExpiredDialog를 엽니다. 같은 파일이 export하는 withSessionExpiry(fetch)로 앱의 fetch를 감싸면 되고, 데이터 Surface에는 fetcher prop으로 넘깁니다.

ts
// lib/protected-fetch.ts
import { withSessionExpiry } from "@/components/session-expired-dialog";

// 401을 받으면 notifySessionExpired()를 호출하는 fetch. 데이터 Surface의
// fetcher prop에 그대로 넘겨도 됩니다: <ReoptRecordTable fetcher={protectedFetch} />
export const protectedFetch = withSessionExpiry(fetch);
PreviousSurface 카탈로그brandapp-ui 인증, AI, EAV, 운영 콘솔 Surface 선택 기준과 설치 명령Surface
Go to Surface 카탈로그
NextProduction readinessbrandapp-ui registry, auth state matrix, secret boundary, consumer smoke, docs 검증 체크리스트Build & Operate