How I built this

Laravel · Livewire · Claude API · Laravel Cloud

The problem with static CVs

A PDF answers the questions a recruiter thinks to ask. It doesn't handle follow-up, can't adapt to context, and gives no signal about how someone thinks. The obvious fix is to make the CV a conversation.

Architecture

Single Laravel application serving two domains — roberteades.com for general applications, cvlaude.ai for Anthropic-specific outreach. Domain detection middleware loads the appropriate system prompt variant at runtime.

The chat interface is built with Livewire and Flux. The Claude API handles all responses via a dedicated streaming endpoint that returns server-sent events. The frontend consumes the SSE stream and animates tokens directly to the DOM, bypassing Livewire's re-render cycle for latency-sensitive updates.

Architecture

cvlaude.ai streaming architecture Diagram showing how a user message flows through Livewire, a fetch handler, the streaming endpoint, and the Claude API, with the response streamed back token by token to the DOM. Browser Livewire component JS fetch handler SSE stream consumer /chat/stream Laravel controller dispatch POST Claude API claude-sonnet-4-5 stream: true SSE chunks DOM bubble Direct textContent update token by token SSE Livewire state onStreamComplete() on complete Rate limiter 10/min · 50/day per IP

The system prompt as knowledge base

The entire CV is embedded in the system prompt alongside behavioural instructions — tone, response length, character consistency. This trades context window cost for response quality. A retrieval approach would be more efficient at scale but adds complexity that isn't warranted here.

Streaming implementation

Standard Livewire request/response produced noticeable latency. The solution is a two-layer approach: Livewire dispatches a browser event on message submission, a vanilla JS fetch handler opens the SSE connection, streams tokens directly to the DOM, then calls back into Livewire only on completion to persist the final message to component state.

                        
// Livewire dispatches, JS handles the stream
Livewire.on('start-stream', async ({ messages }) => {
    const response = await fetch('/chat/stream', { ... });
    const reader = response.body.getReader();

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        // Update DOM directly — no Livewire round trip
        bubble.textContent += token;
    }
    // Only call back into Livewire on completion
    await @this.call('onStreamComplete', fullText);
});
                            
                        

Rate limiting

10 requests per minute, 50 per day per IP. Protects API costs without meaningfully restricting genuine use.

Stack

Laravel Livewire Flux Tailwind AlpineJS Claude API Resend Laravel Cloud

What's next

Conversation analytics to understand what questions hiring managers actually ask. Visitor identification via the email forwarding feature. A/B testing system prompt variants to optimise response quality.

Share