Expo 빠른 시작
Expo 빠른 시작
이 퀵스타트 튜토리얼에서는 Expo를 사용해서 스트리밍 채팅 사용자 인터페이스를 갖춘 간단한 에이전트를 만들어 볼 거예요. 그 과정에서 여러분의 프로젝트에 SDK를 사용할 때 기본이 되는 핵심 개념과 기술들을 배우게 됩니다. 만약 프롬프트 엔지니어링과 HTTP 스트리밍의 개념이 익숙하지 않다면, 이 문서들을 먼저 읽어도 좋아요.
출처: 문서
본문
사전 준비 사항
이 빠른 시작을 따라 하려면 다음이 필요합니다:
- 로컬 개발 머신에 Node.js 22+ 및 pnpm이 설치되어 있어야 합니다.
- Vercel AI Gateway API 키가 필요합니다.
아직 Vercel AI Gateway API 키를 발급받지 않았다면 Vercel 웹사이트에서 가입하여 발급받을 수 있습니다.
애플리케이션 만들기
새 Expo 애플리케이션을 만드는 것부터 시작합니다. 이 명령은 my-ai-app이라는 새 디렉터리를 만들고 그 안에 기본 Expo 애플리케이션을 설정합니다.
새로 생성된 디렉터리로 이동합니다:
의존성 설치
ai와 @ai-sdk/react를 설치합니다. 이는 AI 패키지와 AI SDK의 React 훅입니다. AI SDK의 Vercel AI Gateway provider는 ai 패키지에 포함되어 있습니다. 또한 도구 입력을 정의하는 데 사용되는 스키마 검증 라이브러리인 zod도 설치합니다.
AI Gateway API 키 구성
프로젝트 루트에 .env.local 파일을 만들고 AI Gateway API 키를 추가합니다. 이 키는 Vercel AI Gateway에 애플리케이션을 인증합니다.
.env.local 파일을 편집합니다:
AI_GATEWAY_API_KEY=xxxxxxxxx
xxxxxxxxx를 실제 Vercel AI Gateway API 키로 바꿉니다.
API 라우트 만들기
라우트 핸들러 app/api/chat+api.ts를 만들고 다음 코드를 추가합니다:
import {
streamText,
UIMessage,
convertToModelMessages,
createUIMessageStreamResponse,
toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: __MODEL__,
messages: await convertToModelMessages(messages),
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'none',
},
});
}
이 코드에서 어떤 일이 일어나는지 살펴보겠습니다:
- 비동기
POST요청 핸들러를 정의하고 요청 본문에서messages를 추출합니다.messages변수는 사용자와 챗봇 간의 대화 기록을 포함하며, 챗봇이 다음 생성을 수행하는 데 필요한 컨텍스트를 제공합니다. ai패키지에서 가져온streamText를 호출합니다. 이 함수는modelprovider(ai에서 가져옴)와messages(1단계에서 정의)를 포함하는 구성 객체를 받습니다. 모델의 동작을 추가로 사용자 지정하려면 추가 settings를 전달할 수 있습니다.streamText함수는StreamTextResult를 반환합니다. 해당stream을toUIMessageStream에 전달하고createUIMessageStreamResponse와 함께 반환하여 스트리밍 응답 객체를 만듭니다.- 마지막으로 응답을 스트리밍하기 위해 결과를 클라이언트에 반환합니다.
이 API 라우트는 /api/chat에 POST 요청 엔드포인트를 만듭니다.
Provider 선택
AI SDK는 first-party, OpenAI-compatible, community 패키지를 통해 수십 개의 모델 provider를 지원합니다.
이 빠른 시작은 기본 global provider인 Vercel AI Gateway provider를 사용합니다. 즉, 모델 구성에서 간단한 문자열을 사용하여 모델에 액세스할 수 있습니다:
model: __MODEL__;
또한 게이트웨이 provider를 명시적으로 가져와서 사용하는 두 가지 동등한 방법이 있습니다:
// Option 1: Import from 'ai' package (included by default)
import { gateway } from 'ai';
model: gateway('anthropic/claude-sonnet-5');
// Option 2: Install and import from '@ai-sdk/gateway' package
import { gateway } from '@ai-sdk/gateway';
model: gateway('anthropic/claude-sonnet-5');
다른 provider 사용하기
다른 provider를 사용하려면 해당 패키지를 설치하고 provider 인스턴스를 만드세요. 예를 들어 OpenAI를 직접 사용하려면:
import { openai } from '@ai-sdk/openai';
model: openai('gpt-6-astra');
기본 global provider 업데이트
기본 global provider를 변경하여 애플리케이션 전체에서 문자열 모델 참조가 선호하는 provider를 사용하도록 할 수 있습니다. provider management에 대해 자세히 알아보세요.
애플리케이션 전반에서 provider를 관리하는 방식에 가장 잘 맞는 방법을 선택하세요.
UI 연결하기
이제 LLM을 쿼리할 수 있는 API 라우트가 준비되었으니, 프론트엔드를 설정할 차례입니다. AI SDK의 UI 패키지는 채팅 인터페이스의 복잡성을 하나의 훅인 useChat으로 추상화합니다.
루트 페이지(app/(tabs)/index.tsx)를 다음 코드로 업데이트하여 채팅 메시지 목록을 표시하고 사용자 메시지 입력을 제공하세요:
import { generateAPIUrl } from '@/utils';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { fetch as expoFetch } from 'expo/fetch';
import { useState } from 'react';
import { View, TextInput, ScrollView, Text, SafeAreaView } from 'react-native';
export default function App() {
const [input, setInput] = useState('');
const { messages, error, sendMessage } = useChat({
transport: new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: generateAPIUrl('/api/chat'),
}),
onError: error => console.error(error, 'ERROR'),
});
if (error) return <Text>{error.message}</Text>;
return (
<SafeAreaView style={{ height: '100%' }}>
<View
style={{
height: '95%',
display: 'flex',
flexDirection: 'column',
paddingHorizontal: 8,
}}
>
<ScrollView style={{ flex: 1 }}>
{messages.map(m => (
<View key={m.id} style={{ marginVertical: 8 }}>
<View>
<Text style={{ fontWeight: 700 }}>{m.role}</Text>
{m.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <Text key={`${m.id}-${i}`}>{part.text}</Text>;
}
})}
</View>
</View>
))}
</ScrollView>
<View style={{ marginTop: 8 }}>
<TextInput
style={{ backgroundColor: 'white', padding: 8 }}
placeholder="Say something..."
value={input}
onChange={e => setInput(e.nativeEvent.text)}
onSubmitEditing={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
autoFocus={true}
/>
</View>
</View>
</SafeAreaView>
);
}
이 페이지는 useChat 훅을 사용합니다. 기본적으로 이 훅은 앞서 만든 POST API 라우트(/api/chat)를 사용합니다. 이 훅은 사용자 입력과 폼 제출을 처리하기 위한 함수와 상태를 제공합니다. useChat 훅은 여러 유틸리티 함수와 상태 변수를 제공합니다:
messages- 현재 채팅 메시지 (id,role,parts속성을 가진 객체 배열).sendMessage- 채팅 API에 메시지를 보내는 함수.
컴포넌트는 로컬 상태(useState)를 사용하여 입력 필드 값을 관리하고, 입력 텍스트로 sendMessage를 호출한 다음 입력 필드를 비우는 방식으로 폼 제출을 처리합니다.
LLM의 응답은 메시지의 parts 배열을 통해 접근합니다. 각 메시지에는 모델이 응답에서 생성한 모든 것을 나타내는 순서가 있는 parts 배열이 포함됩니다. 이 parts에는 일반 텍스트, 추론 토큰 등 나중에 보게 될 더 많은 내용이 포함될 수 있습니다. parts 배열은 모델 출력의 순서를 유지하므로 각 구성 요소를 생성된 순서대로 표시하거나 처리할 수 있습니다.
API URL 생성기 만들기
스트리밍 응답을 위해 네이티브 fetch 함수 대신 expo/fetch를 사용하고 있으므로, 클라이언트 환경(예: 웹 또는 모바일)에 따라 올바른 기본 URL과 형식을 사용하고 있는지 확인하기 위해 API URL 생성기가 필요합니다. 프로젝트 루트에 utils.ts라는 새 파일을 만들고 다음 코드를 추가하세요:
import Constants from 'expo-constants';
export const generateAPIUrl = (relativePath: string) => {
const origin = Constants.experienceUrl.replace('exp://', 'http://');
const path = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
if (process.env.NODE_ENV === 'development') {
return origin.concat(path);
}
if (!process.env.EXPO_PUBLIC_API_BASE_URL) {
throw new Error(
'EXPO_PUBLIC_API_BASE_URL environment variable is not defined',
);
}
return process.env.EXPO_PUBLIC_API_BASE_URL.concat(path);
};
이 유틸리티 함수는 개발 및 프로덕션 환경 모두에서 URL 생성을 처리하여 API 호출이 다양한 기기와 구성에서 올바르게 작동하도록 보장합니다.
애플리케이션 실행하기
이제 챗봇에 필요한 모든 것을 구축했습니다! 애플리케이션을 시작하려면 다음 명령을 사용하세요:
브라우저에서 http://localhost:8081을 엽니다. 입력 필드가 표시될 것입니다. 메시지를 입력하여 AI 챗봇이 실시간으로 응답하는지 확인해 보세요! AI SDK를 사용하면 Expo로 AI 채팅 인터페이스를 빠르고 쉽게 구축할 수 있습니다.
도구로 챗봇 향상시키기
대규모 언어 모델(LLM)은 놀라운 생성 능력을 가지고 있지만, 수학과 같은 이산적인 작업이나 날씨 확인과 같은 외부 세계와의 상호작용에는 어려움을 겪어요. 이때 바로 도구가 필요해요.
도구는 LLM이 호출할 수 있는 작업이에요. 이러한 작업의 결과는 LLM에 다시 보고되어 다음 응답에서 고려될 수 있어요.
예를 들어, 사용자가 현재 날씨에 대해 묻는다면 도구가 없으면 모델은 학습 데이터에 기반한 일반적인 정보만 제공할 수 있어요. 하지만 날씨 도구를 사용하면 최신의 위치별 날씨 정보를 가져와 제공할 수 있죠.
간단한 날씨 도구를 추가하여 챗봇을 향상시켜 보세요.
API 라우트 업데이트하기
app/api/chat+api.ts 파일을 수정해서 새로운 날씨 도구를 포함해 보세요:
import {
streamText,
UIMessage,
convertToModelMessages,
tool,
createUIMessageStreamResponse,
toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: __MODEL__,
messages: await convertToModelMessages(messages),
tools: {
weather: tool({
description: 'Get the weather in a location (fahrenheit)',
inputSchema: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async ({ location }) => {
const temperature = Math.round(Math.random() * (90 - 32) + 32);
return {
location,
temperature,
};
},
}),
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'none',
},
});
}
업데이트된 코드에서:
ai패키지에서tool함수를, 스키마 검증을 위해zod에서z를 가져와요.weather도구를 포함하는tools객체를 정의해요. 이 도구는:- 모델이 언제 사용해야 하는지 이해하도록 돕는 설명을 포함해요.
- Zod 스키마를 사용하여
inputSchema를 정의하고, 이 도구를 실행하려면location문자열이 필요하다고 명시해요. 모델은 대화의 맥락에서 이 입력을 추출하려고 시도해요. 추출할 수 없으면 사용자에게 누락된 정보를 요청해요. - 날씨 데이터를 가져오는 것을 시뮬레이션하는
execute함수를 정의해요(이 경우 무작위 온도를 반환해요). 이 함수는 서버에서 실행되는 비동기 함수이므로 외부 API에서 실제 데이터를 가져올 수 있어요.
이제 챗봇은 사용자가 묻는 모든 위치의 날씨 정보를 '가져올' 수 있어요. 모델이 날씨 도구를 사용해야 한다고 판단하면 필요한 입력과 함께 도구 호출을 생성해요. 그러면 execute 함수가 자동으로 실행되고, 도구 출력이 tool 메시지로 messages에 추가돼요.
뉴욕 날씨가 어때? 같은 질문을 해보고 모델이 새 도구를 어떻게 사용하는지 확인해 보세요.
UI에 빈 응답이 표시되나요? 이는 모델이 텍스트 응답을 생성하는 대신 도구 호출을 생성했기 때문이에요. 클라이언트에서 message.parts 배열의 tool-weather 부분을 통해 도구 호출과 이후의 도구 결과에 접근할 수 있어요.
UI 업데이트하기
UI에 날씨 도구 호출을 표시하려면 app/(tabs)/index.tsx 파일을 업데이트해 보세요:
import { generateAPIUrl } from '@/utils';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { fetch as expoFetch } from 'expo/fetch';
import { useState } from 'react';
import { View, TextInput, ScrollView, Text, SafeAreaView } from 'react-native';
export default function App() {
const [input, setInput] = useState('');
const { messages, error, sendMessage } = useChat({
transport: new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: generateAPIUrl('/api/chat'),
}),
onError: error => console.error(error, 'ERROR'),
});
if (error) return <Text>{error.message}</Text>;
return (
<SafeAreaView style={{ height: '100%' }}>
<View
style={{
height: '95%',
display: 'flex',
flexDirection: 'column',
paddingHorizontal: 8,
}}
>
<ScrollView style={{ flex: 1 }}>
{messages.map(m => (
<View key={m.id} style={{ marginVertical: 8 }}>
<View>
<Text style={{ fontWeight: 700 }}>{m.role}</Text>
{m.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <Text key={`${m.id}-${i}`}>{part.text}</Text>;
case 'tool-weather':
return (
<Text key={`${m.id}-${i}`}>
{JSON.stringify(part, null, 2)}
</Text>
);
}
})}
</View>
</View>
))}
</ScrollView>
<View style={{ marginTop: 8 }}>
<TextInput
style={{ backgroundColor: 'white', padding: 8 }}
placeholder="Say something..."
value={input}
onChange={e => setInput(e.nativeEvent.text)}
onSubmitEditing={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
autoFocus={true}
/>
</View>
</View>
</SafeAreaView>
);
}
이 변경으로 UI가 다양한 메시지 부분을 처리하도록 업데이트돼요. 텍스트 부분은 이전과 같이 텍스트 내용을 표시해요. 날씨 도구 호출의 경우 도구 호출과 결과의 JSON 표현을 표시해요.
이제 날씨에 대해 묻으면 채팅 인터페이스에 도구 호출과 결과가 표시되는 것을 볼 수 있어요.
다단계 도구 호출 활성화
채팅 인터페이스에 도구 결과가 표시되지만, 모델이 이 정보를 사용하여 원래 질문에 답하지 않는다는 것을 눈치챘을 수도 있습니다. 이는 모델이 도구 호출을 생성하면 기술적으로 생성을 완료했기 때문입니다.
이 문제를 해결하려면 stopWhen을 사용하여 다단계 도구 호출을 활성화할 수 있습니다. 기본적으로 stopWhen은 isStepCount(1)로 설정되어 있으며, 이는 도구 결과가 있을 때 첫 번째 단계 후 생성이 중지된다는 의미입니다. 이 조건을 변경하면 모델이 도구 결과를 자동으로 다시 보내 지정한 중지 조건이 충족될 때까지 추가 생성을 트리거하도록 할 수 있습니다. 이 경우에는 모델이 계속 생성하여 날씨 도구 결과를 사용해 원래 질문에 답할 수 있도록 하려는 것입니다.
API 라우트 업데이트
app/api/chat+api.ts 파일을 수정하여 stopWhen 조건을 포함하세요:
import {
streamText,
UIMessage,
convertToModelMessages,
tool,
isStepCount,
createUIMessageStreamResponse,
toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: __MODEL__,
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(5),
tools: {
weather: tool({
description: 'Get the weather in a location (fahrenheit)',
inputSchema: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async ({ location }) => {
const temperature = Math.round(Math.random() * (90 - 32) + 32);
return {
location,
temperature,
};
},
}),
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'none',
},
});
}
Expo 앱으로 돌아가서 특정 위치의 날씨를 물어보세요. 이제 모델이 날씨 도구 결과를 사용하여 질문에 답하는 것을 볼 수 있을 것입니다.
stopWhen: isStepCount(5)로 설정하면 모델이 주어진 생성에 대해 최대 5개의 "단계"를 사용할 수 있습니다. 이는 더 복잡한 상호 작용을 가능하게 하며, 필요한 경우 모델이 여러 단계에 걸쳐 정보를 수집하고 처리할 수 있게 합니다. 화씨를 섭씨로 변환하는 도구를 추가하면 이 동작을 직접 확인할 수 있습니다.
도구 더 추가하기
app/api/chat+api.ts 파일을 수정하여 화씨를 섭씨로 변환하는 새 도구를 추가하세요:
import {
streamText,
UIMessage,
convertToModelMessages,
tool,
isStepCount,
createUIMessageStreamResponse,
toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: __MODEL__,
messages: await convertToModelMessages(messages),
stopWhen: isStepCount(5),
tools: {
weather: tool({
description: 'Get the weather in a location (fahrenheit)',
inputSchema: z.object({
location: z.string().describe('The location to get the weather for'),
}),
execute: async ({ location }) => {
const temperature = Math.round(Math.random() * (90 - 32) + 32);
return {
location,
temperature,
};
},
}),
convertFahrenheitToCelsius: tool({
description: 'Convert a temperature in fahrenheit to celsius',
inputSchema: z.object({
temperature: z
.number()
.describe('The temperature in fahrenheit to convert'),
}),
execute: async ({ temperature }) => {
const celsius = Math.round((temperature - 32) * (5 / 9));
return {
celsius,
};
},
}),
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
headers: {
'Content-Type': 'application/octet-stream',
'Content-Encoding': 'none',
},
});
}
새 도구를 위한 UI 업데이트
UI에 온도 변환 도구 호출을 표시하려면 app/(tabs)/index.tsx 파일을 수정하여 새 도구 부분을 처리하세요:
import { generateAPIUrl } from '@/utils';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { fetch as expoFetch } from 'expo/fetch';
import { useState } from 'react';
import { View, TextInput, ScrollView, Text, SafeAreaView } from 'react-native';
export default function App() {
const [input, setInput] = useState('');
const { messages, error, sendMessage } = useChat({
transport: new DefaultChatTransport({
fetch: expoFetch as unknown as typeof globalThis.fetch,
api: generateAPIUrl('/api/chat'),
}),
onError: error => console.error(error, 'ERROR'),
});
if (error) return <Text>{error.message}</Text>;
return (
<SafeAreaView style={{ height: '100%' }}>
<View
style={{
height: '95%',
display: 'flex',
flexDirection: 'column',
paddingHorizontal: 8,
}}
>
<ScrollView style={{ flex: 1 }}>
{messages.map(m => (
<View key={m.id} style={{ marginVertical: 8 }}>
<View>
<Text style={{ fontWeight: 700 }}>{m.role}</Text>
{m.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <Text key={`${m.id}-${i}`}>{part.text}</Text>;
case 'tool-weather':
case 'tool-convertFahrenheitToCelsius':
return (
<Text key={`${m.id}-${i}`}>
{JSON.stringify(part, null, 2)}
</Text>
);
}
})}
</View>
</View>
))}
</ScrollView>
<View style={{ marginTop: 8 }}>
<TextInput
style={{ backgroundColor: 'white', padding: 8 }}
placeholder="Say something..."
value={input}
onChange={e => setInput(e.nativeEvent.text)}
onSubmitEditing={e => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
autoFocus={true}
/>
</View>
</View>
</SafeAreaView>
);
}
이제 "What's the weather in New York in celsius?"라고 물어보면 더 완전한 상호 작용을 볼 수 있습니다:
- 모델이 뉴욕의 날씨를 위해 weather 도구를 호출합니다.
- 도구 결과가 표시됩니다.
- 그런 다음 화씨를 섭씨로 변환하기 위해 온도 변환 도구를 호출합니다.
- 모델은 이 정보를 사용하여 뉴욕의 날씨에 대한 자연어 응답을 제공합니다.
이 다단계 접근 방식을 통해 모델은 정보를 수집하고 이를 사용하여 더 정확하고 상황에 맞는 응답을 제공할 수 있으므로 챗봇이 훨씬 더 유용해집니다.
이 간단한 예는 도구가 모델의 기능을 확장할 수 있는 방법을 보여줍니다. 실제 API, 데이터베이스 또는 기타 외부 시스템과 통합되는 더 복잡한 도구를 만들어 모델이 실시간으로 실제 데이터에 접근하고 처리할 수 있게 할 수 있습니다. 도구는 모델의 지식 컷오프와 최신 정보 사이의 간극을 메워줍니다.
폴리필
AI SDK 내부에서 사용되는 일부 함수는 구성과 대상 플랫폼에 따라 Expo 런타임에서 사용하지 못할 수 있습니다.
먼저 다음 패키지를 설치하세요:
그런 다음 프로젝트 루트에 다음 폴리필이 포함된 새 파일을 만드세요:
import { Platform } from 'react-native';
import structuredClone from '@ungap/structured-clone';
if (Platform.OS !== 'web') {
const setupPolyfills = async () => {
const { polyfillGlobal } =
await import('react-native/Libraries/Utilities/PolyfillFunctions');
const { TextEncoderStream, TextDecoderStream } =
await import('@stardazed/streams-text-encoding');
if (!('structuredClone' in global)) {
polyfillGlobal('structuredClone', () => structuredClone);
}
polyfillGlobal('TextEncoderStream', () => TextEncoderStream);
polyfillGlobal('TextDecoderStream', () => TextDecoderStream);
};
setupPolyfills();
}
export {};
마지막으로 루트 _layout.tsx에서 폴리필을 가져오세요:
import '@/polyfills';
다음 단계
AI SDK를 사용하여 AI 챗봇을 구축했습니다! 이제부터 탐색할 수 있는 여러 경로가 있습니다:
- AI SDK에 대해 더 자세히 알아보려면 문서를 읽어보세요.
- 가이드로 더 깊이 파고들고 싶다면 RAG(검색 증강 생성) 및 멀티모달 챗봇 가이드를 확인해 보세요.
- 첫 AI 프로젝트를 빠르게 시작하려면 사용 가능한 템플릿을 살펴보세요.
탐색
- 공급자 선택
- 라이브러리 둘러보기
- Next.js App Router
- Next.js Pages Router
- Svelte
- Vue.js (Nuxt)
- Node.js
- Expo
- TanStack Start
- 코딩 에이전트