Skip to content
Go back

Building a Reusable AI Chat Component with the Laravel AI SDK and Livewire Streaming

Growth Method has several AI agents. One helps users build campaign hypotheses. Another analyses campaign performance. A third is a general-purpose assistant. Each of these agents needs a chat interface — a message list, an input box, streaming responses.

The problem: our first chat interface was hardcoded to a single agent. The HypothesisAiChat Livewire component had two things tangled together — the chat UI/streaming logic and the specific agent it talked to. Adding a second chattable agent would mean duplicating all of that.

The fix: extract a generic AgentChat component that works with any agent, and make the which agent part configurable.

The goal

{{-- Chat with the hypothesis builder --}}
<livewire:agent-chat agent="hypothesis" />

{{-- Chat with the general assistant --}}
<livewire:agent-chat agent="general" />

Same chat UI, same streaming code, different agent behind it.

The key pieces

Four pieces make this work:

PieceWhat it does
ChatableAgent contractEnsures every chattable agent can build itself from a user, team, and message history
AgentRegistryMaps slugs like "general" to agent classes, plus display metadata (name, greeting, placeholder text)
AgentChat Livewire componentThe generic chat UI — handles streaming, message history, tool status, suggestions, error handling
Wrapper componentsOptional parent components that add agent-specific behaviour (like a “Create campaign” button)

The ChatableAgent contract

Not every agent needs a chat interface. TitleGeneratorAgent takes a single prompt and returns a title — no conversation history needed. IdeaScoringAgent scores a campaign and returns numbers. These are fire-and-forget.

Agents that do support conversation implement a ChatableAgent contract:

interface ChatableAgent
{
    public static function fromChat(
        User $user,
        Team $team,
        array $history,
        array $tools = []
    ): static;
}

The fromChat factory method means each agent knows how to build itself. The chat component doesn’t need to know what constructor arguments each agent requires — it calls AgentClass::fromChat(...) and the agent grabs whatever context it needs.

The hypothesis builder pulls in the team’s strategy questions. The general assistant loads the team’s strategy context and goal. A future “SEO Analyst” agent might pull in analytics data. The chat component doesn’t care — it passes user, team, history, and tools, and the agent sorts itself out.

The AgentRegistry

The registry maps slugs to classes and stores display metadata:

class AgentRegistry
{
    protected static array $agents = [
        'general' => GeneralChatAgent::class,
        'hypothesis' => HypothesisChatAgent::class,
        'analyse-campaign' => CampaignAnalysisAgent::class,
    ];

    protected static array $meta = [
        'general' => [
            'name' => 'General Chat',
            'description' => 'Ask questions about growth, marketing, and your connected tools.',
            'placeholder' => 'Ask me anything...',
            'greeting' => 'Hey %s, how can I help you?',
        ],
        'hypothesis' => [
            'name' => 'New Idea',
            'description' => 'Build a clear, testable hypothesis for your next campaign.',
            'placeholder' => 'Share your observation, insight, data, or research here.',
            'greeting' => 'What observation, insight, data, or research sparked this campaign idea?',
        ],
    ];

    public static function find(string $slug): ?string
    {
        return static::$agents[$slug] ?? null;
    }

    public static function meta(string $slug): array
    {
        return static::$meta[$slug] ?? [
            'name' => 'Chat',
            'placeholder' => 'Type a message...',
            'greeting' => 'Hey %s, how can I help you?',
        ];
    }
}

The greeting field supports sprintf%s is replaced with the user’s first name. This means greetings appear instantly (no API call needed) while still feeling personal.

The generic AgentChat component

Resolving the agent:

public function streamResponse(): void
{
    $agentClass = AgentRegistry::find($this->agent);

    if (! $agentClass || ! is_subclass_of($agentClass, ChatableAgent::class)) {
        return;
    }

    // Gather MCP tools if the agent supports them
    $mcpTools = [];
    if (is_subclass_of($agentClass, HasTools::class)) {
        [$mcpTools, $this->toolIntegrationMap] = $this->gatherTeamTools($team);
    }

    $agentInstance = $agentClass::fromChat($user, $team, $history, $mcpTools);
    $stream = $agentInstance->stream($prompt);
    // ...
}

The component checks two things: does the agent class exist, and does it implement ChatableAgent? If the agent also implements HasTools, it fetches the team’s MCP tools and passes them through. This means the General Chat agent automatically gets access to whatever integrations the team has connected — Webflow tools for one team, GA4 tools for another — without any configuration.

Streaming with tool status:

When an agent calls an MCP tool during streaming, the component shows which integration is being queried:

if ($event instanceof ToolCall) {
    $integrationName = $toolIntegrationMap[$event->toolCall->name] ?? null;
    $label = $integrationName ? 'Querying ' . $integrationName . '' : 'Querying data…';

    $this->stream(
        to: 'response-' . $currentIndex,
        content: '<p class="text-xs text-zinc-400 italic">' . $label . '</p>',
        replace: true,
    );
}

So if the agent queries a Slack MCP server, the user sees “Querying slack.com…” in real-time before the response appears.

The wrapper pattern

Some agents need more than just chat. The hypothesis builder has a “Create campaign” button that saves the conversation output as a new campaign. The general assistant has no extra behaviour — plain chat is enough.

