You add a tool to an agent. One line.
$tools = [new FirecrawlSearch];
You ask it a question. It searches the web and answers. It works.
So you add a second tool. Then a third. Then you wire them into an agent that does something real, and it stops feeling like magic. The chat hangs. The bill creeps up. A scraped web page makes the model do something you never asked for.
The hello-world worked. Production is a different animal.
The docs stop at the tool boundary
Every tool in the Laravel AI SDK toolkit is documented the same way. Install the package. New up the class. Pass it to tools().
That’s the easy part. It’s also the only part the docs cover.
Because the real problems don’t live inside a tool. They live between them. In the loop where the model calls three tools across six round-trips, feeds every result back into its own context, and decides what to do next. The per-tool README can’t help you there. It stops at the boundary.
Here’s what’s past it. One real agent. Six lessons.
One agent, three tools
Here’s an agent I actually needed: a content-research assistant. Give it a topic. It searches the web for what developers are saying, scrapes the best sources, and cross-checks my own published posts so it doesn’t suggest something I’ve already written.
Three tools from the catalog. Two hit the web, one reads the database.
use Laravel\Ai\AnonymousAgent;
use Shipfastlabs\Toolkit\Firecrawl\FirecrawlSearch;
use Shipfastlabs\Toolkit\Firecrawl\FirecrawlScrape;
use Shipfastlabs\Toolkit\Database\DatabaseQueryTool;
$agent = new AnonymousAgent(
instructions: 'Research a blog topic. Search the web for what developers '
.'are saying, scrape the best sources, then query the read-only `posts` '
.'table to find angles I have not already covered.',
messages: [],
tools: [
new FirecrawlSearch,
new FirecrawlScrape,
new DatabaseQueryTool,
],
);
$response = $agent->prompt(
"Research this topic and find angles I haven't covered: {$topic}",
provider: 'anthropic',
model: 'claude-sonnet-4-6',
);
Run it. The model searches, scrapes a couple of results, queries the posts table, and hands back a gap analysis. It works on the first try.
The whole app is on GitHub at laravel-ai-research-agent if you want to clone it and run the agent yourself.
Then you read the details.
Every tool result is untrusted input
My agent scraped a Reddit thread and a Medium post. Whatever those pages said went straight into the model’s context as the next thing it read.
Think about that. A web page you don’t control is now part of your prompt.
Nothing stops that page from containing “Ignore your previous instructions and…”. To the model, the scraped text and your own instructions arrive as the same thing. This is prompt injection, and a tool that reads the open web is the widest door into it. I wrote a whole post on defending the tools, and composing web tools into an agent is exactly the case it warns about.
The DatabaseQueryTool is the safe one here. It reads your own data. The web tools are the open door. Treat everything they return as hostile text, not as instructions, and never let a scraped page decide what runs next.
Raw tool output is a token bill
That research run cost me 39,382 input tokens. It produced 2,345 output tokens.
Read that again. The model wrote two thousand tokens and paid for forty thousand.
Here’s why. Every toolkit tool returns the full API response, pretty-printed, so the model can read every field. A search result. A scraped page in full. Every row of the posts query. All of it gets fed back into the context on the next turn. Six tool calls later, the model is re-reading every tool result it ever saw.
The docs are right that returning every field is convenient. In a single call. In an agent loop, it’s how a cheap task quietly turns into a forty-thousand-token bill. The per-step cost math shows which step pays for it.
You fix it at the tool, before the data ever reaches the model:
new FirecrawlSearch, // cap results; skip scrape_content unless you need it
new FirecrawlScrape, // only_main_content strips nav, footers, junk
new DatabaseQueryTool, // ai.toolkit.database.max_rows caps the rows
Scope the output. Return what the model needs to act, not everything the API knows.
Some tools can’t finish inside a turn
The toolkit has a FirecrawlCrawl tool. Crawling a whole site is slow, so it’s asynchronous. It starts the job and hands back a crawl id. Not the pages. An id.
I know, because I built that tool, and I split it into a start tool and a status tool on purpose.
Here’s the trap. Your agent calls FirecrawlCrawl, gets an id back, and now what? The crawl runs for two minutes. Your agent has a turn budget. It can’t sit and wait. So either it gives up, or it burns turns polling a job that isn’t done.
In-loop work wants synchronous tools. Search. Scrape one page. Query the database. Anything that returns a job id belongs on a queue, not in the agent’s turn. Start it, store the id, let a queued job poll it, and bring the result back on a later prompt.
”Read-only” is not “scoped”
The DatabaseQueryTool is careful. It allows a single SELECT. It rejects INSERT, UPDATE, DELETE, DROP. It blocks multiple statements in one call. The whole “stop letting your agent write SQL” crowd would approve.
It’s still not enough on its own.
Read-only stops the model from deleting your data. It does nothing to stop it from reading rows that aren’t its business. Point this tool at a multi-tenant table and one unscoped SELECT * FROM orders reads every customer’s orders, not just the current user’s.
Read-only is the floor. Scoping is on you. Run the tool against a connection that only sees a read replica, or a database view filtered to the current tenant. The tool guards the verb. You guard the rows.
A tool will fail. Make sure it fails as a string.
During that run, one scrape hit Reddit and got a 403. Reddit blocks scrapers.
The agent didn’t crash. It read the failure, shrugged, and scraped two other sources instead. The final answer never mentioned it.
That only worked because the tool returned the error as a plain string: "The Firecrawl scrape request failed with status 403: ...". The model treated it as a result, reasoned about it, and routed around it. If the tool had thrown an exception, the whole agent run dies on one unreachable URL.
This is the pattern every toolkit tool follows, and the one you copy when you write your own. Catch the failure. Return it as text the model can recover from. Never let one bad tool call take down the loop.
Build less than you think
So why use a catalog at all? Half these tools look like something you’d write yourself in an afternoon.
Then you read the code. The toolkit’s Calculator tool sounds like a one-liner, until you notice it refuses to call eval() and parses expressions with a couple hundred lines of recursive-descent code instead. Safe arithmetic isn’t a one-liner. That’s the tell.
The Firecrawl tool is the same story, louder. I built it, and “wrap an API” undersold the work. Auth. A strict input schema. Allow-listed formats. Clamped limits. A guard so a crawl id can’t be aimed at another endpoint. Error-as-string recovery on every path, and the tests to prove all of it. It went through a pull request with a 100%-coverage gate before it shipped.
That’s the deal a catalog offers. Not magic. The boring correctness you’d get wrong the first time and not notice until production. Your domain tools, the ones that touch your orders and your users, you still write. The generic plumbing you don’t.
FAQ
How many tool calls can a Laravel AI agent make in one prompt?
Steps set the limit, not tool calls. Without #[MaxSteps], laravel/ai v1.2.1 allows 1.5 steps per tool, rounded, so a three-tool agent gets five (outside Bedrock, never more than 25). A step is one request to the model, and every tool call in that response runs inside it. AnonymousAgent takes no step setting, so raise the budget with #[MaxSteps(10)] on a dedicated agent class.
Does the SDK run tool calls in parallel?
No. When the model asks for several tools in one step, laravel/ai v1.2.1 runs them one after another, in the order the model listed them. Three slow API tools in one step cost you the sum of their response times, not the slowest one. Opt-in parallel execution is an open feature request, laravel/ai #619.
Can you stream tool calls to the user while the agent works?
Yes. Call stream() instead of prompt() and iterate the response. In laravel/ai v1.2.1 you get a ToolCall event when the model picks a tool and a ToolResult event when the step’s tools finish. Return that response from a route and Laravel sends it as server-sent events, so your UI can show which tool is running.
Can a Laravel AI agent use tools from an MCP server?
Yes, since laravel/ai v0.8.0. Install laravel/mcp (v1.2.1 requires 1.0 or later), connect a client, and spread its tools into the agent: ...Mcp::client('github')->tools(). The SDK wraps each MCP tool so the model calls it like any other, with the name prefixed mcp_tools_.
Which Laravel and PHP versions does the Laravel AI SDK need?
laravel/ai v1.2.1 needs PHP 8.3 or later and Laravel 12 or 13. Its illuminate/json-schema constraint, ^12.62|^13.15, puts the real floor at Laravel 12.62 or 13.15. Laravel 11 and older can’t install it.
What the demo doesn’t teach you
Wiring a tool to an agent takes one line. That line is the whole tutorial, and it’s the easy part.
The agent that survives production is the one where you own the seams. Treat every tool result as untrusted text. Bound what each tool returns before it reaches the model. Push async tools to a queue. Scope your queries past read-only. And make sure a failing tool comes back as a string, not an exception.
Install the tools. Own the loop. That’s the part the docs leave to you.
The whole agent is on GitHub: laravel-ai-research-agent. Clone it, add your Firecrawl and Anthropic keys, and point it at your own posts table.