What: A portable, zero-configuration AI agent orchestration platform that runs on any shared host (cPanel/FTP) without system dependencies beyond PHP 8.4+.
php artisan boost:add-skill spora-ai/spora-core
Save this content to: AGENTS.md
# Spora
**What:** A portable, zero-configuration AI agent orchestration platform that runs on any shared host (cPanel/FTP) without system dependencies beyond PHP 8.4+.
**Stack:** Spora is standalone PHP — NOT Laravel. It borrows components from the Laravel and Symfony ecosystems for portability and reliability, but runs no full stack framework.
| Package | Role | Why |
|---|---|---|
| `symfony/http-foundation` | Request/response objects | Cleaner API than raw PHP globals |
| `symfony/console` | CLI framework | `bin/spora` commands |
| `symfony/http-client` | HTTP client | HTTP transport for LLM driver requests |
| `symfony/mailer` | Email sending | `SendEmailTool` via Symfony Mailer |
| `symfony/yaml` | YAML parsing | Parsing recipe definition files |
| `nikic/fast-route` | Routing | Fast, standalone router |
| `php-di/php-di` | Dependency injection | Framework-agnostic DI container |
| `illuminate/database` (Eloquent) | Database ORM | Zero-config ORM with SQLite support |
| `delight-im/auth` | Authentication | Lightweight, standalone auth |
| `monolog/monolog` | Logging | PSR-3 logger, PII-safe argument policy |
| `dragonmantank/cron-expression` | Cron parsing | Parsing `cron_expression` for scheduled runs |
| `vlucas/phpdotenv` | Env loading | Loading `.env` files on boot |
| `webklex/php-imap` | Email reading | IMAP access for `ReadEmailTool` |
| `chriskonnertz/string-calc` | Math expressions | Evaluating math strings in `CalculatorTool` |
| `pestphp/pest` | PHP testing | Elegant testing framework |
| Vue 3 + Vite + Tailwind + radix-vue + lucide-vue-next | Frontend | Modern JS stack (shared with Laravel Breeze/Fortify) |
---
## Development Rules
### Enforceable
- `declare(strict_types=1)` on every PHP file
- `final` on all classes unless inheritance is required
- No DB calls in constructors — boot explicitly via `Database::bootDatabaseConnectionOnly()`
- No mocks for integration tests that already boot the DB via `beforeEach`
- Don't add error handling, fallbacks, or abstractions beyond what the task requires
- **Never commit or push directly to `main`.** Every change goes through a feature branch and a pull request — even single-commit fixes. Branch naming: `<scope>/<phase-or-feature>` (e.g. `coverage/phase-1.4-orchestrator-and-controllers`, `feat/tool-authooring-dx`, `fix/logout`). Local `main` may be fast-forwarded via `git pull --ff-only` but never receives a direct push. SonarQube's `new_coverage` metric is calculated per PR, so bypassing the PR flow breaks the quality-gate signal.
### CLI Entry Points
Spora has two CLI binaries, one for runtime/operations and one for build-time
tools that must run in a clean checkout with only the source tree.
**Runtime: `bin/spora`** — boots the Kernel, DI container, and `storage/secret.key`.
This is the operator-facing CLI for installing, migrating, seeding, running workers.
- `spora:install` — Initial setup (idempotent migrations)
- `db:reset` — Wipe the database (SQLite file or MySQL DROP+CREATE) and clear the schema stamp
- `db:seed` — Seed database with sample data
- `worker:run` — Run async worker (queued mode)
- `worker:run --scheduled` — Run scheduled tasks worker
- `spora:openapi [--output=openapi.json] [--check]` — Generate / drift-check the OpenAPI spec
- `spora:audit-operation-overrides` — Read-only report of tool-config rows whose `tool_class` no longer resolves
- Full CLI reference: `bin/spora --help`
**Build: `bin/spora-build`** — deliberately skips the Kernel / DI / secret-key boot
because downstream tooling (CI, sibling-repo docs builds) checks out spora-core
into a sibling directory and runs this binary without an installed app. Lives
in `app/Build/`; gates on the `zircote/swagger-php` dev dependency. Today:
- `openapi:generate [output]` — emit `openapi.json` to the given path (default
`openapi.json` resolved against `BASE_PATH` or the package root)
- `openapi:check [reference]` — exit non-zero if the freshly regenerated spec
differs from `reference` (default `openapi.json`)
- Composer alias: `composer openapi` (alias for `openapi:generate`)
### Storage
`storage/` directory:
- `database.sqlite` — Application database
- `spora.log` — Application logs (PSR-3, Monolog)
- `php.log` — PHP error/fatal logs
### Configuration
Spora's settings are **declared once**, one method per section in
`app/Settings/SettingDeclarations.php`, which `SettingsRegistry` is the reader
for. Together they are the authority on every setting key, its type, its
default and whether it is `bootOnly` — nothing else needs a matching edit, and
a key that is not declared there is not a setting.
Read them through the `Config` value object (`app/Core/Config.php`):
```php
$config->getBool('SPORA_ALLOW_REGISTRATION')
$config->getInt('SPORA_TOOL_HTTP_TIMEOUT')
$config->getString('SPORA_DB_DRIVER')
```
The container binding is `Config::class`. There is no `array $config` and no
`'config'` key.
Precedence, last wins:
```
registry defaults < config.php < .env / OS environment < database
```
Keys marked `bootOnly` in the registry are read before the database can be
opened, so they come from files only and are never stored in it. Enumerating
them here is a list that rots — `SettingsRegistry::bootOnlyKeys()` is the
authority. The database layer is applied by `Kernel::handle()` for a web
request and by each settings-reading command on the CLI, never by the `Config`
factory — see
`Config::applySettingsOverlay()` for why.
### CI
GitHub Actions runs on push to `main`, on `v*` tags, and on pull requests (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml)). **Gating** jobs: `test` (Pest + coverage, default PHP), `test-85` (Pest, PHP 8.5), `static-analysis` (PHPStan, plus OpenAPI drift, merge-marker and markdown-link checks), `sonar`. **Advisory, not gating:** `test-mysql` and `test-mariadb`, which run the same suite against a real engine but carry `continue-on-error: true` until the SQLite-specific migration tests (`PRAGMA`/`sqlite_master`) are ported — a red run there is reported and blocks nothing. The three range-based checks in `static-analysis` cover pulls *and* pushes: the base sha is the PR base or the push's `before`, so a conflict marker merged straight to `main` is still caught. Formatting is not gated in CI — run `composer format` before committing.
### SonarQube (MCP)
- Project key `spora-ai_Spora` (see `sonar-project.properties`).
- For PRs use the `pullRequest` key from `list_pull_requests`; for long-lived branches use `branch`. Never pass a git branch name to `pullRequest`.
- Tool names vary by MCP server version — read the server's tool list, don't memorise.
> **Architecture deep-dive:** The Orchestrator loop, tick phases, worker modes, and plugin system are documented in the [architecture](https://docs.spora-ai.com/reference/concepts/architecture.html) and [agent loop / async](https://docs.spora-ai.com/reference/concepts/agent-loop-async.html) pages.
### Testing
- Backend: **always run `composer test:parallel` for verification (~22s on a typical PR).** Never run `composer test` or `vendor/bin/pest` (serial) for the full suite — they take ~270s. Serial Pest is reserved for debugging one specific failing test. A parallel run may report 0–12 `risky` tests across reruns (well-known parallel-isolation flake); if `passed + risky == total` and there are no `failed`, the suite is clean — proceed.
- **Per-engine testing.** Set `SPORA_TEST_DB_DRIVER=mysql|mariadb` to run the suite against a real engine (default `sqlite`). On MySQL/MariaDB, every Pest parallel worker creates its own `spora_test_w<PID>_<RAND>` database, installs the schema once, and drops it on exit. Override the server via `SPORA_TEST_DB_HOST`/`_PORT`/`_USER`/`_PASSWORD` (defaults: `127.0.0.1:3306 root:root`). CI runs against MySQL 9.7 and MariaDB 12.3 on every push and PR, but those two jobs are advisory (`continue-on-error`) until the SQLite-specific migration tests are ported — see [CI](#ci).
- Frontend unit: `composer frontend:test` (Vitest)
- E2E: not wired up — no Playwright dep, no `frontend/tests/e2e/` (see [frontend architecture](https://docs.spora-ai.com/reference/concepts/frontend-architecture.html))
### Code Comments
See the [code documentation standards](https://docs.spora-ai.com/reference/concepts/code-documentation.html) for comment intent (DELETE noise, KEEP rationale, ADD docblocks).
---
## Project Structure
```
/app — PHP application code (MVC-style)
/Agents — Agent models and orchestration logic
/Auth — Authentication (AuthService, AuthController)
/Console — CLI commands (install, worker, etc.)
/Core — Kernel, Router, DI container, base classes
/Drivers — LLM driver implementations (OpenAI, Anthropic)
/Http — Controllers, middleware, request/response handling
/Models — Eloquent models
/Plugins — Plugin loader and hooks
/Recipes — Recipe scanner
/Services — Business logic (ToolConfigService, NotificationService, etc.)
/Tools — Built-in tool implementations
/bin/spora — CLI entry point
/config.php — Application configuration
/database — Migrations and seeders
/frontend — Vue 3 + Vite + Tailwind frontend
/plugins — Installed plugins (auto-discovered)
/public — Web root (PHP built-in server)
/recipes — Recipe definitions (YAML)
/storage — Runtime files (SQLite DB, logs)
/tests — Pest test suites
```
---
## Local Development
### 1. Install dependencies
```bash
composer install
cd frontend && npm install
```
### 2. Start the dev server
```bash
composer dev # Starts PHP built-in server + Vite concurrently
```
### 3. Run tests
```bash
composer test # Backend (Pest)
composer frontend:test # Frontend unit (Vitest)
```
### 4. Database
- SQLite at `storage/database.sqlite`
- Run migrations: `bin/spora spora:install` (idempotent; safe to re-run)
- Seed: `bin/spora db:seed` (skips itself if users/agents already exist)
- Reset: `php bin/spora db:reset --force` (wipes the file or drops + recreates the MySQL DB; without `--force` it prompts)
---
## Docker Deployment
Docker configuration is in the `docker/` subfolder:
```
docker/
├── docker-compose.yml # spora + mariadb (no separate Mercure)
├── Dockerfile
├── frankenphp.conf # FrankenPHP config with native Mercure hub
├── supervisord.conf
└── .env.local.example # Template — copy to .env.local and configure
```
Key differences from local dev:
- `SPORA_DB_HOST=mariadb` (Docker service name, not `127.0.0.1`)
- `SPORA_MERCURE_URL=http://localhost/.well-known/mercure` (FrankenPHP native)
Start: `docker compose -f docker/docker-compose.yml up`
---
## How to Add a Tool
1. **Create the tool class** in `app/Tools/`:
```php
final class MyTool implements ToolInterface
{
public function name(): string { return 'my_tool'; }
public function description(): string { return 'Does something'; }
public function execute(array $params): ToolResult { ... }
}
```
2. **Register it** via `ToolConfigService` or a plugin's `ContainerBuildingEvent` subscriber.
3. **Add tests** in `tests/` using Pest.
4. **Document** the tool parameters and behavior — tools are user-facing in the agent config UI.
For plugin-based tools, place the class in your plugin directory and use the `PluginLoader` hook system.
> **Full tool system docs:** Naming conventions, `#[Tool]` attribute, `#[ToolSetting]`, `#[ToolParameter]`, `InputToolInterface` vs `OutputToolInterface`, and the settings key convention are on the [tools](https://docs.spora-ai.com/reference/concepts/tools.html) page.
### Agent `notes` field vs `AgentTool`
Operators store runbooks and behaviour hints on an agent through the `notes`
markdown field, either via `PATCH /api/v1/agents/{id}` / the dashboard
settings UI or by seeding the agent template. The `AgentTool`
(`app/Tools/AgentTool.php`) exposes two `AgentTool` operations for the LLM:
`read_notes` (returns current notes) and `write_notes` (defaults to
`mode: append`, also accepts `prepend`). Inside `AgentTool` itself,
`write_notes` is the only path that mutates `notes` from the LLM side;
`update_agent` strips `notes` from its patch and refuses the
call if nothing else remains, so an LLM cannot use config-write to smuggle
a notes mutation. Wholesale replacement is a separate
`write_notes_overwrite` operation, disabled by default and per-call
operator-approved — the destructive path cannot ride on the safe default.
---
## Feature Overview
For a complete list of what's implemented, see the [documentation index](https://docs.spora-ai.com/).
Key areas (all on [docs.spora-ai.com](https://docs.spora-ai.com/)):
- **Orchestrator loop, config priority, plugin system, recipes** → [architecture](https://docs.spora-ai.com/reference/concepts/architecture.html)
- **Database schema and migrations** → [schema](https://docs.spora-ai.com/reference/concepts/schema.html)
- **REST API reference** → [REST API reference](https://docs.spora-ai.com/reference/api.html)
- **LLM drivers** → [drivers](https://docs.spora-ai.com/reference/concepts/drivers.html)
- **Tool system and settings** → [tools](https://docs.spora-ai.com/reference/concepts/tools.html)
- **Plugin system** → [plugin system](https://docs.spora-ai.com/reference/concepts/plugins-system.html)
- **Async workers and deployment** → [agent loop / async](https://docs.spora-ai.com/reference/concepts/agent-loop-async.html)
- **Frontend architecture** → [frontend architecture](https://docs.spora-ai.com/reference/concepts/frontend-architecture.html)
## Backlog
See the [develop section](https://docs.spora-ai.com/develop/) for detailed descriptions, implementation notes, and dependencies.
What: A portable, zero-configuration AI agent orchestration platform that runs on any shared host (cPanel/FTP) without system dependencies beyond PHP 8.4+.
Stack: Spora is standalone PHP — NOT Laravel. It borrows components from the Laravel and Symfony ecosystems for portability and reliability, but runs no full stack framework.
| Package | Role | Why |
|---|---|---|
symfony/http-foundation |
Request/response objects | Cleaner API than raw PHP globals |
symfony/console |
CLI framework | bin/spora commands |
symfony/http-client |
HTTP client | HTTP transport for LLM driver requests |
symfony/mailer |
Email sending | SendEmailTool via Symfony Mailer |
symfony/yaml |
YAML parsing | Parsing recipe definition files |
nikic/fast-route |
Routing | Fast, standalone router |
php-di/php-di |
Dependency injection | Framework-agnostic DI container |
illuminate/database (Eloquent) |
Database ORM | Zero-config ORM with SQLite support |
delight-im/auth |
Authentication | Lightweight, standalone auth |
monolog/monolog |
Logging | PSR-3 logger, PII-safe argument policy |
dragonmantank/cron-expression |
Cron parsing | Parsing cron_expression for scheduled runs |
vlucas/phpdotenv |
Env loading | Loading .env files on boot |
webklex/php-imap |
Email reading | IMAP access for ReadEmailTool |
chriskonnertz/string-calc |
Math expressions | Evaluating math strings in CalculatorTool |
pestphp/pest |
PHP testing | Elegant testing framework |
| Vue 3 + Vite + Tailwind + radix-vue + lucide-vue-next | Frontend | Modern JS stack (shared with Laravel Breeze/Fortify) |
declare(strict_types=1) on every PHP filefinal on all classes unless inheritance is requiredDatabase::bootDatabaseConnectionOnly()beforeEachmain. Every change goes through a feature branch and a pull request — even single-commit fixes. Branch naming: <scope>/<phase-or-feature> (e.g. coverage/phase-1.4-orchestrator-and-controllers, feat/tool-authooring-dx, fix/logout). Local main may be fast-forwarded via git pull --ff-only but never receives a direct push. SonarQube's new_coverage metric is calculated per PR, so bypassing the PR flow breaks the quality-gate signal.Spora has two CLI binaries, one for runtime/operations and one for build-time tools that must run in a clean checkout with only the source tree.
Runtime: bin/spora — boots the Kernel, DI container, and storage/secret.key.
This is the operator-facing CLI for installing, migrating, seeding, running workers.
spora:install — Initial setup (idempotent migrations)db:reset — Wipe the database (SQLite file or MySQL DROP+CREATE) and clear the schema stampdb:seed — Seed database with sample dataworker:run — Run async worker (queued mode)worker:run --scheduled — Run scheduled tasks workerspora:openapi [--output=openapi.json] [--check] — Generate / drift-check the OpenAPI specspora:audit-operation-overrides — Read-only report of tool-config rows whose tool_class no longer resolvesbin/spora --helpBuild: bin/spora-build — deliberately skips the Kernel / DI / secret-key boot
because downstream tooling (CI, sibling-repo docs builds) checks out spora-core
into a sibling directory and runs this binary without an installed app. Lives
in app/Build/; gates on the zircote/swagger-php dev dependency. Today:
openapi:generate [output] — emit openapi.json to the given path (default
openapi.json resolved against BASE_PATH or the package root)openapi:check [reference] — exit non-zero if the freshly regenerated spec
differs from reference (default openapi.json)composer openapi (alias for openapi:generate)storage/ directory:
database.sqlite — Application databasespora.log — Application logs (PSR-3, Monolog)php.log — PHP error/fatal logsSpora's settings are declared once, one method per section in
app/Settings/SettingDeclarations.php, which SettingsRegistry is the reader
for. Together they are the authority on every setting key, its type, its
default and whether it is bootOnly — nothing else needs a matching edit, and
a key that is not declared there is not a setting.
Read them through the Config value object (app/Core/Config.php):
$config->getBool('SPORA_ALLOW_REGISTRATION')
$config->getInt('SPORA_TOOL_HTTP_TIMEOUT')
$config->getString('SPORA_DB_DRIVER')
The container binding is Config::class. There is no array $config and no
'config' key.
Precedence, last wins:
registry defaults < config.php < .env / OS environment < database
Keys marked bootOnly in the registry are read before the database can be
opened, so they come from files only and are never stored in it. Enumerating
them here is a list that rots — SettingsRegistry::bootOnlyKeys() is the
authority. The database layer is applied by Kernel::handle() for a web
request and by each settings-reading command on the CLI, never by the Config
factory — see
Config::applySettingsOverlay() for why.
GitHub Actions runs on push to main, on v* tags, and on pull requests (see .github/workflows/ci.yml). Gating jobs: test (Pest + coverage, default PHP), test-85 (Pest, PHP 8.5), static-analysis (PHPStan, plus OpenAPI drift, merge-marker and markdown-link checks), sonar. Advisory, not gating: test-mysql and test-mariadb, which run the same suite against a real engine but carry continue-on-error: true until the SQLite-specific migration tests (PRAGMA/sqlite_master) are ported — a red run there is reported and blocks nothing. The three range-based checks in static-analysis cover pulls and pushes: the base sha is the PR base or the push's before, so a conflict marker merged straight to main is still caught. Formatting is not gated in CI — run composer format before committing.
spora-ai_Spora (see sonar-project.properties).pullRequest key from list_pull_requests; for long-lived branches use branch. Never pass a git branch name to pullRequest.Architecture deep-dive: The Orchestrator loop, tick phases, worker modes, and plugin system are documented in the architecture and agent loop / async pages.
composer test:parallel for verification (~22s on a typical PR). Never run composer test or vendor/bin/pest (serial) for the full suite — they take ~270s. Serial Pest is reserved for debugging one specific failing test. A parallel run may report 0–12 risky tests across reruns (well-known parallel-isolation flake); if passed + risky == total and there are no failed, the suite is clean — proceed.SPORA_TEST_DB_DRIVER=mysql|mariadb to run the suite against a real engine (default sqlite). On MySQL/MariaDB, every Pest parallel worker creates its own spora_test_w<PID>_<RAND> database, installs the schema once, and drops it on exit. Override the server via SPORA_TEST_DB_HOST/_PORT/_USER/_PASSWORD (defaults: 127.0.0.1:3306 root:root). CI runs against MySQL 9.7 and MariaDB 12.3 on every push and PR, but those two jobs are advisory (continue-on-error) until the SQLite-specific migration tests are ported — see CI.composer frontend:test (Vitest)frontend/tests/e2e/ (see frontend architecture)See the code documentation standards for comment intent (DELETE noise, KEEP rationale, ADD docblocks).
/app — PHP application code (MVC-style)
/Agents — Agent models and orchestration logic
/Auth — Authentication (AuthService, AuthController)
/Console — CLI commands (install, worker, etc.)
/Core — Kernel, Router, DI container, base classes
/Drivers — LLM driver implementations (OpenAI, Anthropic)
/Http — Controllers, middleware, request/response handling
/Models — Eloquent models
/Plugins — Plugin loader and hooks
/Recipes — Recipe scanner
/Services — Business logic (ToolConfigService, NotificationService, etc.)
/Tools — Built-in tool implementations
/bin/spora — CLI entry point
/config.php — Application configuration
/database — Migrations and seeders
/frontend — Vue 3 + Vite + Tailwind frontend
/plugins — Installed plugins (auto-discovered)
/public — Web root (PHP built-in server)
/recipes — Recipe definitions (YAML)
/storage — Runtime files (SQLite DB, logs)
/tests — Pest test suites
composer install
cd frontend && npm install
composer dev # Starts PHP built-in server + Vite concurrently
composer test # Backend (Pest)
composer frontend:test # Frontend unit (Vitest)
storage/database.sqlitebin/spora spora:install (idempotent; safe to re-run)bin/spora db:seed (skips itself if users/agents already exist)php bin/spora db:reset --force (wipes the file or drops + recreates the MySQL DB; without --force it prompts)Docker configuration is in the docker/ subfolder:
docker/
├── docker-compose.yml # spora + mariadb (no separate Mercure)
├── Dockerfile
├── frankenphp.conf # FrankenPHP config with native Mercure hub
├── supervisord.conf
└── .env.local.example # Template — copy to .env.local and configure
Key differences from local dev:
SPORA_DB_HOST=mariadb (Docker service name, not 127.0.0.1)SPORA_MERCURE_URL=http://localhost/.well-known/mercure (FrankenPHP native)Start: docker compose -f docker/docker-compose.yml up
Create the tool class in app/Tools/:
final class MyTool implements ToolInterface
{
public function name(): string { return 'my_tool'; }
public function description(): string { return 'Does something'; }
public function execute(array $params): ToolResult { ... }
}
Register it via ToolConfigService or a plugin's ContainerBuildingEvent subscriber.
Add tests in tests/ using Pest.
Document the tool parameters and behavior — tools are user-facing in the agent config UI.
For plugin-based tools, place the class in your plugin directory and use the PluginLoader hook system.
Full tool system docs: Naming conventions,
#[Tool]attribute,#[ToolSetting],#[ToolParameter],InputToolInterfacevsOutputToolInterface, and the settings key convention are on the tools page.
notes field vs AgentToolOperators store runbooks and behaviour hints on an agent through the notes
markdown field, either via PATCH /api/v1/agents/{id} / the dashboard
settings UI or by seeding the agent template. The AgentTool
(app/Tools/AgentTool.php) exposes two AgentTool operations for the LLM:
read_notes (returns current notes) and write_notes (defaults to
mode: append, also accepts prepend). Inside AgentTool itself,
write_notes is the only path that mutates notes from the LLM side;
update_agent strips notes from its patch and refuses the
call if nothing else remains, so an LLM cannot use config-write to smuggle
a notes mutation. Wholesale replacement is a separate
write_notes_overwrite operation, disabled by default and per-call
operator-approved — the destructive path cannot ride on the safe default.
For a complete list of what's implemented, see the documentation index.
Key areas (all on docs.spora-ai.com):
See the develop section for detailed descriptions, implementation notes, and dependencies.
How can I help you explore Laravel packages today?