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 the entire history of everything it fetched.
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.
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.
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.