The wrapper pattern handles this. A wrapper is a parent Livewire component that embeds AgentChat and adds agent-specific features:

{{-- hypothesis-ai-chat.blade.php --}}
<div class="flex flex-col h-full relative">
    <livewire:agent-chat agent="hypothesis" wire:key="hypothesis-chat" />

    <div class="absolute bottom-1 right-12">
        <flux:button wire:click="triggerCreateIdea" size="sm">
            Create idea
        </flux:button>
    </div>
</div>

The wrapper listens for events from the generic chat component and does its own thing with them:

#[On('agent-chat-response-completed')]
public function onAgentResponse(string $agent, array $messages = [], string $title = ''): void
{
    if ($agent !== 'hypothesis') {
        return;
    }

    $this->messageCount = count($messages);
    $this->description = $this->extractHypothesisFromContent(
        collect($messages)->where('role', 'assistant')->last()['content'] ?? ''
    );

    $this->skipRender();
}

The skipRender() call is important — without it, the wrapper re-renders on every event, which can cause the child AgentChat component to lose its streamed content. The wrapper only needs to update its internal state (for when “Create campaign” is clicked), not re-render the DOM.

For agents that just need plain chat, you skip the wrapper entirely:

{{-- On the dashboard --}}
<livewire:agent-chat agent="general" />

Building a new agent

Here is the GeneralChatAgent — a general-purpose assistant that uses Anthropic’s smartest model, accepts the team’s MCP tools, and gets team strategy context:

#[Provider(Lab::Anthropic)]
#[UseSmartestModel]
#[Temperature(0.7)]
#[MaxTokens(4096)]
#[MaxSteps(10)]
#[Timeout(120)]
class GeneralChatAgent implements Agent, ChatableAgent, Conversational, HasMiddleware, HasTools
{
    use Promptable;

    public function __construct(
        private readonly string $usersFirstName,
        private readonly string $teamName,
        private readonly ?string $strategyContext,
        private readonly ?string $goalsName,
        private readonly array $mcpTools = [],
        private readonly array $history = [],
    ) {}

    public static function fromChat(User $user, Team $team, array $history, array $tools = []): static
    {
        return new static(
            usersFirstName: $user->first_name,
            teamName: $team->name,
            strategyContext: $team->teamStrategies->first()?->content,
            goalsName: $team->goal?->name ?? '(no team goal has been set)',
            mcpTools: $tools,
            history: $history,
        );
    }

    public function instructions(): Stringable|string
    {
        return view('prompts.general_chat', [
            'usersFirstName' => $this->usersFirstName,
            'teamName' => $this->teamName,
            'strategyContext' => $this->strategyContext,
            'goalsName' => $this->goalsName,
        ])->render();
    }

    public function tools(): iterable
    {
        return $this->mcpTools;
    }

    public function messages(): iterable
    {
        return collect($this->history)
            ->map(fn ($msg) => new Message($msg['role'], $msg['content']))
            ->all();
    }
}

Adding a new chattable agent to Growth Method now means:

  1. Create the agent class — implement ChatableAgent and Conversational
  2. Add it to the AgentRegistry with a slug and metadata
  3. Drop <livewire:agent-chat agent="your-slug" /> wherever you need it
  4. Optionally, wrap it if you need extra behaviour

No new streaming logic. No new chat views. No new message handling.

Security: per-team tool isolation

No team can access another team’s tools. In Growth Method, teams connect their own MCP servers — Team A might connect Webflow, Team B might connect GA4. The General Chat agent’s available tools depend on the team’s connections.

The security boundaries:

The General Chat agent doesn’t “own” any tools. It borrows whatever tools the team has plugged in. Different teams, different tools, same agent.

Questions I asked along the way

Can we restrict tool use on an agent and customer basis? For example, customer A can use the general chat agent with only tool 1, customer B can use the general chat agent with only tool 2?

The answer was yes — and it turned out we already had the building blocks. Since tools come from whichever MCP servers are connected to a team, the filtering happens naturally. The gatherTeamTools method fetches only the current team’s connected integrations and applies their enabled_tools whitelist. No hardcoded tool list needed.

What’s the right pattern — AgentRegistry for built-in agents plus a team_agents table for custom agents, or add a team_id column to the registry?

We went with the hybrid approach. Built-in agents are PHP classes in the AgentRegistry — they have specific behaviour, are version-controlled, and are testable. Custom agents (a future feature) would be database records using a generic agent class with configurable instructions and tools. The reasoning: built-in agents are shipped code, custom agents are user data. Don’t put code in the database.

How do we ensure another user doesn’t get someone else’s conversation?

The Laravel AI SDK’s RemembersConversations trait stores conversations with a user_id. The continue() method includes an as: $user parameter that acts as an ownership check — attempting to continue someone else’s conversation returns a new one instead.

What’s next

The next step is letting teams define their own custom agents — set a name, write custom instructions, pick which of their connected tools it can use. The AgentChat component won’t need to change at all. Whether an agent comes from the AgentRegistry (built-in) or from the database (custom) is just a matter of where the lookup happens. The chat UI stays the same.


Back to top ↑