Chat shell
The chat shell
Stream a stubbed assistant reply end to end, from FastAPI through the Vercel AI SDK to a React chat UI.
By the end of this stage you can log in, create a chat thread, send a message, and watch an assistant reply stream into the browser word by word. The reply is a stub. There is no retrieval and no LLM yet. What you are proving is the pipe itself, from React through the AI SDK to FastAPI, with authentication and persistence working the whole way.
This is the vertical-slice idea. The frontend of this product is mostly a streaming chat shell, and the shell must never get ahead of a working API. Once the pipe streams a canned reply end to end, every later phase (retrieval, the agent, citations) just swaps out what flows through it.
daveebbelaar/document-copilot1:26:00Plan first, with two parallel agents
Phase 3 touches both sides of the stack, so Dave does not prompt "build the chat". He asks for two plans first, in two parallel agent sessions. One plans the backend chat API from the Phase 3 tasks in docs/todos.md, the other plans the frontend routes and components. Both plans get reviewed before any code is written.
The plans come out well structured on the first try, and Dave is explicit about why, "the reason why it's getting all of this pretty good out of the box is because we have this whole architecture MD that it is constantly referring to." The quality of unattended work is a function of the documents the agent can consult, not the prompt of the moment.
Before hitting build on either plan, commit a checkpoint. From here the diffs get large, and you want a clean point to return to.
Thread endpoints
The data model is simple. A user owns chat threads, and a thread owns messages. The router in backend/app/api/chat.py exposes list, create, history, and delete, all behind the get_current_user dependency from the previous stage:
router = APIRouter(prefix="/chat", tags=["chat"])
@router.get("/threads")
async def get_threads(
user: CurrentUser = Depends(get_current_user),
access_token: str = Depends(get_access_token),
) -> ThreadListResponse:
await ensure_user(user)
client = await create_user_client(access_token)
threads = await list_threads(client, user)
return ThreadListResponse(threads=threads)
@router.get("/threads/{thread_id}/messages")
async def get_thread_messages(
thread_id: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
access_token: str = Depends(get_access_token),
) -> MessageHistoryResponse:
await require_thread_access(thread_id, user)
client = await create_user_client(access_token)
messages = await load_messages(client, thread_id)
return MessageHistoryResponse(messages=messages)Two details matter here. ensure_user bridges the gap between Supabase Auth and your own tables. Signing up in Supabase creates an auth user, but your users row still has to exist before threads can reference it. And create_user_client(access_token) builds a Supabase client with the caller's own JWT, so row level security applies to every query.
require_thread_access in backend/app/database/chats.py is the ownership check every thread route runs first. Unknown thread means 404, someone else's thread means 403.
row = response.data
owner_id = uuid.UUID(str(row["user_id"]))
if owner_id != user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
...
)The streaming endpoint
POST /chat/stream accepts the Vercel AI SDK message format and returns server-sent events. The request schema in backend/app/schemas/chat.py mirrors what the AI SDK sends, down to the camelCase alias:
class UIMessage(BaseModel):
id: str | None = None
role: Literal["user", "assistant", "system"]
parts: list[MessagePart]
class StreamRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
thread_id: uuid.UUID = Field(validation_alias="threadId")
messages: list[UIMessage]The route validates thread ownership, pulls the newest user message, and returns a StreamingResponse with media_type="text/event-stream". At this stage the stream body is a stubbed reply; the SSE mechanics in backend/app/chat/streaming.py are what will later carry the real agent output:
def _sse_event(payload: dict[str, object]) -> str:
return f"data: {json.dumps(payload, separators=(',', ':'), default=str)}\n\n"
async def _text_events(text: str, *, message_id: str) -> AsyncIterator[str]:
yield _sse_event({"type": "text-start", "id": message_id})
for word in text.split(" "):
yield _sse_event({"type": "text-delta", "id": message_id, "delta": f"{word} "})
yield _sse_event({"type": "text-end", "id": message_id})This is the AI SDK data stream protocol. A text-start event, a series of text-delta events, a text-end. Splitting the canned text on spaces gives you a visible word-by-word stream, which is exactly what you want to verify in the browser.
Message persistence
After the stream completes, both the user message and the assistant message are written to chat_messages. The converters live in backend/app/chat/messages.py, which maps between AI SDK part arrays and database rows:
def ui_message_to_insert(
message: UIMessage,
*,
thread_id: uuid.UUID,
sequence: int,
message_id: uuid.UUID | None = None,
) -> dict[str, Any]:
parts = [part.model_dump(by_alias=True, mode="json") for part in message.parts]
return {
"id": str(message_id or uuid.uuid4()),
"thread_id": str(thread_id),
"role": MessageRole(message.role).value,
"content": text_from_parts(message.parts) or None,
"parts": parts,
"sequence": sequence,
}Storing the raw parts JSON alongside a plain-text content column means the frontend can rehydrate history in the exact shape useChat expects, and row_to_ui_message does that conversion on the way back out.
Wire the frontend with the AI SDK
The frontend plan produces the React Router routes (login, chat list, chat thread), plus ThreadSidebar, MessageList, and ChatInput in frontend/src/components/chat/. The interesting part is the transport. frontend/src/hooks/useChatTransport.ts points the AI SDK's DefaultChatTransport at the FastAPI stream and injects the Supabase bearer token on every request:
new DefaultChatTransport({
api: `${env.apiBaseUrl}/chat/stream`,
headers: async (): Promise<Record<string, string>> => {
const token = await getAccessToken()
return token ? { Authorization: `Bearer ${token}` } : {}
},
prepareSendMessagesRequest: ({ messages }) => ({
body: { threadId, messages },
}),
})prepareSendMessagesRequest adds the threadId to the body so the backend knows which thread to append to. The thread page (frontend/src/pages/chat/ChatThreadPage.tsx) loads history from GET /chat/threads/{id}/messages, then hands everything to useChat:
const transport = useChatTransport(threadId, setPipelineStatus)
const { messages, sendMessage, status, error, stop } = useChat({
id: threadId,
messages: initialMessages,
transport,
})From here the AI SDK does the heavy lifting. messages updates as deltas arrive, status drives the streaming indicator in MessageList, and ChatInput disables itself while a reply is in flight. ThreadSidebar lists past conversations through the useThreads context hook.
Run both sides in dedicated terminals
From this point you are checking both processes constantly, so open one terminal in backend/ and one in frontend/ instead of squinting at the editor's built-in panel:
# terminal 1, in backend/
uv run uvicorn app.main:app --reload
# terminal 2, in frontend/
pnpm devSign in with your test user and click around. Dave's own verdict at this point is "It's still a little bit ugly, but we can at least log in right now, and we have the scaffold." Ugly is fine. Working is the requirement.
Dave is deliberately more hands-off reviewing frontend code than backend code. Once the foundation and authentication are correct, "if it looks good visually and it works, good to go." The backend gets the careful review because that is where data integrity and security live.
Checkpoint
Before moving to ingestion, all of this must work:
- Log in, create a thread from the sidebar, and see it appear in the thread list
- Send a message and watch the stubbed reply stream in word by word
- Reload the page and see both messages come back from
chat_messages - Request another user's thread id and get a 403, a bogus id and get a 404
Next: Document ingestion
Convert 25 SEC filings to Markdown with Docling, chunk them, and embed them into Supabase.