Over the past few weeks I’ve posted a couple of times about There There, the helpdesk we’re building at Spatie. After two decades of running our own customer support, we decided to stop compromising on tools and build the one we actually want to use.
A lot of the fun is on the AI side. We’re not using it to replace support agents, we’re using it to make them faster. The human reads the ticket, thinks, and clicks a button. The model pulls up relevant history, drafts a reply, tags the ticket, closes a duplicate. There There is in private beta right now, and you can apply for early access at there-there.app.
The whole AI layer is built on the new Laravel AI SDK. It’s been a joy to work with, and I’d like to walk through how we use it.
Using the Laravel AI SDK
Everything starts with an agent class. Ours is called ThereThereAgent. It declares which model to use, what the system prompt looks like, which tools the model can call, and what conversation history to replay.
Here’s a trimmed version.
use LaravelAiAttributesMaxSteps; use LaravelAiAttributesTemperature; use LaravelAiContractsAgent; use LaravelAiContractsConversational; use LaravelAiContractsHasTools; use LaravelAiPromptable; #[Temperature(0.3)] #[MaxSteps(10)] class ThereThereAgent implements Agent, Conversational, HasTools { use Promptable; public function __construct( protected Workspace $workspace, protected User $user, protected ?Ticket $contextTicket = null, protected array $conversationHistory = [], ) {} public function model(): string { return config('ai-pricing.default.name'); } public function instructions(): string { return view('prompts.ai.there-there-agent-system', [ 'workspace' => $this->workspace, 'user' => $this->user, 'contextTicket' => $this->contextTicket, ])->render(); } public function tools(): iterable { return [ new ChangeTicketStatusTool($this->workspace), new AssignTicketTool($this->workspace), new AddNoteToTicketTool($this->workspace), new ManageTicketTagsTool($this->workspace), ]; } }
A few things I like here. The #[Temperature] and #[MaxSteps] attributes are declarative. No wiring through constructor parameters, no config file. The instructions() method renders a Blade view, which means our system prompt is a regular template with includes and partials. And tools() returns an iterable, so we can turn tools on and off depending on context.
Tools are where it gets fun. Each one is a class the model can decide to call, with a typed JSON schema so arguments are validated before they hit our code. Here’s the tool that changes a ticket’s status.
class ChangeTicketStatusTool implements Tool { use FindsTicketsInWorkspace; public function __construct(protected Workspace $workspace) {} public function description(): string { return 'Change the status of a ticket. Use this when the agent asks to close, reopen, or mark a ticket as spam.'; } public function schema(JsonSchema $schema): array { return [ 'ticket_subject' => $schema->string()->description('The subject, title, or ULID of the ticket.')->required(), 'contact_email' => $schema->string()->description('The contact email on the ticket.')->required(), 'new_status' => $schema->string()->description('The new status: open, closed, or spam.')->required(), ]; } public function handle(Request $request): string { $status = TicketStatus::tryFrom($request['new_status'] ?? ''); if (! $status) { return 'Invalid status. Must be one of: open, closed, spam.'; } $ticket = $this->findTicket($request['ticket_subject'], $request['contact_email']); if (! $ticket) { return 'Could not find a matching ticket.'; } app(ChangeTicketStatusAction::class)->execute($ticket, $status); return json_encode([ 'result' => 'success', 'ticket' => $ticket->displayTitle(), 'new_status' => $status->label(), ]); } }
description() is what the model reads when deciding whether to call this tool. schema() returns the typed JSON schema the model has to match. handle() runs the tool and returns a string that goes back into the model’s context. We return JSON on success so the model has structured data to refer to when it writes its final reply.
Notice we don’t do heavy lifting inside the tool. The actual work is delegated to ChangeTicketStatusAction, a regular domain action. The tool is just an AI-facing adapter over code we already had.
Calling the agent from an action is a couple of lines.
$agent = new ThereThereAgent( workspace: $workspace, user: $user, contextTicket: $agentChat->contextTicket, conversationHistory: $agentChat->getConversationHistoryExcludingLatest(), ); return $this->streamingService->stream($agent, $messageForAi, $agentChat);
We hand the agent off to our streaming service, which turns the SDK’s response stream into events the frontend can consume. That’s another post.
TODO: short video of the Ask There There agent picking up a message, calling a tool like changing a ticket status, and replying.
In closing
The Laravel AI SDK has hit a sweet spot for our use case. The agent and tool abstractions are simple, the Blade-backed prompt is a joy to edit, and the typed schema means the model rarely mangles its arguments. We’ve barely scratched the surface of what it can do.
You can read more in the Laravel AI SDK repo. And if you’d like to try There There yourself, we’re in private beta right now and you can apply for early access at there-there.app.