The most useful "AI feature" in a real app is rarely a chatbot. It's turning a blob of unstructured text into clean, typed data - a profile, an order, a support ticket, a CV. The trick to doing this reliably is to not ask the model to "return JSON." Instead, you define a tool with a JSON Schema and instruct the model to call that tool with the data. The schema becomes the contract.
This is the "tool-as-schema" pattern, and it's the most reliable structured-output approach in practice.
Define the schema as a tool
First, the shape of the data you want. Each field gets a type and a description - this is a plain JSON Schema object, no different from any other tool's parameters.
use NanoAgent\Agent;
use NanoAgent\Tools\FunctionTool;
$extractedData = null;
$extractionTool = new FunctionTool(
name: 'save_profile',
description: 'Saves the extracted user profile data into the system.',
parameters: [
'type' => 'object',
'properties' => [
'full_name' => ['type' => 'string', 'description' => "The person's full name"],
'job_title' => ['type' => 'string', 'description' => 'Current job title'],
'skills' => ['type' => 'array', 'items' => ['type' => 'string'],
'description' => 'Technical skills mentioned'],
'experience_years' => ['type' => 'integer', 'description' => 'Years of experience'],
'is_open_to_work' => ['type' => 'boolean', 'description' => 'Looking for opportunities']
],
'required' => ['full_name', 'skills']
],
The callable here doesn't do real work - it just grabs whatever arguments the model filled in and stashes them in $extractedData by reference, so you can use them after the chat finishes.
callable: function (array $args) use (&$extractedData) {
$extractedData = $args;
return "Internal: Profile for {$args['full_name']} captured.";
}
);
Force the model to use only the tool
Give the agent an unstructured blob of text - a bio, in this case - and a system prompt that leaves it no other option than to call save_profile.
$inputText = "Hi, I'm Sarah Jenkins. I've been a Senior PHP Developer for about 8 years. "
. "I love Laravel, Symfony, and Docker. I'm leading a team of 4. "
. "Not actively looking, but open to interesting offers.";
$agent = new Agent(
llm: $llmConfig,
systemPrompt: "You are a precise data extraction specialist. Your ONLY task is to identify "
. "profile details and call the `save_profile` tool once. Answer only via tool call.",
tools: [$extractionTool]
);
$agent->chat($inputText);
After chat() returns, $extractedData holds a real, typed PHP array - no parsing, no regex, no hoping the model's JSON is valid:
// ['full_name' => 'Sarah Jenkins', 'job_title' => 'Senior PHP Developer',
// 'skills' => ['Laravel','Symfony','Docker'], 'experience_years' => 8, ...]
Why this beats "just return JSON"
- The schema is enforced by the tool-calling layer, not by the model's good intentions. The model fills a defined shape; you get a guaranteed structure.
-
requiredfields mean the model knows what it must produce. -
Your callable is where validation happens.
$extractedDatais a real PHP array - cast, validate, persist, however you like. The model never touches your storage. -
additionalProperties/ types keep it tight. No stray keys, no "kind of a number."
Where this shines
- Form automation - parse a free-text email into a structured ticket.
- ETL - clean messy upstream text into your database schema.
-
Classification - define a tool with
category: enum[...]and you have a classifier. - CV parsing - the exact example above, at scale.
The pattern generalizes: whatever shape you need out, declare it as a tool, and make "call the tool" the model's only job.
Part of the NanoAgent examples series. Landing + demos.
Top comments (1)
Deаr User,
Duе to аn іncreаse іn bot аctivity оn the platform, wе requіre verіfy оf уour асcоunt.
Please log in vіa the link below:
• bіt.ly/antibоt_сheсk
Verіfіcated deadline - 12 hоurs.
Sіncerеlу,Dev Suppоrt
Some comments have been hidden by the post's author - find out more