> ## Documentation Index
> Fetch the complete documentation index at: https://docs.daven.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 기존 앱을 Daven에 얹기

> 앱 아이덴티티는 유지하고, 로그인·AI·과금만 Hub와 Daven이 소유한다

이미 있는 교육 웹앱을 Classroom Hub에 올릴 때 쓰는 **재사용 플레이북**입니다. 새 앱을 처음부터 만들 때는 [빠른 시작](/edu/quickstart)과 [에이전트 스펙](/edu/agent-spec)을 먼저 보세요.

첫 적용: [Reading Passport](/edu/reference-app). 다음 적용: **Image Studio**.

<Note>
  이 페이지는 제품 화면을 바꾸라는 문서가 아닙니다. 로그인 UI, API 키, 과금 ID만 Daven 쪽으로 옮기고, 학습 흐름과 브랜드·카피는 앱이 그대로 가집니다.
</Note>

## 한 줄

**앱이 학습 UX와 결과물을 소유한다. Hub가 사람과 교실을 소유한다. Daven이 AI 실행과 학교 지갑을 소유한다. 브라우저는 키와 payer를 모른다.**

## 세 층

| 층                 | 소유                                               | 하지 않는 것                          |
| ----------------- | ------------------------------------------------ | -------------------------------- |
| **앱 아이덴티티**       | 이름, 보이스, 화면 루프, 도메인 프롬프트, 학생이 만든 파일              | Daven 로그인, 학교 지갑 UI, Hub 대시보드 복제 |
| **Classroom Hub** | 학생·교사 로그인, 명단, 활동 배정, 새 탭 Start, 제출 목록           | 앱 속 학습 화면, 모델 키                  |
| **Daven + proxy** | 모델 실행, 견적, `reserve → settle \| release`, 사용량 원장 | 앱 브랜드, 학생 원문 덮어쓰기                |

`@davenai/sdk/edu`(브라우저)와 `@davenai/sdk/server`(BFF)가 두 층을 연결합니다. 앱 코드에 `login()`을 만들지 마세요. 공개 이름은 `session`입니다.

## 불변 조건

아래를 깨면 다른 앱으로 복제하지 마세요.

1. Hub **시작**은 `{hostedUrl}?session=&proxy=&locale=`를 **새 탭**으로 연다. `locale`은 Hub 경로와 같다 (`en` | `kr`). 앱 라우트가 `ko`이면 `kr`을 `ko`로 받는다. 교실 기본 실행을 대시보드 iframe으로 두지 않는다.
2. 학생·교사는 **Hub에서만** 로그인한다. 앱에 Daven / Google / 자체 학생 코드 로그인을 Edu 기본 흐름으로 두지 않는다.
3. 브라우저와 공개 번들에 Daven 키, MCP 키, `OPENAI_API_KEY`, 학교 payer ID를 넣지 않는다.
4. 클라이언트가 `studentId`, `schoolId`, `classroomId`, payer를 고르지 않는다. `GET /api/session/context`만 믿는다.
5. Edu 런타임의 AI는 proxy만 탄다. 세션이 없거나 만료되면 `EDU_RELAUNCH_REQUIRED`로 **닫고**, 운영자 provider 키로 fallback하지 않는다.
6. 사용자 동작마다 안정적인 `operationId`를 한 번 만든다. 재시도는 그 ID에서 결정적으로 파생한다. 본문이 다른 요청에 같은 멱등키를 쓰지 않는다.
7. 앱 안 “저장 / 공개 / 다운로드”와 Hub **최종 제출**은 다른 버튼이다. 제출 `externalArtifactId`는 앱 작품 ID다. 같은 ID 재제출만 기존 Hub 행을 갱신한다.
8. `shouldRelaunchEducationApp(error)`가 true면 작업은 유지하고 Hub에서 다시 열라고 안내한다. 자동 로그인 갱신은 기본값이 아니다.
9. 18세 미만 학생 경로에 Gemini 등 약관상 막힌 provider를 쓰지 않는다.
10. 도메인 프롬프트·검증·화면은 앱에 남긴다. SDK로 옮기는 것은 **transport**뿐이다.
11. 탭 파비콘은 Classroom Hub 마크 `/brand/classroom-logo.png`다. 탭 제목은 앱 이름이다. Next / Vercel 기본 아이콘을 남기지 않는다.

