Chat
The Chat tab is a streaming, multi-turn conversation with a model you pick. It is backed by the chat edge function.

Change the prompt or reply length
| To change | Edit | Then |
|---|---|---|
| The system prompt | supabase/functions/chat/index.ts | Redeploy |
| Reply length | MAX_OUTPUT_TOKENS in the same file (currently 2048) | Redeploy |
| Which models appear | config.js, and the function’s allow-list | See Models |
| The UI | src/components/chat/ | Nothing |
No system prompt is set by default, so the function forwards the conversation as-is. Add
one for your product by passing system to streamText:
const result = streamText({
model: openrouter.chat(model),
system: 'You are a helpful assistant for …',
messages: modelMessages,
maxOutputTokens: MAX_OUTPUT_TOKENS,
});supabase functions deploy chatRaising MAX_OUTPUT_TOKENS costs proportionally more per message.
Gotchas
Keep expo/fetch if you replace the transport, because React Native’s global
fetch buffers the whole response body before resolving, so every token arrives at
once.
The model is fixed once a thread exists, so a thread’s history stays consistent
with the model that produced it. ModelPicker is disabled on existing threads. To
switch, start a new chat.
Attachments are resized to a 1024px long edge and re-encoded as JPEG at quality
0.7, because vision models bill by image size. The picker’s own quality option
compresses without capping dimensions, so it is not enough on its own.
Error codes
The function answers with a JSON body containing a code:
| Code | HTTP | Meaning |
|---|---|---|
invalid_model | 400 | Slug not in the function’s allow-list. See Models. |
model_not_vision | 400 | An image was attached to a model that can’t see. |
invalid_file_url | 400 | A file part whose URL isn’t a chat-media signed URL. |
pro_required | 403 | An imageOutput model requested by a non-Pro user. |
not_found | 404 | The threadId doesn’t belong to the caller. |
quota_exceeded | 402 | Free allowance used up. See Quota & Paywall. |
provider_not_configured | 500 | OPENROUTER_API_KEY isn’t set. See Secrets. |
Files
| Route | File |
|---|---|
| Thread list | src/app/(protected)/(tabs)/chats.tsx |
| A conversation | src/app/(protected)/chat/[threadId].tsx |
| Transport | src/lib/ai/chatTransport.ts |
| Attachments | src/lib/storage/chatMedia.ts |
| Server | supabase/functions/chat/index.ts |
How it works
Threads. The client never sends its own thread id. The function creates one on the
first message and returns it in an x-thread-id header, titled from the first 40
characters of the message, or New chat if that message was image-only.
Attachments. An upload goes to chat-media/{userId}/<uuid>.jpg as a signed URL,
which the server rewrites to the bare storage path before persisting, because signed
URLs expire. resolveChatMediaUrl() re-signs the path on demand when history renders.
The function only accepts a file part whose URL is a chat-media signed URL, so it
cannot be tricked into persisting an arbitrary one.
Server-side inlining. Model providers reject Supabase Storage signed URLs outright
(Only HTTPS URLs are supported., Cannot fetch from private/localhost URLs). Before
calling the model, the function downloads each image with the service-role client and
inlines it as a base64 data: URL, which works with any provider and keeps the bucket
private. See inlinePartsForModel() in supabase/functions/_utils/ai.ts.
Image replies. A model flagged imageOutput can answer with images. These models
are Pro-only, and their replies upload separately to chat-media/{userId}/gen/ so they
survive in history.