What the agent does
The agent starts from the issue text and the repository as it is. In an application shaped like symfony/demo it reads composer.json and sees PHP 8.4 or newer, symfony/framework-bundle at 8.1.0 in composer.lock, Doctrine ORM, Twig with symfony/ux-icons, and no symfony/serializer, so it writes the CSV with fputcsv on a StreamedResponse rather than adding a package for a one-column-per-field export. It then reads config/routes.yaml, where every controller route sits under a /{_locale} prefix, and src/Controller/Admin/BlogController.php, whose class carries #[Route('/admin/post')] and #[IsGranted(User::ROLE_ADMIN)], and whose index action loads PostRepository::findBy(['author' => $user], ['publishedAt' => 'DESC']) for the signed-in user.
The export follows that shape. A new export action with #[Route('/export.csv', name: 'admin_post_export', methods: ['GET'])] is placed above the /{id:post} routes so the literal segment wins, loads the same author-scoped list the index uses, and streams one header row and one row per post with the Post entity's title, slug, summary, publishedAt and the tag names from its tags collection, which src/Entity/Post.php orders by name. The response sets Content-Type: text/csv; charset=UTF-8 and a Content-Disposition attachment filename, and a utf8 byte-order mark is left out unless the issue asks for one for a spreadsheet import.
The button goes into templates/admin/blog/index.html.twig next to the existing admin_post_new link, with a tabler icon from symfony/ux-icons and a label through the trans filter like every other string on that page. The new key is added to translations/messages+intl-icu.en.xlf; the repository has 31 message catalogues, and the pull request says which ones still need the string rather than pasting English into each of them. The lint workflow in .github/workflows/lint.yaml runs lint:twig, lint:xliff and lint:translations on the pull request, so a malformed XLIFF or an untranslatable key fails there, not in production.
Tests go into tests/Controller/Admin/BlogControllerTest.php, which already signs in as jane_admin with loginUser in setUp and relies on the DAMA Doctrine test bundle in phpunit.dist.xml to roll each test back. A new testAdminExportCsv requests /en/admin/post/export.csv, asserts a successful response, the text/csv content type and a first line that is the header row, and the getUrlsForRegularUsers data provider gains the export URL so the existing testAccessDeniedForRegularUsers proves a ROLE_USER account gets 403. The agent runs vendor/bin/phpunit, then vendor/bin/php-cs-fixer fix --dry-run and vendor/bin/phpstan, the same tools the lint workflow runs, until all three are clean.
Start it with one API call
POST https://api.prix.dev/o/{org}/sessions with this body:
{
"agent": "claude",
"input": "A Linear issue labeled agent says: \"Add a CSV export to the admin backend. From the post list, an admin should be able to download their own posts as a CSV with title, slug, summary, published date and tags.\" Read config/routes.yaml, src/Controller/Admin/BlogController.php, src/Entity/Post.php, templates/admin/blog/index.html.twig and tests/Controller/Admin/BlogControllerTest.php first. Add the route to the existing admin controller under the ROLE_ADMIN guard, stream the CSV with fputcsv, add the button with a translated label, add the English translation key, add a PHPUnit test for the export and for a regular user being refused, run vendor/bin/phpunit, php-cs-fixer and phpstan, and open a pull request that links the issue.",
"resources": [
{
"url": "https://github.com/your-org/your-app"
}
]
}- Get an API key. In the console, open Settings, then API keys, and create a key for your org. Send it in the Authorization header as a Bearer token.
- Send the request. POST the body in request.json to https://api.prix.dev/o/{org}/sessions, with your repository as the resources URL. The run starts in its own cloud sandbox.
- Read what came back. Follow the run on its live stream or wait for the run.completed webhook, then read the result and its trajectory.
What comes back
What comes back is a pull request on its own branch. The diff touches src/Controller/Admin/BlogController.php for the new action, templates/admin/blog/index.html.twig for the button, translations/messages+intl-icu.en.xlf for the label, and tests/Controller/Admin/BlogControllerTest.php for the export test and the extra data-provider row. No new Composer package appears in composer.json or composer.lock, because the CSV is written with PHP's own fputcsv. The description quotes the issue, lists the columns in order, and names the catalogues that still need a translation.
The checks are the proof. The Tests workflow in .github/workflows/tests.yaml runs vendor/bin/phpunit on PHP 8.4 and 8.5 on Ubuntu, and on 8.4 on macOS and Windows, so the new test runs on all four. The Lint workflow runs PHP-CS-Fixer, composer validate, and the lint:yaml, lint:twig, lint:xliff, lint:translations and lint:container console commands. The session's trajectory at GET /o/{org}/sessions/{id}/messages keeps every PHPUnit and PHPStan invocation with its output, so a reviewer reads the suite passing rather than taking the description's word for it.
Before merging, a reviewer checks three things: that the export is scoped to the signed-in author exactly as the index is, so one admin cannot download another's drafts; that the route is declared before the /{id:post} routes and the test for a regular user answers 403; and that the column order matches what the issue asked for. Nothing reaches main until the pull request merges.
Run it on every Linear issue labelled agent
Sign in to Linear once with POST /me/connections {"type": "app", "name": "linear"} and finish the authorize URL in the console. Then add an automation to the Project with this prompt and a linear trigger on linear.issue.labeled, with the label you reserve for the agent in config.labels and, if only some teams may hand work off, their keys in config.teams. The trigger adds the issue after the prompt and fills in no placeholder, so the agent reads the request from the event itself. The automation's max_runs_per_hour, between 1 and 30, bounds how many issues it takes on in an hour, and each one gets its own sandbox and its own pull request.
From your own server instead, a handler that receives Linear's webhook for the label sends POST /o/{org}/sessions with the issue URL in resources beside the repository: https://linear.app/<workspace>/issue/ENG-42 puts the issue text in the prompt, read through your Linear connection. Send a request_id derived from the issue id so a redelivered webhook returns the run it already started. Give that key a webhook_url and Rush POSTs run.completed, run.failed or run.cancelled when the turn ends, signed per Standard Webhooks, with data.session_url to read for the pull request link to post back on the issue.
The use case this job follows, with its automation and trigger: Turn a Linear label into a PR.
Why run it on Rush?
An export is a small feature with a long tail: the route, the guard, the template, the translation catalogue and the test each live in a different directory, and the lint workflow checks all of them. On a laptop that means a PHP 8.4 install, a composer install, and three tools run by hand before the first push. On Rush the run happens in its own sandbox with its own clone: the agent runs vendor/bin/phpunit, php-cs-fixer and phpstan there, and pushes only when all three are clean, while your working tree stays where you left it.
The result is checkable rather than claimed. The pull request carries the diff and the issue link, the repository's own Tests and Lint workflows run on it across PHP versions and operating systems, and the trajectory keeps every command with its output. Five labelled issues mean five sandboxes and five independent pull requests, within the automation's hourly cap, instead of five serial checkouts on one machine.
Frequently asked questions
Why not add the Symfony Serializer and its CSV encoder?
The demo does not require symfony/serializer, and composer.lock has no entry for it. For a fixed set of scalar columns, fputcsv on a StreamedResponse does the job with no new dependency and no normalizer configuration. If your application already uses the Serializer, the agent reads composer.json, sees it, and uses the csv encoder instead; the pull request says which it chose and why.
Will the route clash with the `/{id:post}` routes?
No. The new route is declared with a literal /export.csv path ahead of the /{id:post} routes in the same controller, and the existing ones carry requirements: ['id' => Requirement::POSITIVE_INT], so export.csv can never match as an id. The test requests /en/admin/post/export.csv under the /{_locale} prefix from config/routes.yaml and asserts a CSV, not a 404.
Does the export include every post or only mine?
Only the signed-in admin's posts, which is what the admin index already shows: index calls findBy(['author' => $user]) and the export reuses that query. The test signs in as jane_admin, so every row it reads belongs to that account. If you want a site-wide export, say so in the issue and the agent drops the author filter and says so in the pull request.
What about the other 30 translation catalogues?
The agent adds the key to messages+intl-icu.en.xlf and lists the other catalogues in the pull request rather than inventing translations. The Lint workflow's lint:xliff and lint:translations steps check that what was added is well formed. Translators fill in the rest in their usual flow.
How do I know the tests ran?
The trajectory at GET /o/{org}/sessions/{id}/messages keeps every vendor/bin/phpunit run with its output, and the pull request triggers the repository's Tests workflow on PHP 8.4 and 8.5. If the suite cannot pass, the agent says so in its final message, which the webhook carries as result, instead of reporting a pull request.