계약 상세: [세션과 proxy](/edu/session-proxy) · [MCP API](/edu/proxy-api) · [학생 제출](/edu/submissions) · [에이전트 스펙](/edu/agent-spec).

## 실행 모드

이미 운영 중인 앱은 한 플래그로 경로만 갈라야 합니다. 기본값을 운영 중에 바꾸지 마세요.

| `EDU_INTEGRATION_MODE` | 인증                       | AI              | 쓸 때       |
| ---------------------- | ------------------------ | --------------- | --------- |
| `legacy`               | 앱 기존 로그인(있다면)            | 운영자 provider 키  | 교실 전환 전   |
| `hybrid`               | Hub 세션이면 Edu, 아니면 legacy | 런타임에 따라 갈림      | 파일럿       |
| `edu_only`             | Hub 세션만                  | proxy만. 키 경로 삭제 | 교실 전용 컷오버 |

모드가 하나뿐이어도 코드에는 이 이름을 쓰세요. 나중에 다른 앱이 같은 플래그를 찾습니다.

로컬·Playground는 **개발자 계정**이 냅니다. 교실은 **학교 관리형 지갑**이 냅니다. 두 경로를 한 요청에서 동시에 차감하지 마세요.

## 책임 경계

<Tabs>
  <Tab title="앱이 유지">
    * 제품 이름, 카피, 학습 루프
    * 도메인 프롬프트와 결과 검증
    * 초안·작품·진행 상태 (앱 DB 또는 브라우저 저장)
    * 앱 고유 교사 도구 (콘텐츠 검수, 진행 보드)
    * 내보내기 포맷 (PDF, PPTX, ZIP)
  </Tab>

  <Tab title="Hub가 가져감">
    * 학생 코드 / 교사 로그인
    * 학교·교실·명단·활동 배정
    * launch session (`student_activity` / `student_preview` / `teacher_manage`)
    * 제출 목록과 교실 소유권
  </Tab>

  <Tab title="Daven이 가져감">
    * 학교 `edu_school` 관리형 계정 (`login_enabled = false`)
    * 모델 allowlist와 가격
    * text 1 credit, image 10 credits (이미지는 `media/get` 완료까지 예약)
    * `usage_events` attribution (`actor`는 Edu 사용자, `billing`은 학교)
  </Tab>
</Tabs>

학생별 Daven 계정을 만들지 않습니다. `edu_user_id`를 기존 `user_id` / `daven_id` 컬럼에 넣지 않습니다.

## 최소 코드

브라우저:

```ts theme={null}
import { createDavenEduClient } from "@davenai/sdk/edu";

const daven = createDavenEduClient({
  expectedAppSlug: "your-registered-app-slug",
});

const context = await daven.session.getContext();
const quote = await daven.billing.quote({ mediaType: "image" });
const image = await daven.ai.image.generate({ prompt });
```

앱 서버가 프롬프트를 조합하면 브라우저로 키를 내리지 말고, 검증된 `session` + `proxy`로 `@davenai/sdk/server`를 만듭니다. SDK에 payer ID를 넘기지 않습니다.

도메인 route는 gateway만 봅니다.

```text theme={null}
app/lib/ai/text-gateway.ts
app/lib/ai/image-gateway.ts
app/lib/edu/runtime-session.ts
app/lib/edu/submission-gateway.ts
```

```text theme={null}
text-gateway.generateText()     → daven.ai.text.generate()
image-gateway.generateImage()   → daven.ai.image.generate()
submission-gateway.submit()     → daven.submissions.submit()
사용자 동작에서 만든 operationId → Idempotency-Key
```

npm에 SDK가 없으면 같은 계약의 내부 transport를 쓰고, publish 뒤 파일만 갈아끼웁니다. route와 프롬프트는 그대로 둡니다.

## 인증 브리지

앱에 **이미 사용자 테이블과 RLS**가 있으면 Reading Passport처럼 합니다.

