<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Khaled Hammrouni</title>
    <description>The latest articles on DEV Community by Khaled Hammrouni (@hammrouni).</description>
    <link>https://dev.to/hammrouni</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2136271%2F3474a1e9-7e61-4da5-82bc-3790fe4742db.jpg</url>
      <title>DEV Community: Khaled Hammrouni</title>
      <link>https://dev.to/hammrouni</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hammrouni"/>
    <language>en</language>
    <item>
      <title>I built a PHP AI agent library with zero dependencies - here's the code</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Thu, 15 Oct 2026 19:51:59 +0000</pubDate>
      <link>https://dev.to/hammrouni/i-built-a-php-ai-agent-library-with-zero-dependencies-heres-the-code-1l7c</link>
      <guid>https://dev.to/hammrouni/i-built-a-php-ai-agent-library-with-zero-dependencies-heres-the-code-1l7c</guid>
      <description>&lt;p&gt;If you've tried to add an LLM agent to a PHP application, you've probably hit the same wall: every serious library is Python. So the "obvious" answer is to bolt a Python microservice behind your PHP app a sidecar that speaks to your agent, your agent speaks to your APIs, and now you're maintaining two runtimes, two deployments, and a network hop you didn't ask for.&lt;/p&gt;

&lt;p&gt;I got tired of that. So I built &lt;strong&gt;NanoAgent&lt;/strong&gt;: a PHP library that does agents natively. One &lt;code&gt;composer require&lt;/code&gt;. Zero runtime dependencies. No Python. No framework. No DSL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require hammrouni/nanoagent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One config array, any provider.&lt;/strong&gt; OpenAI, Groq, Anthropic, DeepSeek, OpenRouter or a local model behind any OpenAI-compatible endpoint (Ollama, llama.cpp server). Swap with one line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-first.&lt;/strong&gt; You map plain PHP functions as tools. Your agent can query your database, hit your internal APIs, read your device telemetry the code you already wrote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP support.&lt;/strong&gt; Register any Model Context Protocol server (Streamable HTTP); its tools behave exactly like local ones, with an allowlist so you control what gets registered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory.&lt;/strong&gt; Conversations survive across requests via pluggable drivers: JSON files, SQLite, MySQL, PostgreSQL or your own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal footprint.&lt;/strong&gt; One package, no hidden magic. You can audit the whole thing in an afternoon (it's small on purpose).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 30-second tour
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'groq'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'model'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'llama-3.3-70b-versatile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'api_key'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GROQ_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;// 'base_url' =&amp;gt; 'http://localhost:8080/v1',  // …or a local model server&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Your plain PHP function, now a tool:&lt;/span&gt;
&lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'weather'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$city&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"It is 22°C in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$city&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'How warm is it in Marrakech?'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The agent decides to call &lt;code&gt;weather&lt;/code&gt;, your function runs in-process, the result comes back, and the reply is generated. No sidecar, no queue, no second language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PHP, really?
&lt;/h2&gt;

&lt;p&gt;Because a huge amount of production AI isn't greenfield. It's Laravel services talking to legacy databases, internal HTTP APIs, and ten years of business logic. The agent that's useful there is not a shiny framework it's a boring, local library that lives inside the process you already run. NanoAgent is deliberately boring. Boring is the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not doing
&lt;/h2&gt;

&lt;p&gt;No auto-generated prompts, no "magic" abstractions, no 14 dependencies. If you want a framework, there are great ones. If you want a library you can read end-to-end, point at a hosted API &lt;em&gt;or&lt;/em&gt; a model on your own hardware, and ship in an afternoon that's what this is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/hammrouni/nanoagentphp" rel="noopener noreferrer"&gt;https://github.com/hammrouni/nanoagentphp&lt;/a&gt; (full docs + runnable examples)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs site:&lt;/strong&gt; &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;https://hammrouni.github.io/nanoagentphp/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composer:&lt;/strong&gt; &lt;code&gt;composer require hammrouni/nanoagent&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. Issues and PRs welcome.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Full 12-part series on building agents in PHP is linked in my profile this post is the short version.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>php</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A local agent loop that graded its own homework</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:31:00 +0000</pubDate>
      <link>https://dev.to/hammrouni/a-local-agent-loop-that-graded-its-own-homework-254f</link>
      <guid>https://dev.to/hammrouni/a-local-agent-loop-that-graded-its-own-homework-254f</guid>
      <description>&lt;p&gt;The first time my local agent loop "finished" a task, nothing had changed on disk. The drafter reported success, the critic signed off, the task moved to done - and the working directory was exactly as I'd left it. It took me a week of staring at audit logs to understand why, and the answer was one dead branch of code that I had written myself and never exercised.&lt;/p&gt;

&lt;p&gt;This is what happens when you build a drafter/critic loop that runs entirely on your own hardware. I'll walk through the loop, the five things that actually broke it, and the one non-obvious lesson that came out of all of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The loop, in one paragraph
&lt;/h3&gt;

&lt;p&gt;The pattern is deliberately simple. A &lt;strong&gt;drafter&lt;/strong&gt; model - a CLI coding agent with real file access - is dispatched into a task's working directory and told to produce a change. A separate &lt;strong&gt;critic&lt;/strong&gt; model then judges that attempt against the task's acceptance criteria. If the critic fails it, the drafter retries with the critic's named errors folded back into the prompt. After a configurable number of consecutive failures - the &lt;strong&gt;iteration cap&lt;/strong&gt;, default three - the task escalates to a human as &lt;em&gt;blocked&lt;/em&gt;; a comment resets the cap and resumes the loop with your guidance.&lt;/p&gt;

&lt;p&gt;Two independent models judging each other, and a hard cap so it can't spin forever. On paper it's a reasonable loop. In practice, on a single 24 GB GPU, it taught me a lot.&lt;/p&gt;

&lt;h3&gt;
  
  
  War story 1: the critic was never the critic you thought
&lt;/h3&gt;

&lt;p&gt;The design said the critic would be a "direct structured-output call at temperature 0" - a pure judgment, no tools, schema-enforced. That is the whole point of the role: a judge that returns a verdict, not an agent that does things.&lt;/p&gt;

&lt;p&gt;The code did not do that. The critic's model tag was read off the critic's &lt;em&gt;agent record&lt;/em&gt;, and that record was always populated. So the flag that decides "dispatch as agent" versus "dispatch as a structured call" was &lt;strong&gt;always true&lt;/strong&gt;. The structured-output branch - the one that would have enforced the verdict schema - was dead code. It never ran.&lt;/p&gt;

&lt;p&gt;The consequence: the critic ran inside the same CLI harness as the drafter, with tools. And a small model handed a tool harness doesn't behave like a judge. It behaves like an agent. Instead of returning a verdict, it tried to &lt;em&gt;read the prompt file&lt;/em&gt; - it emitted a tool call for &lt;code&gt;read&lt;/code&gt;, narrated that it "can use the Read tool," and then failed to parse. The loop retried, failed again, and gave up.&lt;/p&gt;

&lt;p&gt;The fix was a new setting that makes the structured path reachable again - but the lesson was that &lt;strong&gt;a role you describe in a doc is not a role your code enforces.&lt;/strong&gt; The critic was "conceptually" read-only; nothing in the dispatch made it so.&lt;/p&gt;

&lt;h3&gt;
  
  
  War story 2: the judge wrote the files it was judging
&lt;/h3&gt;

&lt;p&gt;This is the one I'd tell anyone building an autonomous pipeline. During a run, the working directory suddenly contained a complete Angular project scaffold - 23 files, &lt;code&gt;angular.json&lt;/code&gt;, &lt;code&gt;package.json&lt;/code&gt;, the whole lot. The task explicitly forbade a framework and a package manager.&lt;/p&gt;

&lt;p&gt;It wasn't the drafter. The audit log's action diffs - computed from real before/after directory snapshots, so a model can't fake them - attributed those 23 files to the &lt;strong&gt;critic model&lt;/strong&gt;. In the same dispatch, it returned a clean &lt;code&gt;{"verdict":"pass"}&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; scaffolded an application into the very directory it was supposed to be impartially judging.&lt;/p&gt;

&lt;p&gt;Why? Because of war story 1. The critic dispatching through the CLI agent got the exact same invocation as the drafter: a working directory, full write access, and a flag that skips permission prompts. A model that decides to "build the thing to check it" simply does.&lt;/p&gt;

&lt;p&gt;The fix that finally made this structurally impossible: route the critic through a text-only judgment call with no subprocess and no working directory - a component with no capability to write anything at all. A read-only judge that can't touch the artifact is not a nicety; it's the difference between a verdict you can trust and a verdict rendered against a directory the judge itself changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  War story 3: model choice silently decides whether any file gets written
&lt;/h3&gt;

&lt;p&gt;After fixing the runtime, the drafter ran twice, reported success, and wrote &lt;strong&gt;nothing&lt;/strong&gt;. The audit log showed why: one of my "coder" models emits a fabricated &lt;code&gt;&amp;lt;tool&amp;gt;&lt;/code&gt; block as &lt;em&gt;plain prose&lt;/em&gt; instead of performing a real tool call. The harness sees text, takes zero actions, and nothing touches disk. The model &lt;em&gt;described&lt;/em&gt; writing the file; it never did.&lt;/p&gt;

&lt;p&gt;I had assigned that model based on its file size and reputation. The audit log already contained the evidence to predict the failure - a count of which models actually produce real file actions through the harness. Two models made hundreds of real actions; two others made &lt;strong&gt;zero&lt;/strong&gt;. I hadn't consulted the log before reassigning.&lt;/p&gt;

&lt;p&gt;The systemic gap: the agent dropdown offered a model purely because the local server had it pulled. There was no capability probe for tool use. A drafter on a non-tool-calling model will burn its entire iteration cap producing confident, empty attempts, and report success the whole time.&lt;/p&gt;

&lt;h3&gt;
  
  
  War story 4: the load tax
&lt;/h3&gt;

&lt;p&gt;A drafter/critic loop alternates roles constantly - drafter, critic, drafter, critic - inside a single run. Each model switch on a local runtime can mean a cold load. In my first run, &lt;strong&gt;81% of the wall-clock time went to model loading&lt;/strong&gt;, not to actual generation. The runtime was configured to keep only one model resident at a time and pin it forever. The fix was two environment variables; the insight was that the bottleneck was the &lt;em&gt;runtime config&lt;/em&gt;, not the models and not the GPU.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked (so it's not all war stories)
&lt;/h3&gt;

&lt;p&gt;Mostly, it held. Auto-generated acceptance criteria were genuinely strong - nineteen items, each traceable to the task description, locked with no prompting. The state machine never produced an illegal transition, even through repeated dispatch failures. And the observability is what made all of this debuggable at all: a JSONL audit log of every model call, with durations, token counts, and - crucially - file-action diffs from real directory snapshots. Nearly every number in this post came straight out of that log.&lt;/p&gt;

&lt;p&gt;When I did re-run the pipeline after the fixes, it delivered a seven-file site with zero human intervention - and when I checked it &lt;em&gt;outside&lt;/em&gt; the system, with a script that never consults the loop, it held on 64 of 66 points.&lt;/p&gt;

&lt;h3&gt;
  
  
  The non-obvious lesson
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A claim is not a verification.&lt;/strong&gt; The drafter claimed it wrote the file. The critic claimed it judged it. The task claimed it was done. None of those claims were backed by what actually happened on disk - until I reconstructed the run from the recorded state, directory snapshots and all. The single strongest signal in the whole system, &lt;code&gt;status: done&lt;/code&gt;, turned out to be a copy of one step's verdict, not a whole-task check.&lt;/p&gt;

&lt;p&gt;If you're building a local agentic loop, three things I'd do before trusting it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enforce the judge's role in the dispatch, not just in the doc.&lt;/strong&gt; A read-only critic that cannot write is the difference between a trustworthy verdict and a self-contradiction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probe models for the capability you're actually using.&lt;/strong&gt; Tool-calling and structured output are different capabilities. "It's a coding model" is not evidence it can drive your harness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the runtime config a first-class suspect.&lt;/strong&gt; When 80% of your time is loading, the problem is almost never the model.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How I Built an Open-Source LLM Key-Pool Gateway (and Why You Might Want One)</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 11 Oct 2026 12:19:53 +0000</pubDate>
      <link>https://dev.to/hammrouni/how-i-built-an-open-source-llm-key-pool-gateway-and-why-you-might-want-one-3jf9</link>
      <guid>https://dev.to/hammrouni/how-i-built-an-open-source-llm-key-pool-gateway-and-why-you-might-want-one-3jf9</guid>
      <description>&lt;p&gt;I hit a wall that felt familiar to anyone building with LLMs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; I had three OpenAI keys (two free-tier, one paid), an Anthropic key, and a Gemini key. Every app I built needed its own logic to handle rate limits, and quota exhaustion. When one key hit its limit, the app died - unless I wrote custom failover logic &lt;em&gt;in every single app&lt;/em&gt;. Provider keys were scattered across &lt;code&gt;.env&lt;/code&gt; files, shared insecurely, and impossible to rotate without breaking something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The realization:&lt;/strong&gt; This isn't an app problem. It's an infrastructure problem. Every team building with LLMs solves the same three things badly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key pooling&lt;/strong&gt; - spreading load across multiple keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover&lt;/strong&gt; - automatically switching when a key fails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session continuity&lt;/strong&gt; - keeping the conversation alive across the switch&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I built the thing I wanted to exist: &lt;strong&gt;Palimpsest Gateway&lt;/strong&gt; a self-hosted, OpenAI-compatible gateway that sits in front of all your LLM keys, pools them, fails over automatically, and keeps every conversation going across the switch.&lt;/p&gt;

&lt;p&gt;It's open source (MIT), written in Python/FastAPI, and runs with a single &lt;code&gt;docker compose up&lt;/code&gt;. This is the story of why I built it, how it works, and the hard parts I didn't expect.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Keys Run Out, Apps Break
&lt;/h2&gt;

&lt;p&gt;If you've built anything with LLMs at scale, you know the pain points:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pain Point&lt;/th&gt;
&lt;th&gt;What Actually Happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate limits&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Your free-tier key hits 429. App crashes. You catch it, sleep, retry... but the user already left.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Quota exhaustion&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Monthly spend cap hit. Or daily free-tier allowance gone. No warning, just 402/429.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key sprawl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 apps × 3 providers = 15 keys in 15 &lt;code&gt;.env&lt;/code&gt; files. Rotating one is a deployment.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;No visibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You don't know which key served which request, how much you spent, or why a request failed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I wanted &lt;strong&gt;one endpoint&lt;/strong&gt; that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accepts standard OpenAI client calls (zero code change)&lt;/li&gt;
&lt;li&gt;Pools all my provider keys behind it&lt;/li&gt;
&lt;li&gt;Fails over transparently when a key hits a limit or errors&lt;/li&gt;
&lt;li&gt;Keeps conversation context so the user doesn't notice the switch&lt;/li&gt;
&lt;li&gt;Runs on my hardware, with my keys encrypted at rest&lt;/li&gt;
&lt;li&gt;Gives me a dashboard to see everything&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architecture: What Actually Runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────┐     ┌──────────────────────┐     ┌─────────────────┐
│  Your App   │────▶│  Palimpsest Gateway  │────▶│  LLM Providers  │
│ (OpenAI     │     │  • Auth &amp;amp; Admission  │     │  OpenAI         │
│  client)    │     │  • Key Selection     │     │  Anthropic      │
└─────────────┘     │  • Failover Loop     │     │  Google Gemini  │
                    │  • Session Memory    │     │  OpenRouter     │
                    │  • Usage Metering    │     └─────────────────┘
                    │  • Encrypted Storage │
                    └──────────┬───────────┘
                               │
                    ┌──────────▼───────────┐
                    │       Redis          │
                    │  • Key registry      │
                    │  • Session history   │
                    │  • Usage counters    │
                    │  • Audit log         │
                    └──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Request Flow
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate&lt;/strong&gt; - App sends its gateway key (&lt;code&gt;pgw_live_...&lt;/code&gt;), never a provider key&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admit&lt;/strong&gt; - Check user's daily cap + pool capacity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load context&lt;/strong&gt; - With &lt;code&gt;X-Session-ID&lt;/code&gt;, pull stored history so the app only sends the new message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose a key&lt;/strong&gt; - Pick from active keys serving the requested model, preferring capped keys with room left&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call provider&lt;/strong&gt; - Forward in provider-native format (OpenAI, Anthropic, Google, OpenRouter)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail over if needed&lt;/strong&gt; - Rate limit, outage, bad key → rest/exhaust/suspend that key, retry on next (same call, context carried)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer &amp;amp; record&lt;/strong&gt; - Return OpenAI-format response, save turn, count usage once&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Hard Parts (And How I Solved Them)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Streaming Failover Without Breaking the Client
&lt;/h3&gt;

&lt;p&gt;This was the hardest part. When a provider returns a streamed response, you can't just "retry on another key" - the client is already receiving tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; The gateway buffers the &lt;em&gt;entire&lt;/em&gt; provider stream in memory before sending anything to the client. If the provider fails mid-stream, the gateway retries on the next key with full context (system prompt + last N turns + current message). The client only ever sees a complete, valid OpenAI stream.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simplified: the failover loop in app/router/exhaustion.py
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_call_with_failover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;KeyRecord&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# buffers full stream
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ProviderError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;classification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_cooldown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cooldown_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# try next key
&lt;/span&gt;            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exhausted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_exhausted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suspend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# fatal
&lt;/span&gt;                &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PoolExhaustedError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;buffer the stream, failover before any bytes leave the gateway&lt;/strong&gt;. The client never sees a partial response.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Context Carry-Over Across Providers
&lt;/h3&gt;

&lt;p&gt;When failover happens, the replacement provider needs the conversation history. But each provider has different context window sizes and message formats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; The gateway stores every turn in Redis per session. On failover, it reconstructs a "verbatim window" - the last N turns (default 4) sent exactly as they were, plus the system prompt and current message. This fits in any reasonable context window and preserves the conversation thread.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/context/rebuild.py - simplified
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_failover_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Turn&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;current_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;verbatim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session_history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verbatim_turns_n&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;  &lt;span class="c1"&gt;# last 4 turns
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;verbatim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;current_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Encrypted Key Storage That Survives Restarts
&lt;/h3&gt;

&lt;p&gt;Provider keys are the crown jewels. They must be encrypted at rest, never logged, never returned after creation - but the gateway needs to decrypt them at request time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; A master key (Fernet) generated on first boot, persisted in a Docker volume. Every provider key is encrypted with it before hitting Redis. The master key never leaves the container. Rotating it is a destructive operation (&lt;code&gt;make rotate-secrets&lt;/code&gt;) - by design.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/core/crypto.py
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SecretsManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;master_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fernet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fernet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;master_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fernet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fernet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ciphertext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Key Selection That Respects Your Intent
&lt;/h3&gt;

&lt;p&gt;You have free keys (daily caps) and paid keys (uncapped). You want free keys used &lt;em&gt;first&lt;/em&gt;, paid keys as backstop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; Selection algorithm orders keys by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keys with a request cap + requests remaining → most remaining first&lt;/li&gt;
&lt;li&gt;Keys with no cap → last&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Within each group, prefer the provider that served the session's previous turns (locality).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/pool/registry.py - routable_keys()
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sort_for_selection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;KeyRecord&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;KeyRecord&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;capped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_cap&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;uncapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;daily_cap&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;capped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining_requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;capped&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;uncapped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Start: 60 Seconds to Running
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/hammrouni/palimpsest-gateway.git
&lt;span class="nb"&gt;cd &lt;/span&gt;palimpsest-gateway
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. On first boot it generates a master key and operator key, persists them in a Docker volume, and starts the API at &lt;code&gt;http://localhost:8000&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add a provider key (one-time, or use the dashboard)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PALIMPSEST_PROVIDER_API_KEY=sk-your-openai-key'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Create a user in the dashboard (or via API), get a pgw_live_... key&lt;/span&gt;
&lt;span class="c"&gt;# Then call it from any OpenAI client:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pgw_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send &lt;code&gt;X-Session-ID: my-chat&lt;/code&gt; header to keep the conversation going server-side.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Looks Like In Practice
&lt;/h2&gt;

&lt;p&gt;The dashboard shows you everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pool Keys&lt;/strong&gt; - health, usage, caps, reset schedules, cooldown status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Utilization &amp;amp; Failover&lt;/strong&gt; - which key served what, failover events, retry chains&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users &amp;amp; Sessions&lt;/strong&gt; - per-account usage, active sessions, cap consumption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs&lt;/strong&gt; - spend by model/user/day against prices &lt;em&gt;you enter&lt;/em&gt; (nothing bundled)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerts&lt;/strong&gt; - low capacity, key problems, error rates, webhook support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit &amp;amp; Export&lt;/strong&gt; - every operator action logged, CSV exports for usage/requests/audit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click to see a sample request log line&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-04T20:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"req_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_xyz"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sess_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o-mini"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latency_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"key_openai_free_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"key_openai_paid"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attempts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;em&gt;First key rate-limited → failover to paid key → context carried → success&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "Palimpsest"?
&lt;/h2&gt;

&lt;p&gt;A palimpsest is a manuscript page written on, scraped clean, and written on again - the old text still faintly visible underneath. That's what this gateway does: it layers your conversation history across provider switches, so the new provider sees the "old writing" (context) even though the underlying key changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-gateway clustering&lt;/strong&gt; - run multiple gateway instances behind a load balancer, shared Redis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider quota sync&lt;/strong&gt; - read actual provider quota (OpenRouter already supported) instead of manual caps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request transformations&lt;/strong&gt; - normalize tool calls across providers, inject system prompts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your PR here&lt;/strong&gt; - good first issues tagged, &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; has the dev loop&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Try It, Star It, Break It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/hammrouni/palimpsest-gateway" rel="noopener noreferrer"&gt;github.com/hammrouni/palimpsest-gateway&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Docs:&lt;/strong&gt; &lt;a href="https://github.com/hammrouni/palimpsest-gateway/blob/main/docs/USER_GUIDE.md" rel="noopener noreferrer"&gt;User Guide&lt;/a&gt; · &lt;a href="https://github.com/hammrouni/palimpsest-gateway/blob/main/docs/DEVELOPMENT.md" rel="noopener noreferrer"&gt;Development&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Docker:&lt;/strong&gt; &lt;code&gt;docker pull ghcr.io/hammrouni/palimpsest-gateway:latest&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you run it and hit something weird, open an issue. If you fix it, open a PR. If you hate it, tell me why - I've probably already hit that wall too.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>apigateway</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>What if your JSON schema was just a tool ?</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Fri, 09 Oct 2026 12:30:00 +0000</pubDate>
      <link>https://dev.to/hammrouni/what-if-your-json-schema-was-just-a-tool--2k3g</link>
      <guid>https://dev.to/hammrouni/what-if-your-json-schema-was-just-a-tool--2k3g</guid>
      <description>&lt;p&gt;Every integration I've inherited has the same line somewhere in it: "return valid JSON."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvux652aa7zao1hxskea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvux652aa7zao1hxskea.png" alt="What if your JSON schema was just a tool ?" width="493" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then the parser throws commentary before the object, markdown fences around it, a field dropped because the source text was messy. The schema was never enforced - it was requested, in a language the model has no reason to obey.&lt;/p&gt;

&lt;p&gt;There's a trick I keep coming back to stop describing the schema. Make it the tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="nv"&gt;$extracted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nv"&gt;$saveProfile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'save_profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Saves the extracted profile data into the system.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'full_name'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'skills'&lt;/span&gt;           &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
                &lt;span class="s1"&gt;'experience_years'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'full_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'skills'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$extracted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$extracted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Captured profile for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'full_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'You extract profile details. Your ONLY task is to call save_profile once. Answer only via tool call.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$saveProfile&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$unstructuredText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// $extracted is now a typed array — full_name, skills, experience_years&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't validate the output afterwards, you don't strip fences, the model has to produce arguments that satisfy your schema, because that is the shape of a tool call - the same mechanism that makes every tool call reliable is what makes your "JSON" reliable.&lt;/p&gt;

&lt;p&gt;It works with any provider that does tool calling, hosted or running on the machine next to you, and the validation happens on your side, in your language, where you can react to it.&lt;/p&gt;

&lt;p&gt;The full pattern with more examples is article #04 of my &lt;a href="https://dev.to/hammrouni/"&gt;PHP-AI series&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's your structured-output approach, schema-as-tool, a provider's native mode, or regex salvage ? Genuinely asking, because I've argued myself out of two of the three.&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>11 - Multi-Tool Orchestration in PHP - One Agent, Many Tools, One Answer</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:56:16 +0000</pubDate>
      <link>https://dev.to/hammrouni/11-multi-tool-orchestration-in-php-one-agent-many-tools-one-answer-5c7f</link>
      <guid>https://dev.to/hammrouni/11-multi-tool-orchestration-in-php-one-agent-many-tools-one-answer-5c7f</guid>
      <description>&lt;p&gt;A single tool is easy to reason about. The interesting problem is an agent with &lt;strong&gt;several specialized tools&lt;/strong&gt; that has to figure out &lt;em&gt;which&lt;/em&gt; to call, in &lt;em&gt;what order&lt;/em&gt;, and how to combine the results into one answer.&lt;/p&gt;

&lt;p&gt;This example is a personal concierge with three tools - location, weather, events - and the user's request needs all three chained together: &lt;em&gt;"Check where I am, get the weather there, and see if there are any jazz events tonight."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three focused tools
&lt;/h2&gt;

&lt;p&gt;The first tool takes no input at all - it just reports where the (simulated) user is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$locationTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'detect_user_location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Determines the user\'s current city and country.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;stdClass&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;  &lt;span class="c1"&gt;// no params&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Paris'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'country'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'France'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second and third tools both take a &lt;code&gt;city&lt;/code&gt; - the value the location tool just produced is what will get passed into these two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$weatherTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'get_current_weather'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Fetches the live weather forecast for a given city.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The city name'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$obs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Paris'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Cloudy, 14°C'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'London'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Rainy, 11°C'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'New York'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Sunny, 22°C'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$obs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'Weather data unavailable.'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$eventTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'search_local_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Searches for upcoming cultural events or concerts in a specific city.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The city name'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'Paris'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Jazz Festival at Le Caveau'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'time'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'20:00'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Monet at Grand Palais'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'time'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'10:00-18:00'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"No specific events found for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; today."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The agent decides the order
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a personal concierge with location, weather, and event tools. "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"When answering, first describe the location and weather, then list any "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"relevant events."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$locationTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$weatherTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$eventTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableActivityLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;"Check where I am, get the weather there, and see if there are any jazz events tonight."&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You did &lt;strong&gt;not&lt;/strong&gt; script the sequence. The model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calls &lt;code&gt;detect_user_location&lt;/code&gt; (no args) → &lt;code&gt;Paris&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;get_current_weather&lt;/code&gt; with &lt;code&gt;city: "Paris"&lt;/code&gt; → &lt;code&gt;Cloudy, 14°C&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;search_local_events&lt;/code&gt; with &lt;code&gt;city: "Paris"&lt;/code&gt; → the jazz festival.&lt;/li&gt;
&lt;li&gt;Composes the final answer in the order the prompt asked for.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The dependency is implicit.&lt;/strong&gt; The model reads the location result, then &lt;em&gt;passes that city into the next two tools.&lt;/em&gt; You gave it the pieces and the goal; it built the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things that make this work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each tool is narrow and well-described.&lt;/strong&gt; &lt;code&gt;description&lt;/code&gt; is what the model reads to choose a tool. "Determines the user's current city and country" tells it this is the first step. Vague descriptions = wrong tool picks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prompt sets the &lt;em&gt;shape&lt;/em&gt; of the answer&lt;/strong&gt;, not the tool calls. "First describe location and weather, then list events" steers the composition while leaving the orchestration to the model.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tool with no parameters - the &lt;code&gt;stdClass()&lt;/code&gt; trick
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;detect_user_location&lt;/code&gt; takes no input. In JSON Schema an object with no properties is &lt;code&gt;{'type':'object','properties':{}}&lt;/code&gt; - in PHP you write &lt;code&gt;new stdClass()&lt;/code&gt; for that empty object. Small gotcha, easy to miss: a &lt;code&gt;[]&lt;/code&gt; (empty array) encodes as a JSON &lt;em&gt;array&lt;/em&gt;, not an object.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use this vs. an agent chain
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tool, one agent&lt;/strong&gt; (this): the steps are &lt;em&gt;interdependent&lt;/em&gt; and the model should choose the path. Good for concierges, assistants, anything open-ended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent chain&lt;/strong&gt; (&lt;a href="https://dev.to/hammrouni/08-multi-agent-workflow-in-php-chain-a-researcher-into-a-writer-4o10"&gt;this article&lt;/a&gt;): the stages are &lt;em&gt;fixed&lt;/em&gt; and you want different models/prompts per stage. Good for deterministic pipelines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: &lt;strong&gt;if the path is fixed, use a chain; if the path depends on the input, give one agent many tools and let it decide.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>10 - Stateful Tools in PHP - An Agent That Reads and Mutates Inventory</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:51:39 +0000</pubDate>
      <link>https://dev.to/hammrouni/10-stateful-tools-in-php-an-agent-that-reads-and-mutates-inventory-5e19</link>
      <guid>https://dev.to/hammrouni/10-stateful-tools-in-php-an-agent-that-reads-and-mutates-inventory-5e19</guid>
      <description>&lt;p&gt;So far the tools have been read-only (weather, search). The more interesting case is a tool that &lt;strong&gt;changes state&lt;/strong&gt; - placing an order, updating a record, firing an action. The agent has to &lt;em&gt;query&lt;/em&gt; state, &lt;em&gt;decide&lt;/em&gt;, then &lt;em&gt;mutate&lt;/em&gt; it, all in one turn.&lt;/p&gt;

&lt;p&gt;This example is an inventory agent: the user asks to buy something, the agent checks stock, then places the order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Simulated state, real mutation
&lt;/h2&gt;

&lt;p&gt;A plain PHP class stands in for a real database - an in-memory array of products, with methods to read and write it. This keeps the example runnable with no setup, while behaving exactly like a DB-backed store would.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductDatabase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'p1'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Quantum Laptop'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'stock'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'p2'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Nano Phone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'stock'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'p3'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'AI Headset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'stock'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;stripos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;order()&lt;/code&gt; is the mutating half: it validates the product exists and has enough stock &lt;em&gt;before&lt;/em&gt; touching anything, then decrements the count and returns a human-readable result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Error: Product not found."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'stock'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Error: Insufficient stock."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'stock'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- the mutation&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Success: Ordered &lt;/span&gt;&lt;span class="nv"&gt;$qty&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. "&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"New stock: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'stock'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getInventory&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ProductDatabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two tools: one reads, one writes
&lt;/h2&gt;

&lt;p&gt;The read tool is a thin wrapper around &lt;code&gt;$db-&amp;gt;search()&lt;/code&gt; - no logic of its own, just a schema the model can call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$searchTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'search_products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Search for products by name in the store inventory.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Product name or keyword'&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'additionalProperties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write tool is the same shape, just pointed at &lt;code&gt;$db-&amp;gt;order()&lt;/code&gt; instead - the mutation itself lives entirely in the &lt;code&gt;ProductDatabase&lt;/code&gt; class, not in the tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$orderTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'place_order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Place an order for a specific product by its ID.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'product_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The unique product ID'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'quantity'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Units to purchase'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'product_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'quantity'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'additionalProperties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'product_id'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'quantity'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The agent that runs both
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a retail sales assistant. You have tools 'search_products' and "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"'place_order'. You MUST use these tools to check availability and place "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"orders. Do not guess stock levels."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$searchTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orderTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableActivityLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"I want to buy 2 Quantum Laptops. Check stock and order."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the database afterward and the stock is genuinely lower - this wasn't just a chat reply, the agent actually called &lt;code&gt;place_order&lt;/code&gt; and it actually ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;var_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getInventory&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;'p1'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'stock'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;  &lt;span class="c1"&gt;// 5 -&amp;gt; 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "do not guess stock levels" line in the prompt is load-bearing. Without it, a model might answer "yes, we have some" from memory. With it, the agent &lt;em&gt;must&lt;/em&gt; call &lt;code&gt;search_products&lt;/code&gt; to read the real number, then &lt;code&gt;place_order&lt;/code&gt; to act on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patterns that make stateful tools safe
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read before write.&lt;/strong&gt; The agent checks stock, &lt;em&gt;then&lt;/em&gt; orders. The mutation is grounded in a fresh read, not an assumption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return human-readable results, not raw arrays.&lt;/strong&gt; &lt;code&gt;"Success: Ordered 2... New stock: 3"&lt;/code&gt; lets the model reason about the outcome and report it. A bare array gives it less to work with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail as a result, not an exception.&lt;/strong&gt; &lt;code&gt;"Error: Insufficient stock."&lt;/code&gt; is returned to the model, which can then tell the user "only 0 in stock" instead of your app 500ing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;additionalProperties =&amp;gt; false&lt;/code&gt;&lt;/strong&gt; tightens the schema so the model can't pass junk fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enableActivityLogging()&lt;/code&gt;&lt;/strong&gt; so you can audit exactly which tools fired in what order - non-negotiable once tools can write.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scaling this up
&lt;/h2&gt;

&lt;p&gt;Swap &lt;code&gt;ProductDatabase&lt;/code&gt; for real persistence and you've got an agent that can &lt;strong&gt;read your DB, make a decision, and write back to your DB&lt;/strong&gt; - the core of most "agentic" business automation (orders, tickets, inventory, bookings). The agent is the decision layer; your PHP functions are the hands.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>09 - RAG in PHP - Answer From Your Own Docs, Not the Model's Memory</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:47:33 +0000</pubDate>
      <link>https://dev.to/hammrouni/09-rag-in-php-answer-from-your-own-docs-not-the-models-memory-1md8</link>
      <guid>https://dev.to/hammrouni/09-rag-in-php-answer-from-your-own-docs-not-the-models-memory-1md8</guid>
      <description>&lt;p&gt;The failure mode you hit the moment you point an LLM at internal questions: it &lt;strong&gt;hallucinates policy&lt;/strong&gt; that sounds right but was never written. "Can I work from Hawaii for two months?" is exactly the kind of question where a confident wrong answer costs you real money.&lt;/p&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) fixes it: &lt;strong&gt;retrieve the relevant doc first, then answer from that - and say so when there's no answer.&lt;/strong&gt; In NanoAgent, RAG is just a search &lt;em&gt;tool&lt;/em&gt;. No vector database required to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The knowledge base (a plain array here)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$knowledgeBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'policy_wfh'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Remote Work Policy 2024'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Employees may work remotely up to 3 days a week. Full remote work '&lt;/span&gt;
                    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'requires Director approval. Working from international locations is '&lt;/span&gt;
                    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'limited to 30 days per year due to tax implications.'&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'policy_holiday'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Holiday Schedule 2024'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Closed on New Year\'s Day, Memorial Day, Independence Day, Labor Day, '&lt;/span&gt;
                    &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'Thanksgiving, and Christmas Day.'&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'it_support'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'IT Support Contacts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Urgent: ext 5555. Non-urgent: support@company.com. Password resets via portal.'&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The search tool
&lt;/h2&gt;

&lt;p&gt;The tool takes one argument - free-text keywords - and describes itself as a policy search, so the model knows to reach for it on anything HR-shaped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$searchTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'search_knowledge_base'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Searches the internal knowledge base for policy documents. Input keywords.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'query'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Keywords to search for'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;callable&lt;/code&gt; does a plain case-insensitive substring match against title and content, and returns either the matching documents or an explicit "nothing found" - never a guess.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$knowledgeBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'query'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nv"&gt;$hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$knowledgeBase&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Content: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'content'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$hits&lt;/span&gt;
             &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;implode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"No relevant documents found."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The agent - and the prompt that makes RAG safe
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a professional HR assistant. You must ONLY answer based on the "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"search results. If the information is not present, politely say so. "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"Always cite the document title."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$searchTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Can I work from Hawaii for two months?"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last prompt line is the whole safety model. Three rules do the heavy lifting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"ONLY answer based on the search results"&lt;/strong&gt; - no training-data memory allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"If not present, say so"&lt;/strong&gt; - the model is &lt;em&gt;permitted&lt;/em&gt; to decline, which is what stops the confident hallucination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Cite the document title"&lt;/strong&gt; - every claim is traceable to a source.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the Hawaii question, the tool retrieves &lt;code&gt;Remote Work Policy 2024&lt;/code&gt; (it contains "international locations"), and the agent answers: &lt;em&gt;limited to 30 days/year, so two months isn't allowed - per Remote Work Policy 2024.&lt;/em&gt; Grounded, cited, correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  From array to real RAG - the same seam
&lt;/h2&gt;

&lt;p&gt;The only thing that changes as you scale is what the &lt;code&gt;callable&lt;/code&gt; does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;The &lt;code&gt;search_knowledge_base&lt;/code&gt; callable does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Demo&lt;/td&gt;
&lt;td&gt;keyword scan of a PHP array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real (small)&lt;/td&gt;
&lt;td&gt;query your SQL/Postgres docs table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real (large)&lt;/td&gt;
&lt;td&gt;embed the query + docs, hit a &lt;strong&gt;vector store&lt;/strong&gt; (Qdrant, pgvector)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The agent code never changes.&lt;/strong&gt; You keep the same tool name and the same prompt; you just make retrieval smarter inside the function. Start with the dumb keyword search - it's enough to prove the pattern and to handle small corpora - and upgrade the retrieval when you need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tools (not pre-prompting) for RAG
&lt;/h2&gt;

&lt;p&gt;You could stuff the whole doc into the prompt. That blows the context window and wastes tokens on irrelevant content. A &lt;em&gt;tool&lt;/em&gt; lets the model decide &lt;strong&gt;what to retrieve and when&lt;/strong&gt;, pulling only what's relevant. As your corpus grows, that's the difference between "works" and "doesn't fit."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>08 - Multi-Agent Workflow in PHP - Chain a Researcher into a Writer</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:40:22 +0000</pubDate>
      <link>https://dev.to/hammrouni/08-multi-agent-workflow-in-php-chain-a-researcher-into-a-writer-4o10</link>
      <guid>https://dev.to/hammrouni/08-multi-agent-workflow-in-php-chain-a-researcher-into-a-writer-4o10</guid>
      <description>&lt;p&gt;A single agent can do a lot, but some jobs are cleaner as a &lt;strong&gt;pipeline&lt;/strong&gt;: one agent gathers, another refines. The pattern is "agent chain" - the output of agent A becomes the input of agent B. Each agent has a narrow role and a focused system prompt, which is more reliable than one overloaded prompt trying to do both.&lt;/p&gt;

&lt;p&gt;This example: a &lt;strong&gt;Researcher&lt;/strong&gt; pulls facts, a &lt;strong&gt;Writer&lt;/strong&gt; turns them into a polished post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two agents, one data flow
&lt;/h2&gt;

&lt;p&gt;A mock research tool stands in for a real data source - a lookup table keyed by topic, so the example runs with zero external dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$researchTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'fetch_topic_facts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Retrieves interesting facts about a given topic.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'topic'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The subject to research'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'topic'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$knowledge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'php'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Created by Rasmus Lerdorf in 1994.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="s1"&gt;'Powers over 75% of websites with a known server-side language.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="s1"&gt;'The latest version is 8.4.'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'ai'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'The term AI was coined in 1956.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="s1"&gt;'Transformer architecture (2017) revolutionized NLP.'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$knowledge&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;strtolower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'topic'&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;
             &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"No specific facts found for '&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'topic'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'."&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. The Researcher - its only job is to gather
&lt;/h3&gt;

&lt;p&gt;Its system prompt is narrow on purpose: fetch facts with the tool, return them as a list, nothing more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a research specialist. Use the 'fetch_topic_facts' tool to "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"gather raw data. Return only the collected facts in a list."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$researchTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. The Writer - its only job is to polish, and NOT invent
&lt;/h3&gt;

&lt;p&gt;The Writer gets no tools at all - it only ever sees text the Researcher already gathered, and its prompt explicitly forbids adding facts of its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a creative content writer. Transform raw research facts into an "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"engaging social media post with hashtags. Do not invent facts that were "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"not provided."&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Run the chain
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$topic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PHP'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$research&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$researcher&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Research the topic: &lt;/span&gt;&lt;span class="nv"&gt;$topic&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// e.g. "1. Created by Rasmus Lerdorf in 1994. 2. Powers over 75% of websites..."&lt;/span&gt;

&lt;span class="nv"&gt;$post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$writer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Write a post based on these facts:&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$research&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seam is trivial: &lt;strong&gt;&lt;code&gt;$writer-&amp;gt;chat(... $research ...)&lt;/code&gt;&lt;/strong&gt; - the Researcher's string is just the Writer's prompt. There's no framework, no message bus, no orchestration library. A chain is literally one &lt;code&gt;chat()&lt;/code&gt; result fed into the next &lt;code&gt;chat()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split it instead of one big agent?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Focus beats scope.&lt;/strong&gt; A prompt that says "research AND write well AND don't hallucinate" does all three poorly. Two prompts do each one well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different models per stage.&lt;/strong&gt; The Researcher can be cheap and fast; the Writer can be your best model. You choose per agent because each is its own &lt;code&gt;new Agent(llm: ...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolated failure.&lt;/strong&gt; If research returns junk, you see it before it pollutes the final output. You can validate between stages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composability.&lt;/strong&gt; Add a third stage (a Critique agent, a translator) by appending one &lt;code&gt;chat()&lt;/code&gt; call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this goes next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Researcher → Writer → Critic&lt;/strong&gt; - a third agent scores the post and the loop retries if it's weak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fan-out&lt;/strong&gt; - several Researcher agents on sub-topics, then one Writer merges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each stage can carry its own tools&lt;/strong&gt; - the Researcher with a search tool, the Writer with a brand-voice tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mental model: &lt;strong&gt;an agent is a function; a workflow is a composition of functions.&lt;/strong&gt; PHP makes that composition boring in the best way - just call them in order.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>07 - MCP in PHP - Connect an Agent to a Remote Tool Server in 4 Lines</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:35:28 +0000</pubDate>
      <link>https://dev.to/hammrouni/07-mcp-in-php-connect-an-agent-to-a-remote-tool-server-in-4-lines-4phe</link>
      <guid>https://dev.to/hammrouni/07-mcp-in-php-connect-an-agent-to-a-remote-tool-server-in-4-lines-4phe</guid>
      <description>&lt;p&gt;If you build AI tools in more than one language, you know the tax: every integration you write for Python, you re-write for PHP. &lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt; kills that tax. It's a standard protocol for exposing tools, so your PHP agent talks to the &lt;em&gt;same&lt;/em&gt; tool servers a Python, Node, or Go agent would. You don't re-implement integrations per language - you point at a server and let the agent discover the tools.&lt;/p&gt;

&lt;p&gt;This is the shortest path to a genuinely capable agent in PHP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four lines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Connect to the MCP server
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;McpClient&lt;/code&gt; just points at a URL - no handshake code, no auth setup for a public server like this one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Mcp\McpClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$mcpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'https://mcp.deepwiki.com/mcp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Build the agent as usual
&lt;/h3&gt;

&lt;p&gt;Nothing MCP-specific here yet - it's a normal &lt;code&gt;Agent&lt;/code&gt; with a system prompt describing what it's for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a research assistant with access to MCP tools for "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"exploring GitHub repositories. The user will name a repository "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"as 'owner/repo'. Use the available tools to answer their question."&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Register the server, then chat
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;registerMcpServer()&lt;/code&gt; asks the remote server what tools it offers and wires them into the agent - that's the "discovery" step. From here, calling &lt;code&gt;chat()&lt;/code&gt; works exactly like it would with local tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$discovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;registerMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$mcpClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableActivityLogging&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nv"&gt;$answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Repository: facebook/react&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Question: What does this repo do?"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect → register → chat. &lt;strong&gt;The agent discovers the server's tools at runtime&lt;/strong&gt; (that's what &lt;code&gt;registerMcpServer()&lt;/code&gt; returns) and calls them exactly like local &lt;code&gt;FunctionTool&lt;/code&gt;s. No tool schema by hand, no new DSL, no sidecar process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent is talking to here
&lt;/h2&gt;

&lt;p&gt;The example uses &lt;a href="https://mcp.deepwiki.com/mcp" rel="noopener noreferrer"&gt;DeepWiki's public MCP server&lt;/a&gt; - free, no auth - which exposes tools for asking questions about the source and docs of any public GitHub repository. So the agent above can genuinely read and summarize an unfamiliar codebase. Point the same four lines at any other MCP server (search, databases, file systems, your own services) and the agent picks up &lt;em&gt;that&lt;/em&gt; server's tools instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local tool vs. MCP tool - the comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;FunctionTool&lt;/code&gt; (local)&lt;/th&gt;
&lt;th&gt;MCP tool (remote)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;in your PHP process&lt;/td&gt;
&lt;td&gt;on a remote server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who defines it&lt;/td&gt;
&lt;td&gt;you, in PHP&lt;/td&gt;
&lt;td&gt;the MCP server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reuse across languages&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes - one server, any client&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth / infra&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;server handles it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;you list them&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;automatic via &lt;code&gt;registerMcpServer()&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You use &lt;strong&gt;local tools&lt;/strong&gt; for logic that lives in your app (your DB, your business rules). You use &lt;strong&gt;MCP&lt;/strong&gt; for capabilities that should be shared, versioned, or maintained by someone else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for PHP specifically
&lt;/h2&gt;

&lt;p&gt;PHP shops don't leave PHP to add an AI feature. MCP means they don't have to &lt;em&gt;build&lt;/em&gt; the feature either - point at a server that already has the tool. &lt;strong&gt;The agent is a thin PHP layer over a standard protocol&lt;/strong&gt;, which is exactly the shape you want for a language that has no AI framework ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Find or run an MCP server (DeepWiki is a zero-config one).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;new McpClient($url)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$agent-&amp;gt;registerMcpServer($client)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ask it something the server can answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole integration.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>06 - Calling Real APIs from a PHP Agent - Live Weather in Four Functions</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 20:31:01 +0000</pubDate>
      <link>https://dev.to/hammrouni/06-calling-real-apis-from-a-php-agent-live-weather-in-four-functions-2jai</link>
      <guid>https://dev.to/hammrouni/06-calling-real-apis-from-a-php-agent-live-weather-in-four-functions-2jai</guid>
      <description>&lt;p&gt;An agent that only talks is a toy. The moment you give it a tool that hits a &lt;strong&gt;real external API&lt;/strong&gt;, it becomes useful - it can answer with &lt;em&gt;current, real&lt;/em&gt; data instead of whatever it memorized in training.&lt;/p&gt;

&lt;p&gt;This example gives the agent a &lt;code&gt;FunctionTool&lt;/code&gt; that calls &lt;code&gt;wttr.in&lt;/code&gt; (a free, no-key weather API) and returns live conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool is just a PHP function that does HTTP
&lt;/h2&gt;

&lt;p&gt;Start with the tool's shape: a name, a description the model reads to decide when to use it, and a single required &lt;code&gt;city&lt;/code&gt; argument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$weatherTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'fetch_live_weather'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Retrieves current weather for any city using a real weather API.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'city'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The city name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;callable&lt;/code&gt; is where the real HTTP call happens - a plain cURL request to &lt;code&gt;wttr.in&lt;/code&gt;'s JSON endpoint, with a timeout and a user agent so the request doesn't hang or get rejected outright.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://wttr.in/"&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"?format=j1"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nb"&gt;curl_setopt_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="no"&gt;CURLOPT_URL&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;CURLOPT_RETURNTRANSFER&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;CURLOPT_TIMEOUT&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="no"&gt;CURLOPT_USERAGENT&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'NanoAgent/1.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;curl_errno&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;curl_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nb"&gt;curl_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Network error while reaching weather API: &lt;/span&gt;&lt;span class="nv"&gt;$err&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nb"&gt;curl_close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then decode the response and hand back a plain sentence - not raw JSON - so the model has something natural to fold into its answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;        &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'current_condition'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Could not find weather data for '&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Current weather in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: "&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'weatherDesc'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'value'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
             &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;", &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'temp_C'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;°C."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The agent decides &lt;em&gt;when&lt;/em&gt; to call it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$_GET&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'city'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'London'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a meteorological assistant. You can retrieve real-time "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"weather information for any city."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$weatherTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"What is the real-time weather in &lt;/span&gt;&lt;span class="nv"&gt;$city&lt;/span&gt;&lt;span class="s2"&gt; right now?"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user never mentions &lt;code&gt;fetch_live_weather&lt;/code&gt;. The model reads the tool's &lt;strong&gt;description&lt;/strong&gt;, decides it's the right tool, fills in &lt;code&gt;city&lt;/code&gt;, and the agent runs the function and folds the result into the answer. You wrote the HTTP; the model wrote the &lt;em&gt;decision to use it&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the tool robust - the details that matter
&lt;/h2&gt;

&lt;p&gt;A real-world tool lives or dies on error handling. Notice the patterns above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timeout&lt;/strong&gt; (&lt;code&gt;CURLOPT_TIMEOUT&lt;/code&gt;) so a slow API can't hang your request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-Agent&lt;/strong&gt; - some APIs reject the default cURL UA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful failure string, not an exception.&lt;/strong&gt; Return a human-readable error &lt;em&gt;as the tool result&lt;/em&gt;. The model can then say "I couldn't reach the weather service" instead of your app crashing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the JSON&lt;/strong&gt; before you trust &lt;code&gt;$data['current_condition'][0]&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Generalize it
&lt;/h2&gt;

&lt;p&gt;Swap &lt;code&gt;wttr.in&lt;/code&gt; for anything that speaks HTTP:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a payments API,&lt;/li&gt;
&lt;li&gt;your own REST backend,&lt;/li&gt;
&lt;li&gt;a search engine,&lt;/li&gt;
&lt;li&gt;a CRM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; the tool's &lt;em&gt;description&lt;/em&gt; is what the model reads, so write it like you're telling a smart intern what the tool does and when to use it. The &lt;code&gt;parameters&lt;/code&gt; schema is the contract; the &lt;code&gt;callable&lt;/code&gt; is your normal PHP (cURL, PDO, anything).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>05 - Multi-Provider LLM in PHP - Switch Between Groq, OpenAI, and Mock</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 18:26:47 +0000</pubDate>
      <link>https://dev.to/hammrouni/05-multi-provider-llm-in-php-switch-between-groq-openai-and-mock-47ei</link>
      <guid>https://dev.to/hammrouni/05-multi-provider-llm-in-php-switch-between-groq-openai-and-mock-47ei</guid>
      <description>&lt;p&gt;Hard-coding one LLM provider is a trap. You want to test locally with a mock, run cheap on Groq, and fall back to OpenAI in production - &lt;strong&gt;without rewriting the agent.&lt;/strong&gt; The whole provider choice is one config array.&lt;/p&gt;

&lt;p&gt;This example loops over three providers with the &lt;em&gt;identical&lt;/em&gt; agent code and shows the switch is genuinely that small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The providers are just config
&lt;/h2&gt;

&lt;p&gt;Read the default provider/model/key from config once, then lay out three provider configs as a plain array - a free offline mock, your configured default (Groq), and OpenAI as a paid fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$configFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/../NanoAgent/config.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$default&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$configFile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="nv"&gt;$configFile&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="nv"&gt;$apiKey&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$default&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$default&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'groq'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$model&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$default&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'model'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'llama-3.3-70b-versatile'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$configs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'Mock'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'llm'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'test-model'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'mock-key'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Offline mock for local development.'&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Groq'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'llm'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$apiKey&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'High-performance inference.'&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'OpenAI'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'llm'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'openai'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gpt-4o'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
        &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Commercial provider.'&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The loop - same agent, different brain
&lt;/h2&gt;

&lt;p&gt;Skip any provider you have no key for, then create an &lt;code&gt;Agent&lt;/code&gt; per config and send it the same test message. Notice the &lt;code&gt;Agent&lt;/code&gt; constructor call is identical every time except for the &lt;code&gt;llm&lt;/code&gt; array.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$configs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'Mock'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'llm'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;: skipped (no key)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'llm'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                       &lt;span class="c1"&gt;// &amp;lt;-- the ONLY thing that changes&lt;/span&gt;
        &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are testing the &lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt; provider. Reply briefly identifying yourself."&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap the call in try/catch so one provider's outage or bad key doesn't stop you from testing the others.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Verify connection."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;: OK -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;: ERROR -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;new Agent(llm: ...)&lt;/code&gt; line is the seam. &lt;strong&gt;Everything downstream - tools, history, streaming, structured output - is provider-agnostic.&lt;/strong&gt; You never touch the rest of your code to change the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Offline development.&lt;/strong&gt; Point at the &lt;code&gt;mock&lt;/code&gt; provider and develop/test the whole agent flow with zero API spend and zero network. No &lt;code&gt;.env&lt;/code&gt; required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost control.&lt;/strong&gt; Route high-volume, low-stakes calls to a cheap/fast model; reserve the expensive one for the hard paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience.&lt;/strong&gt; Wrap in a fallback: try the primary, catch &lt;code&gt;Throwable&lt;/code&gt;, retry on a secondary provider. Same agent object shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B and evals.&lt;/strong&gt; Run the same task against several providers and compare outputs programmatically - this loop &lt;em&gt;is&lt;/em&gt; an eval harness.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fallback pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'groq'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'openai'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$providers&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'provider'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'model'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'api_key'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
               &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Throwable&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// log and try the next provider&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Bottom line:&lt;/strong&gt; keep the provider in config, keep the agent code generic, and "switching models" stops being a refactor and becomes a one-line change.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
    <item>
      <title>04 - Structured Output in PHP - Extract JSON from Unstructured Text</title>
      <dc:creator>Khaled Hammrouni</dc:creator>
      <pubDate>Sun, 27 Sep 2026 18:24:31 +0000</pubDate>
      <link>https://dev.to/hammrouni/04-structured-output-in-php-extract-json-from-unstructured-text-5c86</link>
      <guid>https://dev.to/hammrouni/04-structured-output-in-php-extract-json-from-unstructured-text-5c86</guid>
      <description>&lt;p&gt;The most useful "AI feature" in a real app is rarely a chatbot. It's &lt;strong&gt;turning a blob of unstructured text into clean, typed data&lt;/strong&gt; - a profile, an order, a support ticket, a CV. The trick to doing this reliably is to &lt;em&gt;not&lt;/em&gt; ask the model to "return JSON." Instead, you define a tool with a JSON Schema and instruct the model to &lt;strong&gt;call that tool with the data.&lt;/strong&gt; The schema becomes the contract.&lt;/p&gt;

&lt;p&gt;This is the "tool-as-schema" pattern, and it's the most reliable structured-output approach in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the schema as a tool
&lt;/h2&gt;

&lt;p&gt;First, the shape of the data you want. Each field gets a type and a description - this is a plain JSON Schema &lt;code&gt;object&lt;/code&gt;, no different from any other tool's &lt;code&gt;parameters&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Agent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;NanoAgent\Tools\FunctionTool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$extractedData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$extractionTool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FunctionTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'save_profile'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Saves the extracted user profile data into the system.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'properties'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'full_name'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"The person's full name"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'job_title'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Current job title'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'skills'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                    &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Technical skills mentioned'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'experience_years'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'integer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Years of experience'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'is_open_to_work'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'description'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Looking for opportunities'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="s1"&gt;'required'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'full_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'skills'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;callable&lt;/code&gt; here doesn't do real work - it just grabs whatever arguments the model filled in and stashes them in &lt;code&gt;$extractedData&lt;/code&gt; by reference, so you can use them after the chat finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="n"&gt;callable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$extractedData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$extractedData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"Internal: Profile for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'full_name'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; captured."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Force the model to use only the tool
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;save_profile&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$inputText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hi, I'm Sarah Jenkins. I've been a Senior PHP Developer for about 8 years. "&lt;/span&gt;
           &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"I love Laravel, Symfony, and Docker. I'm leading a team of 4. "&lt;/span&gt;
           &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"Not actively looking, but open to interesting offers."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$llmConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;systemPrompt&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You are a precise data extraction specialist. Your ONLY task is to identify "&lt;/span&gt;
                &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"profile details and call the `save_profile` tool once. Answer only via tool call."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$extractionTool&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nv"&gt;$agent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$inputText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;chat()&lt;/code&gt; returns, &lt;code&gt;$extractedData&lt;/code&gt; holds a real, typed PHP array - no parsing, no regex, no hoping the model's JSON is valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ['full_name' =&amp;gt; 'Sarah Jenkins', 'job_title' =&amp;gt; 'Senior PHP Developer',&lt;/span&gt;
&lt;span class="c1"&gt;//  'skills' =&amp;gt; ['Laravel','Symfony','Docker'], 'experience_years' =&amp;gt; 8, ...]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this beats "just return JSON"
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The schema is enforced by the tool-calling layer&lt;/strong&gt;, not by the model's good intentions. The model fills a defined shape; you get a guaranteed structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;required&lt;/code&gt; fields&lt;/strong&gt; mean the model knows what it &lt;em&gt;must&lt;/em&gt; produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your callable is where validation happens.&lt;/strong&gt; &lt;code&gt;$extractedData&lt;/code&gt; is a real PHP array - cast, validate, persist, however you like. The model never touches your storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;additionalProperties&lt;/code&gt; / types&lt;/strong&gt; keep it tight. No stray keys, no "kind of a number."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this shines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Form automation&lt;/strong&gt; - parse a free-text email into a structured ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ETL&lt;/strong&gt; - clean messy upstream text into your database schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classification&lt;/strong&gt; - define a tool with &lt;code&gt;category: enum[...]&lt;/code&gt; and you have a classifier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CV parsing&lt;/strong&gt; - the exact example above, at scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern generalizes: &lt;strong&gt;whatever shape you need out, declare it as a tool, and make "call the tool" the model's only job.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part of the NanoAgent examples series. &lt;a href="https://hammrouni.github.io/nanoagentphp/" rel="noopener noreferrer"&gt;Landing + demos&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>ai</category>
      <category>opensource</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
