See the earlier posts:
- Building a Serverless Firebase Web Chat with Vite and TypeScript
- Setting Up a FastAPI Backend API Project
- Implementing Firebase Authentication in FastAPI
Following those posts in order, I built the chat application and backend and implemented Firebase authentication. I then added AI chat by making API requests from the chat application.
I have Anthropic's paid Max plan, which gives me access to Claude Code. I could have built this with an Anthropic or OpenAI API key, but each AI response would incur a cost, so I used Claude Code for the AI chat feature.
Project Overview
Background
“Kid Chat” is a simple chat app for family communication. I wanted to add a feature where children could call an AI character by name and get a reply from a friend with a distinct personality.
Meet the AI Characters
| Character | Trigger | Personality | Traits |
|---|---|---|---|
| 🩷 Mallang | “말랑아” (Hey, Mallang) | A caring friend | Warm and empathetic; offers praise and encouragement |
| 💛 Lupin | “루팡아” (Hey, Lupin) | A confident friend | Slightly cheeky but helpful and humorous |
| 💚 Pudding | “푸딩아” (Hey, Pudding) | A cute pet | Uses sound effects; simple and innocent |
| 🩵 Mycall | “마이콜아” (Hey, Mycall) | An English teacher | Replies in English; educational |
Technology Stack
- Backend: FastAPI + Python 3.12
- Frontend: Vite + TypeScript
- Database: PostgreSQL + SQLAlchemy
- Authentication: Firebase Authentication
- AI: Claude Code CLI (subprocess)
Architecture
┌─────────────┐ JWT Token ┌─────────────┐ ┌─────────────┐
│ Kid Chat │─────────────▶│ Backend API │────▶│ Claude CLI │
│ (Vite + TS) │◀─────────────│ (FastAPI) │◀────│ (subprocess)│
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ Login/Signup │ Token 검증
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Firebase │◀─────────────│ Firebase │
│ Auth │ Admin SDK │ Admin SDK │
└─────────────┘ └─────────────┘
Implementing Firebase Authentication
1. Configure the Firebase Admin SDK
First, initialize the Firebase Admin SDK. Authenticate with a service-account key file.
# app/firebase.py
import firebase_admin
from firebase_admin import auth, credentials
from app.config import get_settings
settings = get_settings()
_firebase_app = None
def get_firebase_app():
global _firebase_app
if _firebase_app is None:
cred = credentials.Certificate(settings.firebase_credentials_path)
_firebase_app = firebase_admin.initialize_app(cred)
return _firebase_app
def verify_firebase_token(id_token: str) -> dict:
"""Firebase ID 토큰을 검증하고 사용자 정보를 반환합니다."""
get_firebase_app()
decoded_token = auth.verify_id_token(id_token)
return decoded_token2. Authentication Dependency
I used FastAPI's Depends to create a reusable authentication dependency.
# app/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from app.firebase import verify_firebase_token
security = HTTPBearer()
class FirebaseUser(BaseModel):
uid: str
email: str | None = None
name: str | None = None
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> FirebaseUser:
"""Firebase 토큰을 검증하고 현재 사용자를 반환합니다."""
token = credentials.credentials
try:
decoded_token = verify_firebase_token(token)
return FirebaseUser(
uid=decoded_token["uid"],
email=decoded_token.get("email"),
name=decoded_token.get("name")
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid authentication: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)AI Chat API Design
Requirements
- Respond only to messages beginning with a character's Korean trigger: 말랑아 / 루팡아 / 푸딩아 / 마이콜아 (Mallang / Lupin / Pudding / Mycall)
- Respond with each character's unique personality
- Reply concisely like a friend, within 100 words
- Do not modify files, for safety
- Use web search when up-to-date information is needed
Pydantic Schemas
# app/schemas/ai.py
from pydantic import BaseModel, Field
class ChatRequest(BaseModel):
prompt: str = Field(
...,
min_length=1,
max_length=10000,
description="AI에게 보낼 메시지"
)
timeout_seconds: int | None = Field(
default=None,
ge=10,
le=300,
description="응답 대기 시간 (초)"
)
class ChatResponse(BaseModel):
response: str = Field(..., description="AI 응답")
elapsed_time_ms: int = Field(..., description="처리 시간 (밀리초)")
truncated: bool = Field(default=False, description="응답 잘림 여부")
persona: str | None = Field(
default=None,
description="응답한 AI 캐릭터 (말랑이/루팡/푸딩/마이콜)"
)API Endpoint
# app/routers/ai.py
from fastapi import APIRouter, Depends, HTTPException, status
from app.dependencies import get_current_user, FirebaseUser
from app.schemas.ai import ChatRequest, ChatResponse
from app.services.claude_service import ClaudeService, get_claude_service
router = APIRouter(prefix="/ai", tags=["ai"])
@router.post("/chat", response_model=ChatResponse)
async def chat(
request: ChatRequest,
user: FirebaseUser = Depends(get_current_user),
claude_service: ClaudeService = Depends(get_claude_service),
):
"""AI와 대화합니다. Firebase 인증이 필요합니다."""
try:
result = await claude_service.chat(
prompt=request.prompt,
timeout_seconds=request.timeout_seconds
)
return ChatResponse(
response=result.output,
elapsed_time_ms=result.elapsed_ms,
truncated=result.truncated,
persona=result.persona_name # AI 캐릭터 이름 반환
)
except ValueError as e:
# AI 트리거가 감지되지 않은 경우
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except TimeoutError:
raise HTTPException(
status_code=status.HTTP_408_REQUEST_TIMEOUT,
detail="AI response timed out"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"AI service error: {str(e)}"
)Claude Code CLI Integration
The Core Idea
Call Claude Code CLI as a subprocess to generate AI responses. Run it automatically with --dangerously-skip-permissions, and add character-specific system instructions to the prompt.
The AI Persona System
Persona Definitions
# app/services/personas.py
from enum import Enum
from dataclasses import dataclass
class PersonaType(str, Enum):
MALLANGI = "mallangi" # 말랑이
LUPIN = "lupin" # 루팡
PUDDING = "pudding" # 푸딩
MICHAEL = "michael" # 마이콜
@dataclass
class Persona:
type: PersonaType
name: str # 한글 이름
triggers: list[str] # 호출 트리거
system_prompt: str # 캐릭터 프롬프트
# 페르소나 정의
PERSONAS = {
PersonaType.MALLANGI: Persona(
type=PersonaType.MALLANGI,
name="말랑이",
triggers=["말랑아", "말랑이야"],
system_prompt="""너는 '말랑이'야. 다정하고 따뜻한 친구야.
- 항상 공감하고 칭찬해줘
- 부드럽고 친근한 말투를 사용해
- "~해줄게", "~하자" 같은 표현을 사용해
- 이모지를 적절히 사용해 (💕, 🤗, ✨)"""
),
PersonaType.LUPIN: Persona(
type=PersonaType.LUPIN,
name="루팡",
triggers=["루팡아", "루팡이야"],
system_prompt="""너는 '루팡'이야. 자신감 넘치는 친구야.
- 약간 건방지지만 실력이 있어
- "흥, 그 정도는 쉽지~" 같은 말투
- 도움을 줄 때도 쿨하게
- 가끔 유머도 섞어서"""
),
PersonaType.PUDDING: Persona(
type=PersonaType.PUDDING,
name="푸딩",
triggers=["푸딩아", "푸딩이야"],
system_prompt="""너는 '푸딩'이야. 귀여운 애완동물이야.
- 의성어를 많이 사용해 (멍멍, 왈왈, 낑낑)
- 단순하고 순수하게 반응해
- 꼬리 흔들며 기뻐하는 느낌으로
- 짧고 귀엽게 대답해"""
),
PersonaType.MICHAEL: Persona(
type=PersonaType.MICHAEL,
name="마이콜",
triggers=["마이콜아", "마이콜이야"],
system_prompt="""너는 '마이콜'이야. 친절한 영어 선생님이야.
- 한국어와 영어를 섞어서 대답해
- 영어 표현을 가르쳐줄 때 발음도 알려줘
- "In English, we say..." 같은 표현 사용
- 재미있게 영어를 알려줘"""
),
}Persona Detection
def detect_persona(message: str) -> tuple[PersonaType, str]:
"""메시지에서 페르소나 트리거를 감지합니다.
Returns:
tuple[PersonaType, str]: (페르소나 타입, 트리거 제거된 실제 질문)
Raises:
ValueError: 트리거가 감지되지 않은 경우
"""
message_lower = message.strip().lower()
for persona_type, persona in PERSONAS.items():
for trigger in persona.triggers:
if message_lower.startswith(trigger.lower()):
# 트리거 제거하고 실제 질문만 추출
actual_prompt = message[len(trigger):].strip()
return persona_type, actual_prompt
raise ValueError("no_trigger: AI 캐릭터 호출이 감지되지 않았습니다")System Prompts for Each Character
BASE_INSTRUCTIONS = """
다음 규칙을 반드시 따르세요:
- 파일을 절대 수정하지 않습니다
- 100단어 이내로 간결하게 대답합니다
- 최신 정보가 필요하면 웹검색을 활용합니다
- 아이들에게 적합한 언어를 사용합니다
"""
def get_persona_prompt(persona_type: PersonaType, user_message: str) -> str:
"""캐릭터별 시스템 프롬프트와 사용자 메시지를 조합합니다."""
persona = PERSONAS[persona_type]
return f"""{persona.system_prompt}
{BASE_INSTRUCTIONS}
사용자 메시지: {user_message}
"""ClaudeService Implementation
# app/services/claude_service.py
import asyncio
import time
from dataclasses import dataclass
from app.config import get_settings
from app.services.personas import detect_persona, get_persona_prompt, PERSONAS
settings = get_settings()
@dataclass
class ClaudeResponse:
output: str
elapsed_ms: int
truncated: bool = False
persona_name: str | None = None # AI 캐릭터 이름
class ClaudeService:
def __init__(self):
self.cli_path = settings.claude_cli_path
self.default_timeout = settings.claude_timeout_seconds
self.max_timeout = settings.claude_max_timeout_seconds
async def chat(
self,
prompt: str,
timeout_seconds: int | None = None
) -> ClaudeResponse:
# 1. 페르소나 감지 (없으면 ValueError 발생)
persona_type, actual_prompt = detect_persona(prompt)
persona = PERSONAS[persona_type]
# 2. 캐릭터별 프롬프트 생성
full_prompt = get_persona_prompt(persona_type, actual_prompt)
timeout = min(
timeout_seconds or self.default_timeout,
self.max_timeout
)
cmd = [
self.cli_path,
"--dangerously-skip-permissions",
"-p",
full_prompt
]
start_time = time.time()
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL, # systemd 환경에서 필수!
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=timeout
)
except asyncio.TimeoutError:
process.kill()
raise TimeoutError(f"Claude CLI timed out after {timeout}s")
elapsed_ms = int((time.time() - start_time) * 1000)
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
raise RuntimeError(f"Claude CLI failed: {error_msg}")
output = stdout.decode().strip()
# 응답 길이 제한 (안전장치)
MAX_LENGTH = 5000
truncated = len(output) > MAX_LENGTH
if truncated:
output = output[:MAX_LENGTH] + "..."
return ClaudeResponse(
output=output,
elapsed_ms=elapsed_ms,
truncated=truncated,
persona_name=persona.name # 말랑이, 루팡, 푸딩, 마이콜
)
# 의존성 주입용
def get_claude_service() -> ClaudeService:
return ClaudeService()Additional Configuration
# app/config.py
class Settings(BaseSettings):
# ... 기존 설정 ...
# Claude CLI
claude_cli_path: str = "claude"
claude_timeout_seconds: int = 120
claude_max_timeout_seconds: int = 300Conclusion
Completed Features
- Firebase authentication: JWT-based user authentication
- AI chat API: generate AI responses using Claude Code CLI
- AI personas: four characters with distinct personalities and response styles
- Security: mandatory authentication, prompt-length limits, and timeouts
Project Structure
backend-api/
├── app/
│ ├── main.py # FastAPI 앱 엔트리포인트
│ ├── config.py # 설정 (pydantic-settings)
│ ├── firebase.py # Firebase Admin SDK 초기화
│ ├── dependencies.py # 인증 의존성
│ ├── routers/
│ │ ├── auth.py # /auth 엔드포인트
│ │ └── ai.py # /ai 엔드포인트
│ ├── schemas/
│ │ └── ai.py # Pydantic 스키마
│ └── services/
│ ├── claude_service.py # Claude CLI 서비스
│ └── personas.py # AI 페르소나 정의
├── tests/
│ ├── test_auth.py # 인증 테스트
│ └── test_ai.py # AI 채팅 테스트
└── requirements.txt
Troubleshooting
AI chat worked correctly in local development, but after deployment to production:
"인증이 만료되었습니다. 다시 로그인해주세요." (401)
→ "AI 서비스를 이용할 수 없습니다." (503)
→ "Failed to fetch" (타임아웃)
As the error messages changed one after another, I discovered three separate problems.
Problem 1: Firebase Authentication Failure (401)
Symptoms
INFO: 192.168.0.1:0 - "POST /ai/chat HTTP/1.1" 401 Unauthorized
The frontend showed an “Authentication has expired” error.
Diagnosing the Cause
Cause: the Firebase service-account key file was missing from the server.
Fix
Copy the file from the local machine to the server:
Problem 2: Claude CLI Not Found (503)
Symptoms
Claude CLI not found at: claude
INFO: 192.168.0.1:0 - "POST /ai/chat HTTP/1.1" 503 Service Unavailable
Diagnosing the Cause
Check the Claude CLI installation:
which claude && claude --version
# /home/funq/.local/bin/claude
# 2.0.75 (Claude Code)The CLI was installed, but the systemd service could not find it.
Cause: the systemd service's PATH did not include ~/.local/bin.
# /etc/systemd/system/backend-api.service
Environment="PATH=/home/funq/dev/backend-api/venv/bin"Fix
Add the full path to .env:
echo 'CLAUDE_CLI_PATH=/home/funq/.local/bin/claude' >> ~/backend-api/.env
sudo systemctl restart backend-apiNote: config.py reads and uses this environment variable:
class Settings(BaseSettings):
claude_cli_path: str = "claude" # .env의 CLAUDE_CLI_PATH로 오버라이드됨Lessons
- A systemd service runs in a different environment from the user's shell
- Always using absolute paths for external CLI tools is recommended
Problem 3: Claude CLI Timeout
Symptoms
After fixing the CLI path, another error appeared:
Claude CLI timeout after 120060ms
It failed after 120 seconds, the default timeout.
Diagnosing the Cause
Step 1: Test Directly in the Terminal
timeout 30 claude -p "안녕" --dangerously-skip-permissions
# 성공! "안녕하세요! FastAPI 백엔드 프로젝트에서 무엇을 도와드릴까요?"It worked correctly in the terminal.
Step 2: Check the systemd Environment
sudo cat /etc/systemd/system/backend-api.service[Service]
User=funq
Group=funq
WorkingDirectory=/home/funq/dev/backend-api
Environment="PATH=/home/funq/dev/backend-api/venv/bin"
ExecStart=/home/funq/dev/backend-api/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000Discovery: the HOME environment variable was missing! Claude CLI needs HOME to find its ~/.claude/ configuration.
Step 3: Test Without a TTY
systemd runs without a TTY (terminal):
timeout 30 setsid claude -p "안녕" --dangerously-skip-permissions </dev/null 2>&1
# 성공!It worked without a TTY too. The problem was elsewhere...
Step 4: Analyze the Python Code
# app/services/claude_service.py
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
# stdin이 없음!
)Root cause: stdin was not explicitly set, so the subprocess inherited its parent's stdin. Under systemd, stdin can be in a special state instead of /dev/null, leaving Claude CLI waiting.
Fix
1. Modify the systemd Service File
[Service]
User=funq
Group=funq
WorkingDirectory=/home/funq/dev/backend-api
Environment="PATH=/home/funq/dev/backend-api/venv/bin:/home/funq/.local/bin"
Environment="HOME=/home/funq"
ExecStart=/home/funq/dev/backend-api/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=3Changes:
- Add
HOME=/home/funq - Add
~/.local/binto PATH
sudo systemctl daemon-reload
sudo systemctl restart backend-api2. Modify the Python Code
# app/services/claude_service.py
process = await asyncio.create_subprocess_exec(
*cmd,
stdin=asyncio.subprocess.DEVNULL, # 추가!
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)Add stdin=asyncio.subprocess.DEVNULL so the subprocess does not wait for stdin.





Comments
Korean and English pages share this conversation.
Loading comments…