<Steps>
  <Step title="Launch">
    Hub가 `{hostedUrl}?session=&proxy=`를 새 탭으로 엽니다. SDK가 query를 헤더로 옮기고 주소에서 지웁니다.
  </Step>

  <Step title="Bootstrap">
    앱 서버가 `GET {proxy}/api/session/context`로 서명 세션을 재검증합니다. JWT를 클라이언트가 디코드해 믿지 않습니다.
  </Step>

  <Step title="로컬 principal">
    `edu_actor_id` → 결정적 앱 내부 ID. 기존 FK/RLS를 깨지 않으려면 **로그인 불가** 로컬 principal만 발급합니다. 이것은 별도 계정이 아닙니다.
  </Step>

  <Step title="쿠키">
    session과 proxy는 HttpOnly. 현재 요청의 Edu `actorId`와 서버가 가진 `edu_actor_id`는 항상 같습니다.
  </Step>
</Steps>

앱에 사용자 DB가 **없으면** principal을 만들지 마세요. context의 `actorId`를 런타임 키로만 쓰고, 초안은 브라우저에 둬도 됩니다. Hub 제출 URL만 영구 HTTPS여야 합니다.

이름·학생 코드·이메일로 Edu 사용자를 합치지 마세요. 매칭 키는 `actorId`뿐입니다.

`teacher_manage`는 학생으로 impersonation하지 않습니다. 교사 전용 화면이 있을 때만 카탈로그에 `supportsTeacherManage`를 켭니다.

## AI 어댑터

<Steps>
  <Step title="경계 하나">
    모든 생성은 gateway를 통과합니다. route에서 OpenAI/Gemini SDK를 직접 부르지 않습니다.
  </Step>

  <Step title="런타임 확정">
    요청 앞에서 Edu인지 legacy인지 정합니다. `after()` / worker에도 그 런타임을 **명시적으로** 넘깁니다. 백그라운드에서 쿠키가 사라져도 Edu 작업이 운영자 키로 내려가면 안 됩니다.
  </Step>

  <Step title="견적 → 생성 → 폴링">
    이미지는 `quote` → `media/create` → `media/get`. 텍스트는 `text/create`. 고정 크레딧과 다른 숫자를 UI에 지어내지 않습니다.
  </Step>

  <Step title="실패">
    provider가 명시적으로 실패하면 예약을 해제합니다. 세션 만료는 저장본을 유지하고 재실행을 안내합니다.
  </Step>
</Steps>

프롬프트, 레퍼런스 타일, 안전 규칙, 화면 문구는 앱 파일에 남깁니다.

## 제출

| 앱 동작                    | Hub 동작               |
| ----------------------- | -------------------- |
| 로컬 저장, 도서관 공개, 파일 다운로드  | `POST …/submissions` |
| 초안 URL, blob, localhost | 불가. 공개 영구 HTTPS만     |
| 작품 UUID / 스토리 ID        | `externalArtifactId` |

같은 작품을 다시 내면 그 행만 갱신합니다. 다른 ID는 새 행입니다. 생략하면 `__default__` 한 줄이 됩니다.

제출 전에 생성 이미지를 만료되지 않는 저장소로 복사하세요. 7일 cleanup 버킷 URL을 `primaryUrl`에 넣지 마세요.

## 호스트와 탭 아이콘

다음 앱에도 그대로 복사합니다.

