Architecture
About 948 wordsAbout 3 min
Configuration
Priority: OS env → .env → config.php → built-in defaults.
- Shared hosting:
config.php(gitignored, likewp-config.php) — editable over FTP. - Docker/VPS/CI:
SPORA_*env vars, skipconfig.phpentirely.
Encryption key separation: The DB stores encrypted tool credentials; the key must never be in the same backup. The path is recorded in config.php as key_path (default install writes storage/secret.key). SPORA_SECRET_KEY (base64 env var) bypasses the file entirely for containers; SPORA_KEY_PATH overrides the file path instead.
Tool Taxonomy
ToolInterface — every tool implements Spora\Tools\ToolInterface. Input vs. output is a per-operation flag, not a class distinction. Read-only / generative operations (requiresApprovalByDefault: false) execute without approval; operations marked requiresApprovalByDefault: true are intercepted by the Orchestrator for human approval.
Approval resolution for an operation:
agent_tool_operation_overrides.default_requires_approvalper-agent, per-operation override (0/1/null)- Fall back to the operation’s
#[ToolOperation(requiresApprovalByDefault:)]class default
If approval required → serialize AgentState to DB as PENDING_APPROVAL, PHP process exits. On human approval → status set to RUNNING (Sync) or QUEUED (Worker). tick() is invoked again only in sync mode; in worker mode the daemon picks up the task on its next drain cycle (see Orchestrator::resume() at app/Agents/Orchestrator.php:585-589).
Orchestrator Loop
Stateless and short-lived. Each tick() is one full LLM turn (Think → Act). Structured in three phases to avoid holding a DB connection during network I/O:
- Claim — short
lockForUpdate()transaction: validate status. Lock released before any network call. - LLM call — blocking HTTP call outside any transaction.
step_countis incremented after the lock is released. - Write — append history rows, update task status.
flowchart LR
start(["start()"])
tick["tick()"]
claim["claim<br/>(lockForUpdate)"]
llm["LLM call<br/>(outside transaction)"]
text["text response"]
input["InputTool call"]
output["OutputTool call"]
approved["auto-approved"]
grant["approval granted"]
required{"requires approval?"}
history1["append history"]
history2["append history"]
completed(["COMPLETED"])
failed(["FAILED"])
pending(["PENDING_APPROVAL"])
cancel(["CANCELLED"])
resume(["resume()"])
reject(["reject()"])
max{{"step_count >= max_steps?"}}
start --> tick
tick --> claim --> llm
llm -->|text| text --> completed
llm -->|InputTool| input --> history1 --> tick
llm -->|OutputTool| output --> required
required -->|no| approved --> history1
required -->|yes| grant --> history2 --> tick
required -->|yes| pending
pending -->|resume| resume --> tick
pending -->|reject| reject --> tick
tick --> max
max -->|yes| failed
max -.->|no| claim
classDef entry fill:var(--spora-paper),stroke:var(--spora-warm),color:var(--spora-ink)
classDef action fill:var(--spora-paper-deep),stroke:var(--spora-warm-deep),color:var(--spora-ink)
classDef terminal fill:var(--spora-cream),stroke:var(--spora-warm-deep),color:var(--spora-ink),font-weight:bold
class start,tick,claim,llm,text,input,output,required,approved,grant,history1,history2,max action
class completed,failed,pending,cancel,resume,reject terminalStatus transitions: QUEUED → RUNNING → COMPLETED | FAILED | PENDING_APPROVAL ⇄ RUNNING → CANCELLED (PENDING is the initial value written by the migration; in practice the worker transitions QUEUED→RUNNING before the first tick. The CANCELLED terminal status is set by TaskService::cancelRetryChain — REJECTED is the analogous status for tool_calls rows, not tasks.)
Worker Modes (SPORA_SYNC_MODE)
SPORA_SYNC_MODE is a boolean that flips a single worker_mode config flag (true → Sync, false → Worker). Only two worker modes exist: Sync and Worker.
| Mode | Default | Behaviour |
|---|---|---|
sync (SPORA_SYNC_MODE=true) | ✓ | start() creates task as RUNNING and calls tick() inline. HTTP response blocked until agent completes. Suitable for dev and lightweight deployments. |
worker (SPORA_SYNC_MODE=false) | start() creates task as QUEUED and returns immediately. Run php bin/spora worker:run (default = daemon, --once for cron, --once --include-queue for cron-with-queue) to drain. |
In Worker mode, multi-step tasks (multiple LLM turns) still run synchronously within a single worker invocation — the loop continues until COMPLETED, FAILED, or PENDING_APPROVAL.
For the full details on tick phases, task lifecycle, and Mercure publishing, see Agent loop and async mode.
Plugin System
Drop a folder into plugins/ with a plugin.json manifest (and optional Plugin.php). Auto-discovered at boot — no manual registration.
Boot sequence (app/Plugins/PluginLoader.php):
- Glob
plugins/*/plugin.jsonand read each manifest - Register PSR-4 mappings from
autoload.psr-4with the Composer classloader require_oncebootstrap files fromautoload.files(e.g. the plugin’s ownvendor/autoload.php)require_oncethe manifest’sfile(defaultPlugin.php)- Instantiate the declared class; call its
autoload()for additional PSR-4 bindings tools(),drivers(),agentTemplatePaths(),schemaVersion(),migrationsPath()→ register contributions.recipePaths()is retained on the interface but deprecated in favour ofagentTemplatePaths().register(ContainerBuilder)→ arbitrary DI bindings
Plugins can contribute: tools, LLM drivers, agent templates, and database migrations. See app/Plugins/PluginInterface.php and the Plugin system page.
Status: wired.
PluginLoadernow fully wires every contribution surface from a plugin’s manifest entry point: agent templates (agentTemplatePaths()), drivers (drivers()), tools (tools()), database migrations (migrationsPath()+schemaVersion()), and arbitrary DI bindings (register(ContainerBuilder)). New drivers, tools, templates, and migrations contributed via plugins take effect automatically once the plugin is installed — no additional glue inapp/Plugins/PluginLoader.phporconfig.phpis required. The historicalrecipePaths()hook is kept on the interface for compatibility but is superseded byagentTemplatePaths().
Plugin conflicts: duplicate slugs or duplicate entry-point FQCNs are silently skipped — first-loaded wins. Plugin Composer dependencies are isolated by shipping a separate vendor/ per plugin (declared in autoload.files); the host vendor tree is not affected.
Database
SQLite by default (zero config), MySQL/MariaDB supported via config.php or env vars (SPORA_DB_DRIVER=mysql + SPORA_DB_HOST/PORT/NAME/USER/PASSWORD). All schema managed by DatabaseSchemaInstaller using Illuminate Schema Builder — versioned, component-aware, with a hot-path stamp cache. See the Database schema page.
Runtime artifacts in storage/: .schema_stamp (DB installer cache) and spora-worker.lock (single-instance worker lock) are runtime state, not data — exclude them from backups. See the Backups page for what to back up.