15 October 2026 · 14 min read

Write your own coding agent in PHP with Papi

  • ai
  • php
  • papi-ai
  • agents

The agent in your terminal is not magic. It is a loop: it asks a model what to do next, does it, shows the model what happened, and asks again until the model answers in words. Around that loop sit four things that turn a chat into a colleague: tools, so it can read and change your project; memory, so tomorrow continues today; a budget, so a long afternoon still fits the model's context; and preferences, so you say "Yoda conditions" once.

This post builds papi-code, a terminal coding agent in PHP on Papi. The screen is laid out the way phpspec's pair mode lays it out, with the conversation scrolling above a prompt that stays put, but the terminal is not the subject. The five parts are, and each one is a class or two. The whole thing is on GitHub; the transcripts below are real, from claude-sonnet-5 working on a small project with one class and one test.

❯ Add a Portuguese greeting: Greeter::greet takes an optional locale, 'en' by default, and returns 'Olá, <name>!' when the locale is 'pt'. Add a test for it and run the test suite.
  ▸ list_files directory=.
    ↳ composer.json (5 lines)
  ▸ read_file path=src/Greeter.php
    ↳ <?php (14 lines)
  ▸ read_file path=tests/GreeterTest.php
    ↳ <?php (17 lines)
  ▸ edit_file path=src/Greeter.php search=    public function greet(string $name): stri... replace=    public function greet(string $name, strin...
    ↳ Edited src/Greeter.php.
  ▸ edit_file path=tests/GreeterTest.php search=    public function testItGreetsByName(): voi... replace=    public function testItGreetsByName(): voi...
    ↳ Edited tests/GreeterTest.php.
  ▸ run_command command=composer install --quiet 2>&1; vendor/bin/php...
    ↳ PHPUnit 12.5.39 by Sebastian Bergmann and contributors. (11 lines)
 
  Done. Changes made:
 
  - `src/Greeter.php`: `greet()` now accepts an optional `$locale = 'en'` parameter, returning `'Olá, <name>!'` when `$locale === 'pt'`, otherwise the existing English greeting.
  - `tests/GreeterTest.php`: added `testItGreetsInPortuguese` asserting `greet('Ana', 'pt')` returns `'Olá, Ana!'`.

It then reported the two tests green: one prompt, six tool calls, and the kind of change you would have made yourself.

Talk to the model

Papi puts a provider in front of each model API and a Conversation in front of the messages. The provider takes the key, the conversation takes the turns:

use PapiAI\Anthropic\AnthropicProvider;
use PapiAI\Core\Conversation;
 
$provider = new AnthropicProvider($apiKey);
$conversation = new Conversation();

Papi's Agent::run() runs the loop for one prompt and starts a fresh conversation each time, which is right for a job and wrong for a session: a coding session is one conversation that lasts an afternoon, and every turn has to see the ones before. So the loop is ours, on the provider, and the conversation outlives the prompt:

final class CodingAgent
{
    public function ask(Conversation $conversation, string $prompt, TurnListener $listener): Reply
    {
        $conversation->addUser($prompt);
        $inputTokens = 0;
        $outputTokens = 0;
 
        for ($round = 0; $round < $this->maxToolRounds; ++$round) {
            $conversation->setSystem($this->instructions->text());
            $this->context->fit($conversation, $listener);
 
            $response = $this->provider->chat($conversation->getMessages(), $this->options());
            $inputTokens += $response->usage->inputTokens;
            $outputTokens += $response->usage->outputTokens;
            $conversation->addAssistant($response->text, $response->toolCalls ?: null);
 
            if (!$response->hasToolCalls()) {
                return new Reply($response->text, $inputTokens, $outputTokens);
            }
 
            foreach ($response->toolCalls as $call) {
                $conversation->addToolResult($call->id, $this->execute($call, $listener));
            }
        }
 
        throw new TooManyToolRounds($this->maxToolRounds);
    }
}

That is the whole agent. Each round sends the conversation, appends what came back, and either returns the words or runs the tools and appends their results, so the next round starts with the model knowing what its tools said. The system prompt is set again every round, which matters in a minute, and fit() keeps the conversation under budget, which matters later. The cap on rounds is the one piece of defensive engineering an agent cannot do without: a loop that can call tools can loop for ever.

The provider gets the model, a cap on the answer, and the tools, as a neutral description each provider turns into its own wire format:

final class CodingAgent
{
    private function options(): array
    {
        return [
            'model' => $this->model,
            'maxTokens' => $this->maxTokens,
            'tools' => array_values(array_map(
                fn (ToolInterface $tool): array => [
                    'name' => $tool->getName(),
                    'description' => $tool->getDescription(),
                    'parameters' => $tool->getParameterSchema(),
                ],
                $this->tools,
            )),
        ];
    }
}

Use tools

A tool is a public method with an attribute. Papi reads the method name, the parameters and their descriptions, and builds the schema the model sees. The files of the project are one class:

use PapiAI\Core\Attributes\Description;
use PapiAI\Core\Attributes\Tool;
 
final class FileTools
{
    public function __construct(private readonly Workspace $workspace)
    {
    }
 
    #[Tool('Read a file of the project. Paths are relative to the project root.')]
    public function readFile(#[Description('Path relative to the project root')] string $path): string
    {
        return $this->workspace->read($path);
    }
 
    #[Tool('Replace one exact occurrence of a snippet in a file. Fails when the snippet is missing or appears more than once, so read the file first.')]
    public function editFile(
        #[Description('Path relative to the project root')] string $path,
        #[Description('The exact text to replace, indentation included')] string $search,
        #[Description('The text that replaces it')] string $replace,
    ): string {
        $content = $this->workspace->read($path);
        $occurrences = substr_count($content, $search);
        if (0 === $occurrences) {
            throw new \RuntimeException(\sprintf('The snippet was not found in %s.', $path));
        }
        if ($occurrences > 1) {
            throw new \RuntimeException(\sprintf('The snippet appears %d times in %s; include more context to make it unique.', $occurrences, $path));
        }
 
        $this->workspace->write($path, str_replace($search, $replace, $content));
 
        return \sprintf('Edited %s.', $path);
    }
}

Tool::fromClass(new FileTools($workspace)) turns that into read_file and edit_file, plus write_file, list_files and search from the methods not shown. The descriptions are the only documentation the model gets, so they say what the tool refuses and why: the edit tool wants one exact match, and the sentence "so read the file first" is what makes the model read before it writes, as it did in the transcript above. The Workspace resolves every path and throws on anything that would leave the project, and an exception in a tool becomes an error result the model reads rather than the end of the session.

Running commands is the one tool that can do damage, so it asks:

interface Approval
{
    public function approves(string $command): bool;
}
 
final class ShellTools
{
    #[Tool('Run a shell command in the project root and return its output and exit code. The user is asked for permission first.')]
    public function runCommand(#[Description('The command line, as typed in a terminal')] string $command): string
    {
        if (!$this->approval->approves($command)) {
            return 'The user declined to run this command.';
        }
 
        $process = Process::fromShellCommandline($command, $this->workspace->root(), null, null, $this->timeoutSeconds);
 
        try {
            $process->run();
        } catch (ProcessTimedOutException) {
            return \sprintf('The command was stopped after %d seconds.', $this->timeoutSeconds);
        }
 
        $output = $this->shrinker->shrink($command, $process->getOutput().$process->getErrorOutput());
 
        return \sprintf("%s\n[exit code %d]", rtrim($output), $process->getExitCode());
    }
}

The terminal implements Approval with a [y/N] on the prompt line; a scripted run passes --yes and an implementation that says yes to everything. A declined command is not an error, it is an answer the model can work with.

Manage memory

Memory, at its simplest, is the conversation on disk. Papi ships a ConversationStoreInterface and a file implementation, and a session is a conversation with a name:

use PapiAI\Core\Contracts\ConversationStoreInterface;
use PapiAI\Core\Conversation;
 
final class Sessions
{
    public function __construct(private readonly ConversationStoreInterface $store)
    {
    }
 
    public function start(?string $id = null): Session
    {
        return new Session($id ?? date('Ymd-His'), new Conversation());
    }
 
    public function resume(string $id): Session
    {
        $conversation = $this->store->load($id);
        if (null === $conversation) {
            throw new UnknownSession($id);
        }
 
        return new Session($id, $conversation);
    }
 
    public function save(Session $session): void
    {
        $this->store->save($session->id, $session->conversation);
    }
}

The loop saves after every turn, the one that failed included, so a crashed process or a closed laptop loses nothing. Sessions live as JSON under .papi/sessions/ in the project, and you can read them. Starting the agent with --continue picks the most recent one up:

❯ What did we change in this session, and are the tests green?
 
  In this session we:
 
  1. **Added Portuguese greeting support** (`src/Greeter.php`)
     - `greet()` gained an optional `string $locale = 'en'` parameter.
     - When `'pt' === $locale`, it returns `'Olá, <name>!'`; otherwise it returns the original `'Hello, <name>!'`.
     - Added `testItGreetsInPortuguese` to `tests/GreeterTest.php`.
 
  2. **Added validation for empty names** (`src/Greeter.php`)
     - `greet()` now throws `InvalidArgumentException` when `'' === $name` (Yoda-style check, constant on the left), before any locale logic runs.
     - Added `testItRefusesAnEmptyName` to `tests/GreeterTest.php`, which asserts the exception via `expectException(InvalidArgumentException::class)`.

It answered the second half of the question from the same place: the last run was green, three tests. No tool was called. It was in the conversation, and the conversation was in the file.

Manage context

Two things grow during a session: the conversation, and the output of the commands in it. Both cost tokens on every call, because the whole conversation goes to the model every round.

The conversation gets a budget. Papi's HeuristicTokenEstimator counts bytes, which is all a budget needs, and when the estimate goes over, the oldest turns are folded into a summary written by the model itself, and the recent turns stay as they are:

final class ContextWindow
{
    public function fit(Conversation $conversation, TurnListener $listener): void
    {
        $before = $this->tokens($conversation);
        if ($before <= $this->budgetTokens) {
            return;
        }
 
        $messages = array_values(array_filter(
            $conversation->getMessages(),
            fn (Message $message): bool => !$message->isSystem(),
        ));
        $cut = $this->cutPoint($messages);
        if (0 === $cut) {
            return;
        }
 
        $old = \array_slice($messages, 0, $cut);
        $summary = $this->summariser->summarise($old);
 
        $conversation->clear(keepSystem: true);
        $conversation->addUser("Summary of the earlier part of this session, written by you:\n".$summary);
        $conversation->addAssistant('Understood, I will carry on from there.');
        foreach (\array_slice($messages, $cut) as $message) {
            $conversation->addMessage($message);
        }
 
        $listener->contextCompacted(\count($old), $before, $this->tokens($conversation));
    }
}

The cut lands on a user message, so a tool result never loses the call it answers; the Anthropic API rejects that, and the model would be confused by it anyway. The summariser is one more provider call with its own instructions: keep the task, the decisions, the files changed and why, the open problems, under 300 words, in the first person. The status line at the bottom of the screen shows the estimate against the budget, so you see it coming.

Command output gets a filter. Papi has an interface for token optimisation proxies and an adapter for rtk, the proxy that rewrites developer output before it reaches a model. It only pays off when it knows the shape of the text, so the shrinker picks the filter from the command and leaves the rest alone:

use PapiAI\Core\Contracts\LLMTokenOptimisationProxyInterface;
 
final class OutputShrinker
{
    private const FILTERS = [
        'git log' => 'git-log',
        'git diff' => 'git-diff',
        'git status' => 'git-status',
        'grep' => 'grep',
        'rg' => 'rg',
        'find' => 'find',
        'fd' => 'fd',
    ];
 
    public function __construct(
        private readonly ?LLMTokenOptimisationProxyInterface $optimiser,
        private readonly int $maxCharacters = 12000,
    ) {
    }
 
    private function optimised(string $command, string $output): string
    {
        $filter = $this->filterFor($command);
        if (null === $this->optimiser || null === $filter || '' === trim($output)) {
            return $output;
        }
 
        try {
            $result = $this->optimiser->optimise($output, ['filter' => $filter]);
        } catch (\Throwable) {
            return $output;
        }
 
        if (!$result->isMeasured() || 0 === $result->tokensSaved()) {
            return $result->optimised;
        }
 
        return $result->optimised.\sprintf("\n[output compressed from %d to %d tokens]", $result->tokensBefore, $result->tokensAfter);
    }
}

The note at the end is for the model, so it knows it is reading a digest. Optimisation is lossy by design, and this exchange, with the agent pointed at its own repository, shows both sides of that:

❯ Summarise the last 5 commits of this repository and say which files each one touched. A plain git log --stat is enough.
  ▸ run_command command=git log -5 --stat
    ↳ commit 131c8f431a4b523d199a5860d2ae98b594356890 (7 lines, compressed from 614 to 59 tokens)
  ▸ run_command command=git log -5 --stat --no-color | cat
    ↳ commit 131c8f431a4b523d199a5860d2ae98b594356890 (54 lines)

The first call cost 59 tokens instead of 614, and it also lost the file list the question was about, because rtk's filter keeps the subjects of a log and drops the stats. The model read the note, asked again through a pipe, which the shrinker leaves alone, and paid the full price for the one call that needed it. That is the trade to make: compress by default, and leave the model a way to the raw output. An earlier version of the shrinker filtered pipelines and custom formats as well, and a --pretty=format: log came back as a row of dashes; the model routed around that too, in eight calls instead of one. On this repository a git log --stat is 88% smaller through rtk and a find over the source tree 91% smaller, while PHPUnit's eleven lines come back as they are, because rtk has no filter for them and the shrinker does not ask. Everything is capped as well, so a runaway command cannot fill the context on its own.

Keep preferences

A preference is a sentence the model should read at the start of every turn, this session and the next. They live in .papi/preferences.md, one bullet each, where you can edit them by hand:

final class Preferences
{
    public function add(string $preference): void
    {
        $preference = trim($preference);
        if ('' === $preference || \in_array($preference, $this->all(), true)) {
            return;
        }
 
        $header = is_file($this->file) ? '' : self::HEADER;
        file_put_contents($this->file, $header.'- '.$preference."\n", \FILE_APPEND);
    }
}

They reach the model through the system prompt, which is rebuilt for every round of the loop rather than fixed at start-up, so a preference remembered a minute ago already counts:

final class Instructions
{
    public function text(): string
    {
        $lines = [
            \sprintf('You are a coding agent working in the project at %s.', $this->workspace->root()),
            'You change the project through your tools: read a file before you change it, keep edits small and exact, and run the tests after a change when the project has them.',
            'Paths are relative to the project root. Ask before doing anything you are unsure about, and say what you did when you are done.',
        ];
 
        $preferences = $this->preferences->all();
        if ([] !== $preferences) {
            $lines[] = '';
            $lines[] = 'The user prefers:';
            foreach ($preferences as $preference) {
                $lines[] = '- '.$preference;
            }
        }
 
        return implode("\n", $lines);
    }
}

There are two ways in: /remember on the prompt, and a tool, so the model can keep something you said in passing:

final class PreferenceTools
{
    #[Tool('Remember a lasting preference of the user about how they like code and work done, so this and future sessions follow it.')]
    public function remember(#[Description('The preference, as one sentence')] string $preference): string
    {
        $this->preferences->add($preference);
 
        return \sprintf('Remembered: %s', $preference);
    }
}

This is the part of the transcript I like best. The first task produced $locale === 'pt'. Then:

❯ /remember I like Yoda conditions: the constant goes on the left of a comparison.
  Remembered: I like Yoda conditions: the constant goes on the left of a comparison.
❯ Now make greet refuse an empty name with an InvalidArgumentException, test included, and run the tests again.
  ▸ read_file path=src/Greeter.php
    ↳ <?php (18 lines)
  ▸ edit_file path=src/Greeter.php search=namespace Sample;  final class Greeter {     ... replace=namespace Sample;  use InvalidArgumentExcepti...
    ↳ Edited src/Greeter.php.
  ▸ edit_file path=src/Greeter.php search=        if ($locale === 'pt') { replace=        if ('pt' === $locale) {
    ↳ Edited src/Greeter.php.

Nobody asked for the second edit. The preference was in the system prompt, the old comparison was in a file it had just read, and it fixed it on the way past.

The terminal, briefly

The layout is twenty lines of ANSI, borrowed from phpspec's pair mode. The screen is split into a scroll region for the conversation and three reserved rows at the bottom: a divider, the prompt, and a status line with the model, the session and the tokens. The prompt never moves, the conversation scrolls above it, and off a terminal the same class prints plainly, which is how the transcripts in this post were captured.

$this->output->write(\sprintf("\033[r\033[2J\033[H\033[1;%dr\033[1;1H", $this->height - 3));

Everything else is a readline() call and a loop that sends a line to the agent or to a slash command.

What you have

Twenty-six classes, fifty-five phpspec examples, and an agent that reads your code, changes it in exact snippets, runs your tests when you let it, remembers what it did yesterday, keeps its conversation inside the model's window, and writes the way you asked it to. Papi gave the provider, the conversation, the tool attributes, the store, the token estimator and the rtk adapter; the rest is the kind of PHP you write every week.

The things left out are the things to add next: structured output for the summariser, streaming so the answer appears as it is written, hooks for logging every tool call, and a second provider behind the same loop. Papi has all four; the loop does not change.

← all posts

Write your own coding agent in PHP with Papi — Marcello Duarte