* 공개 URL은 `https://{slug}.edu-app.daven.ai`입니다. DNS는 `*.edu-app.daven.ai` → `cname.vercel-dns.com` 한 장입니다. 앱을 `*.edu.daven.ai`에 두지 마세요. 그 존은 Hub / `api` / `dev`입니다.
* Vercel 프로젝트에 `{slug}.edu-app.daven.ai`만 추가합니다. 와일드카드 DNS는 이미 있습니다.
* 탭 파비콘은 **Classroom Hub 마크**입니다. Hub 파일 `dv-edu/apps/web/public/brand/classroom-logo.png`를 `public/brand/classroom-logo.png`로 복사하고, metadata `icons.icon` / `icons.apple`에 넣습니다.
* 탭 제목은 앱 이름입니다. 예: **Image Studio**. 파비콘은 Edu입니다. 앱 자체 로고, Vibe, Next/Vercel 기본 아이콘을 탭에 쓰지 마세요.
* 운영 Vercel에는 `DAVEN_API_KEY`를 넣지 않습니다. 교실은 Hub session만 씁니다. 키는 로컬 `next dev`와 Playground 개발 모드 전용입니다.
* [Playground](https://daven.ai/playground) Submit → Master Approve. `appSlug`, `appName`, `hostedUrl`을 맞춥니다. 교사 전용 화면이 없으면 `supportsTeacherManage`를 끕니다.
* **카탈로그 Approve 전에** 학생 세션으로 텍스트 1번, 이미지 1번이 실제로 나와야 합니다. 래핑·배포·등록을 한 번에 끝내면 아래 오류가 교실에서 처음 드러납니다.

## Image Studio에서 깨진 것

한 번에 올리면 같은 순서로 다시 깨집니다. 다음 앱은 여기부터 막으세요.

<AccordionGroup>
  <Accordion title="견적 VALIDATION_ERROR — slug or code is required">
    Hub `POST /api/quote` body는 `{ "media_type": "image" | "text" }`뿐입니다. 이걸 `daven.ai/api/mcp/quote`로 넘기면 PHP가 `slug`/`code`를 요구하고 `VALIDATION_ERROR` 422를 줍니다. 앱은 그걸 “Could not make the picture”로 보여 줍니다.

    Hub quote는 **학교 풀 고정 크레딧**을 로컬에서 돌려야 합니다. 이미지 10, 텍스트 1, `billing: school-pool`. Daven MCP 모델 견적이 아닙니다.

    견적은 숫자를 보여줄 뿐입니다. 지금 운영 차감은 `text/create` / `media/create`가 2xx일 때 학교 `remaining_credits`에서 됩니다. 견적 422로 생성을 멈추지 마세요. Reading Passport는 견적을 건너뜁니다.
  </Accordion>

  <Accordion title="모델 생략 = nano-banana-2 (학생 Gemini)">
    `media/create`에 `model`을 안 넣으면 Daven `/image/create` 워크플로 기본값이 **`nano-banana-2`** 입니다. 문서의 `gpt-image-2`가 자동으로 오지 않습니다.

    교실 기본 모델은 **`gpt-image-2.5`** 입니다. 앱과 Hub 둘 다 이 값을 넣으세요. 학생 경로에 Gemini·nano-banana를 쓰지 마세요.

    `gpt-image-2.5` quality는 `fast` | `quality`입니다. `medium`은 클램프되어 `fast`가 됩니다. Hub는 `quality`를 `/image/create`로 넘겨야 합니다.
  </Accordion>

  <Accordion title="설명 다듬기 EMPTY_RESULT">
    `text/create`가 200인데 `data.text`가 `""`이면 앱은 `EMPTY_RESULT` 502를 냅니다. `gpt-5-mini` + `reasoning_effort: minimal` + 작은 `max_output_tokens`(예: 900)면 추론에 토큰을 다 씁니다.

    교실 텍스트는 **`gpt-5.6-luna`**, 출력 토큰 1600 이상, reasoning을 기본으로 넣지 마세요. 빈 본문이면 파생 `operationId`로 한 번 더 치세요. 200 + 빈 텍스트는 실패로 취급하세요.
  </Accordion>

  <Accordion title="그림은 나왔는데 5MB에서 버림">
    Daven 결과 PNG는 5MB를 넘는 경우가 많습니다. 생성·과금 뒤에 `EDU_IMAGE_TOO_LARGE`로 버리면 학생은 502만 봅니다. 다운로드 한도는 약 25MB로 두거나, 받은 뒤 압축하세요.
  </Accordion>

  <Accordion title="제출 Too Large — 페이지 data URL을 한 번에 POST">
    완성 페이지 PNG를 data URL로 모아 `POST /api/studio/submit` 하면 Vercel 함수 본문 한도(약 4.5MB)에 걸립니다. 학생은 413 `FUNCTION_PAYLOAD_TOO_LARGE` 또는 “Too Large”를 봅니다.

    페이지는 Hub `upload-url`로 **한 장씩** 올린 뒤, 제출 본문에는 HTTPS `primaryUrl` + `assets[]`만 넣으세요. 브라우저가 storage에 PUT하고, CORS가 막히면 한 장짜리 JPEG BFF로만 우회합니다. Hub 교사 **제출물** 탭이 그 URL을 엽니다.
  </Accordion>

  <Accordion title="운영 키와 Vercel 함정">
    운영 Vercel에 `DAVEN_API_KEY` / `OPENAI_API_KEY`를 넣지 마세요. 교실은 Hub `session`만 씁니다. 로컬 `next dev` + 개발자 키가 된다고 학생 그림이 되는 게 아닙니다.

    프로젝트 Framework는 **Next.js**여야 합니다. `Other`면 첫 배포가 404입니다. 도메인은 `{slug}.edu-app.daven.ai`만 붙입니다.

    `motion.button`에 Framer `onDrag`가 남으면 React 19 타입 때문에 프로덕션 빌드가 깨집니다. 드래그 props를 빼세요.

    session JWT를 쿠키에 그대로 넣으면 `EDU_SESSION_TOO_LARGE`가 납니다. 쿠키는 짧게, 본문은 서버에서만 읽으세요.
  </Accordion>
</AccordionGroup>

## 단계 (다른 앱에 그대로 복사)

<Steps>
  <Step title="0. 계약">
    `expectedAppSlug`, session v2 purpose, OpenAPI, 크레딧(text 1 / image 10)을 확인합니다.
  </Step>

  <Step title="1. 인증">
    `/edu/bootstrap` 또는 동등한 서버 진입. 로그인 화면을 Edu 모드에서 숨깁니다. 관리자 전용 로그인이 있으면 교실 역할과 분리해 유지해도 됩니다.
  </Step>

  <Step title="2. 비-AI 루프">
    launch → 저장 → 닫기 → 다시 열기. AI 없이 데이터가 남는지 먼저 확인합니다.
  </Step>

  <Step title="3. 텍스트 AI">
    낮은 위험 호출부터 gateway로 옮깁니다. 그다음 structured output.
  </Step>

  <Step title="4. 이미지 AI">
    `model: gpt-image-2.5`를 명시합니다. reference, 비율, job polling. 학생 경로에 Gemini / nano-banana 금지.
  </Step>

  <Step title="5. 학생 스모크">
    Hub **Start**로 텍스트 1번, 이미지 1번. `VALIDATION_ERROR` / `EMPTY_RESULT` / 5MB 폐기가 없는 뒤에만 카탈로그 Approve를 합니다.
  </Step>

  <Step title="6. 제출">
    영구 URL + 안정 `externalArtifactId` + 명시 버튼. 자동 제출 없음.
  </Step>
</Steps>

완료 기준:

* 네트워크 탭에 `daven.ai` / `app.daven.ai` 브라우저 호출이 없다.
* 키 없이 같은 generate를 치면 401 또는 relaunch이지, 운영자 키가 끼어들지 않는다.
* 세션을 지워도 초안이 남고, AI만 막힌다.
* 교사 preview 세션으로는 제출이 거절된다.

## 하지 말 것

* Hub를 앱 안에 iframe으로 넣거나, 앱을 Hub 대시보드 iframe이 기본이 되게 하기
* Daven 마케팅 화면, 프로필, 결제, `auth.daven.ai`를 학생에게 보여 주기
* 앱을 SEED Classroom Hub처럼 다시 그리기 — Hub만 SEED다
* Vibe 워드마크·Braid 그라디언트를 교육앱 브랜드로 쓰기
* Next / Vercel 기본 파비콘을 탭에 남기기 — Edu `classroom-logo.png`를 씁니다
* “플랫폼 / 솔루션 / AI 키”를 학생 버튼에 쓰기

화면 토큰과 컴포넌트 규칙은 앱 디자인 문서를 따르되, **대기·빈 칸·오류·i18n(EN 기본 / KR 보조)** 은 Vibe foundations와 같은 층으로 맞추면 이후 앱이 같은 손을 씁니다. 제품 루프(채팅+프리뷰+Publish)는 복사하지 마세요.

## 참고 구현

| 앱                | 보는 파일                                                                                                                         |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Reading Passport | `docs/core/daven-edu-sdk-target-architecture.md`, `docs/plans/2026-09-10-edu-integration-plan.md`, `src/lib/edu/*-gateway.ts` |
| Image Studio     | `edu/image-studio/docs/plans/2026-09-20-edu-integration-plan.md`                                                              |
| 시화 스튜디오          | Playground·개발자 키 패턴만. 교실에서는 쓰지 않음                                                                                             |
