Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
agent-creation
spora-ai/spora-core
claude-code
agent-creation
tool-configuration
agent-manifests
sub-agents
skill-discovery

When the user asks to create, set up, scaffold, or configure a new Spora agent, sub-agent, or specialised assistant; OR when a sub-task needs a toolset different from the calling agent's; OR when the user wants to edit an existing agent's configuration (name, description, system_prompt, scheduling, flags). Use for tasks like 'create me a weather agent', 'I need a research sub-agent', 'scaffold a translator', or 'rename agent 42 to Translation bot'. Do NOT use for editing the current agent's notes — those go through write_notes. Recommended tools: agent (operations create_agent, configure_tools, read_agent, update_agent, list_agents, get_available_tools, read_notes, write_notes, write_notes_overwrite).

Install
php artisan boost:add-skill spora-ai/spora-core

Save this content to: .claude/skills/agent-creation/SKILL.md

# Agent creation

When the user asks for a new agent, follow this protocol end-to-end. Do NOT try to invent the call sequence from memory — read this skill first, then drive the flow through `get_available_tools` and the three agent-tool calls.

## When to use this skill

Trigger on any of:

- "Create me an agent that …", "Set up a weather agent …", "Scaffold a translator …"
- "I need a sub-agent for X" or "Spin up an assistant that only uses Y"
- "Configure a new assistant like this one but with different tools"
- Any time the operator's intent is "make me a new agent" or "add a new assistant".

Do NOT use this skill for editing the *current* agent's notes (use `write_notes`) or for changing the current agent's name/system prompt (use `update_agent` directly — it's the right tool for in-place edits).

## List your existing agents before creating a new one

Before driving the create-agent flow, run `agent(action: "list_agents")` if there's any chance the user already has agents you could reuse or rename. The operator's most common reason to ask "create me a translator agent" is that they lost the agent_id after a turn boundary — `create_agent` returns a fresh pk each time it runs, so the cheap recovery is:

```json
{ "action": "list_agents" }
```

Returns a slim list of `{agent_id, name, description, is_archived}` rows (`#4 Custom Agent — does X`, with ` (archived)` appended for an archived agent) visible to the current user — their own agents, plus the agents of every group they belong to — ordered newest-first. Empty list means "go ahead and create". If the agent you want already exists at row N, skip step 1 — call `update_agent(agent_id: N, agent: {…})` or `configure_tools(agent_id: N, tools: […])` directly. `update_agent` accepts an optional `agent_id`; omitting it edits the calling agent.

A row being listed is not a guarantee you may act on it: a group agent the user merely belongs to is listed, but `update_agent` / `configure_tools` on it are refused unless they own or administer the group.

## Mandatory pre-flight

Before driving the flow:

1. **Read the current agent's manifest** via `agent(action: "read_agent")` (no `agent_id` — reads the calling agent) so you know what an existing agent looks like in this codebase. The result has two JSON blocks in `result_content`: a `Base config` block and a `Tool config` block (see *Canonical agent manifest* below).
2. **List available tools** via `agent(action: "get_available_tools")`. The version-2 payload tells you, per tool:
    - `tool_class` — the FQCN. **Use this for `configure_tools`.** NOT `call_name` and NOT `tool_name` (both removed in v2).
    - `plugin_slug` — `null` for core tools, the slug (e.g. `"weather"`) for plugin tools. Informational: it tells you where a plugin tool came from, nothing more.
   - `enabled` — current state on the calling agent (informational; new agents start fresh).
   - `ready_to_enable` — whether configure will succeed without operator setup.
   - `missing_required` — list of setting keys that block enablement (e.g. `["api_key"]`).
   - `operations[]` — each `{name, description, enabled, requires_approval}`. Convert to `[{name: "..."}]` objects before placing inside `configure_tools`'s `tools[].operations`.
   - `skills` — the skill discovery block: `allowed` is the calling agent's `allowed_skills` list right now, and `visible[]` is every skill the current principal can see, as `{name, description, active}`. Read `visible` to pick names; a name you did not read there is refused by `configure_tools`, and a name you read there is one the principal can be granted.

## Minimal-toolset protocol

Prefer fewer tools. Each one is an attack surface and a cost. Ask the user only when the answer materially changes the design:

- If the user said exactly which tools to enable, do that.
- If the request is open-ended ("a weather agent"), decide on the smallest reasonable toolset (e.g. weather plugin's API tool + the core Time tool).
- If two intents are ambiguous, ask **one** short clarifying question — never invent.

Prefer core tools (`plugin_slug: null`) over plugin tools when both would suffice. Plugins add install/runtime cost and operator approval.

## Canonical agent manifest (the wire shape)

Every agent read/write operation speaks this shape. `result_content` is the Markdown wrapper; `result_data` is the same structure as structured JSON.

````markdown
## Agent #6 — Weather Agent

1 of 18 tools enabled. 0 missing required config.

### Base config

```json
{
  "agent_id": 6,
  "name": "Weather Agent",
  "description": "Answers weather questions…",
  "system_prompt": "You are the Weather Agent…",
  "notes": null,
  "template_id": null,
  "version": null,
  "max_steps": 10,
  "allow_followup": true,
  "retry_after_minutes": 0,
  "max_retries": 0,
  "is_pinned": false,
  "is_archived": false
}
```

### Tool config

```json
{
  "tools": [
    {
      "tool_class": "Spora\\Plugins\\Weather\\Tools\\WeatherApiTool",
      "icon": "sun",
      "enabled": true,
      "operations": [
        { "name": "current",  "enabled": true, "requires_approval": false },
        { "name": "forecast", "enabled": true, "requires_approval": false },
        { "name": "search",   "enabled": true, "requires_approval": false },
        { "name": "astronomy","enabled": true, "requires_approval": false }
      ]
    }
  ],
  "missing_required": [],
  "warnings": []
}
```
````

Key invariants:

- `agent_id` is the numeric primary key. **`template_id` is no longer an identifier** — templates are creation labels, and multiple agents can share one. Always carry forward the numeric `agent_id` from `create_agent` (or `list_agents`).
- **There is no `is_favorite` key.** Favouriting is per-user and lives outside this payload (`POST/DELETE /api/v1/agents/{id}/favorite`), so nothing in the manifest reflects it — do not wait for it to appear.
- Per-tool entries in `tools[]` carry only `tool_class`, `icon`, `enabled`, and `operations[]` — slim by design, since `read_agent` / `update_agent` / `configure_tools` responses run on every LLM turn. Browsing-style enrichment (`display_name`, `description`) stays on `get_available_tools` (operator-facing). Pin the slim shape in your reply so an upstream change can't silently bloat the response.
- `tools[]` lists every registered tool (with `enabled: true|false`) so you can see at a glance what's active and what isn't. Per-tool `operations[]` carries the effective `enabled` / `requires_approval` state after per-agent overrides fold in.
- The Markdown preamble adds a `Disabled: ClassA, ClassB, …` line under the status line when at least one tool is disabled. The line is omitted entirely on the all-enabled case so the all-green path stays clean.
- `overrides[]` carries the per-operation audit trail — `{tool_class, operation, enabled, default_requires_approval}` rows for every op where the operator actively overrode the tool's default. **`enabled` and `default_requires_approval` are nullable**: `null` means the operator kept the tool's default for that field, `true|false` means the operator explicitly set it. This is what proves a `configure_tools` patch with `auto_approve: true` actually persisted — the `tools[i].operations[j].requires_approval` effective value would show `false` either way, but `overrides[]` is the only place that records "the operator's call landed on this op". **It says nothing about which tools are enabled** — that is `tools[].enabled`. An empty `overrides[]` after a `configure_tools` that only touched `enabled` is correct, not a lost write.
- `missing_required: ["<tool_class>:<setting_key>", ...]` lists configuration that blocks enablement. `[]` means no blockers.
- `warnings: []` is empty on success. Operator-upload templates may populate it with `TOOL_PLUGIN_MISSING` notes.

## Two-phase flow (LLM-facing)

The LLM does NOT create an agent and configure its tools in one call. Use:

1. **`create_agent`** — slim skeletal record. Capture the `agent_id` from the response.
2. **`configure_tools`** — apply the toolset. Pass `agent_id: <the new id from step 1>` so the right agent gets configured.
3. **`read_agent(agent_id: <the new id>)`** — verify the toolset is exactly what you wanted.

Call them in order. The result_content of each ends with the canonical manifest (via the two-JSON-block Markdown wrapper) so you can confirm what landed without a follow-up read.

## Sub-agent isolation

AgentTool ships with **inconsistent defaults by design**: three operations are on (`read_notes`, `write_notes`, `list_agents`), the other six are off, and four of those are approval-gated as well. The full per-operation table lives in the **agent-tool** skill and is not repeated here — two copies of that table is two chances to read a stale row.

What that means for this flow: `get_available_tools`, `create_agent`, `configure_tools`, `read_agent` and `update_agent` are all **off by default**. An operator who enabled the `agent` tool without enabling those five gave you a tool that refuses every call in this flow — starting with the pre-flight step below — with "operation not available". That is their setting, not a bug; name the operations you need rather than looking for another route in.

If you need to fully lock the sub-agent out of `agent` — i.e. exclude the tool entirely from its manifest — disable the `agent` tool at the agent-level in the dashboard, not per-operation. Per-op `enabled: false` keeps the entries visible in the manifest so you can audit the intent (the operator can still see "this op is disabled on purpose"), while a fully-disabled tool entry is dropped from `tools[]` entirely.

The Markdown preamble of every `read_agent` / `configure_tools` response shows the disabled FQCNs in a single `Disabled: ClassA, ClassB, …` line so the operator can scan the perimeter at a glance without parsing the trailing `tools[]` block.

### Step 1 — `create_agent`

```json
{
  "action": "create_agent",
  "payload": {
    "name": "Weather Agent",
    "description": "Answers weather questions: current conditions, forecasts, location search, and astronomy (sunrise/sunset, moon phase) worldwide.",
    "system_prompt": "You are the 'Weather Agent'. Use the Weather API tool to answer questions about weather, forecasts, and astronomy (sunrise/sunset, moon phase). Reply in the user's language. If a location is ambiguous, ask briefly or use the location-search operation. Always state the timezone when the user asks for a time.",
    "max_steps": 10,
    "allow_followup": true
  }
}
```

Result content (Markdown wrapper — `result_data` carries the same shape as structured JSON):

```text
Created agent #6 ('Weather Agent'). Configure tools next with
`configure_tools(agent_id: 6, tools: [...])` and verify with
`read_agent(agent_id: 6)`.

## Agent #6 — Weather Agent

0 of 18 tools enabled. 0 missing required config.

### Base config
{ … base-config JSON … }
### Tool config
{ … tool-config JSON … }
```

Capture the `agent_id` from `result_data.agent_id` (or the Markdown preamble) for the next two steps.

### Step 2 — `configure_tools(agent_id: <new id>)`

`configure_tools` accepts an optional `agent_id`. **Always pass it** in the create-then-configure flow so the right agent gets configured; omitting `agent_id` falls back to the calling agent (in-place edit semantics, useful but rarely what you want after `create_agent`):

```json
{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Plugins\\Weather\\Tools\\WeatherApiTool",
      "enabled": true,
      "operations": [
        { "name": "current",  "enabled": true },
        { "name": "forecast", "enabled": true },
        { "name": "search",   "enabled": true },
        { "name": "astronomy","enabled": true }
      ]
    },
    {
      "tool_class": "Spora\\Tools\\TimeTool",
      "enabled": true,
      "operations": [
        { "name": "now",    "enabled": true, "auto_approve": true },
        { "name": "format", "enabled": true, "auto_approve": true }
      ]
    }
  ]
}
```

`configure_tools` returns the canonical manifest of the targeted agent (same shape as `read_agent`). Use `tools[].operations[]` to pick which operations get enabled, and `auto_approve: true` to skip per-call operator approval.

### Step 3 — `read_agent(agent_id: <new id>)`

```json
{
  "action": "read_agent",
  "agent_id": 6
}
```

Returns the canonical manifest for agent #6. The Markdown wrapper in `result_content` lets you scan the pre-flight summary at a glance (`2 of 18 tools enabled. 0 missing required config.`) and inspect the per-tool/per-op state in the trailing JSON blocks. If a tool is missing or an operation is wrong, call `configure_tools(agent_id: 6, tools: [...])` again with the delta — `enabled: false` on a tool entry removes it; `enabled: true` re-adds it.

## Slim `create_agent` payload reference

The LLM-facing `create_agent` accepts only a slim subset of the agent-template schema. The full schema (with `id`, `version`, nested `agent{}`, nested `tools[]`) is reserved for the operator-upload endpoint at `POST /api/v1/agent-templates/import`.

| Key | Required | Type | Description |
| --- | --- | --- | --- |
| `name` | yes | string (1..200 chars) | Top-level agent name. NOT inside any wrapper. |
| `description` | no | string (≤2000 chars) | One-line description shown on the agent card. |
| `system_prompt` | no | string | The agent's persona. Edit later via `update_agent`. |
| `max_steps` | no | int (1..100, default 10) | Max LLM tool-call steps per task. |
| `allow_followup` | no | bool, default true | Whether followup tasks are allowed. |
| `retry_after_minutes` | no | int, default 0 | Cooldown between auto-retries. |
| `max_retries` | no | int, default 0 | Max auto-retries per task. |

`additionalProperties: false` — anything else (including the legacy `id`, `version`, `agent{}`, `tools[]`, `template_id`) is rejected with a literal "send X instead" example. Plugin dependencies are not part of this payload at all: a plugin's tools are named by FQCN in `configure_tools` after the agent row exists, and installation is out-of-band (dashboard or `spora plugin install`).

## `configure_tools` shape (LLM-facing)

`configure_tools(agent_id?, tools: [...])`:

| Field | Required | Description |
| --- | --- | --- |
| `agent_id` | no | Numeric pk returned by `create_agent`. Omit to operate on the calling agent. Cross-user agent ids return "not found". |
| `tools` | yes | Array of `{ tool_class, enabled?, settings?: { … }, operations?: [{ name, enabled?, auto_approve? }] }`. An empty array changes **nothing** — it is not a revoke-all. To strip the toolset, read the current tools and send each with `enabled: false`. |

#### Writing settings

`settings` writes a tool's own `#[ToolSetting]` values, and only keys the tool actually declares are accepted — an unknown key is refused, naming the valid ones.

```json
{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Tools\\SkillTool",
      "enabled": true,
      "settings": { "allowed_skills": ["time-arithmetic", "email"] }
    }
  ]
}
```

Two things to get right, both of which fail silently if you assume the opposite:

- **A settings write REPLACES the value at that key outright.** It does not merge and it does not append. If the agent already holds `["a"]` and you send `["b"]`, the result is `["b"]`, not `["a", "b"]`. Always send the whole list you want.
- **The write lands at the agent level.** Entries the agent was inheriting from a group or user level stop being inherited from here on, because the agent now has its own value. This is the intended consequence of a replace, not a bug.

A multi-select takes an array, but the element type depends on the setting: `allowed_skills` is `string[]` (names), while `allowed_target_agents` is `int[]` (agent ids) — send `[3, 4]`, not `["3", "4"]`. A skill name the current principal cannot see is refused outright, with the offending name — pick names from `get_available_tools` → `skills.visible`. A `type: 'password'` setting is refused outright and always will be: a tool call must not be able to write a credential, because the value would land in the call's own recorded arguments. Credentials stay operator-only, through the settings panel.

Operating the tool once you already have it — including granting yourself a skill — is the **agent-tool** skill. This section covers only the new-agent flow.

#### Delta syntax

Even for a single-operation change, **send the full tool object** (including `tool_class`) inside `tools[]`. There is no path-tool / op-id dotted-address syntax — every entry is the complete tool record. For example, to flip `TimeTool.now.auto_approve` from true to false:

```json
{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Tools\\TimeTool",
      "operations": [
        { "name": "now", "auto_approve": false }
      ]
    }
  ]
}
```

If the same entry is sent without `tool_class`, the resolver can't bind the operation to a tool and the call fails validation. On a tool entry, omit a field to keep its current value and send a value to override it. The one exception is an operation's own `enabled`: omit it and the operation is switched **on**, not left alone — see below.

Each tool entry:

- `tool_class` — FQCN string. **Get this from `get_available_tools`.** NOT `call_name` (v2 removed) and NOT `tool_name` (v2 removed).
- `enabled` — **tri-state, and the middle state is the one that bites.** `true` enables the tool on the target agent; `false` removes it; **omitting the key changes nothing.** It also accepts `"true"` / `"false"` / `0` / `1`, and a quoted value is read as the value it names rather than as truthy, so a revocation still revokes. So `{"tool_class": "X", "settings": {...}}` configures X *without* turning it on — which is what you want almost every time. The full statement, with the traps table, is in the **agent-tool** skill.
- `settings` — object of `{setting_key: value}` for the tool's own settings. Omit to leave them alone. See *Writing settings* above for the replace semantics.
- `operations` — array. Omit to inherit the tool's per-operation defaults. Each entry is `{ name, enabled?, auto_approve? }`:
  - `name` (required) — string from `get_available_tools.operations[].name`
  - `enabled` — **bool, default true. Unlike the tool's own `enabled`, this one is NOT tri-state.** Naming an operation in `operations[]` turns it on, so an entry carrying only `auto_approve` enables the operation too. Send `enabled: false` to switch one off. The two fields share a name and a grammar and behave differently, which makes this the easiest thing in the whole call to get wrong.
  - `auto_approve` — bool, default false. When true, the operation runs without per-call operator approval.

## Common mistakes

The slim `create_agent` + `configure_tools(agent_id?)` flow fixes these directly. Each error message carries a literal "send X instead" example.

| Symptom | Cause | Fix |
| --- | --- | --- |
| `do NOT wrap fields in an agent{} block` | Sent legacy `agent: { name, description, ... }` to `update_agent` (this is the right shape for `update_agent`, not `create_agent`) — or sent `agent: {…}` to `create_agent` | For `create_agent`: send the slim flat payload `{ name, description, ... }` at top level. For `update_agent`: keep the `agent: { … }` wrapper — that's the canonical shape. |
| `tools[]` is no longer accepted here | Sent `tools: [...]` inside `create_agent` payload | Create the agent first, then call `configure_tools(agent_id: N, tools: [...])` |
| `max_steps must be an integer in 1..100` | Sent a string or out-of-range int | Send `max_steps: 10` (integer, not string) |
| `allow_followup must be a boolean` | Sent the string `"true"` | Send `allow_followup: true` (real bool, not string) |
| `\`template_id\` is no longer an identifier` | Sent `template_id: "weather-agent"` to `read_agent`, `configure_tools`, `update_agent`, or `list_agents` | Use the numeric `agent_id` you got from `create_agent` (or `list_agents`) |
| `\`agent_id\` must be a positive integer` | Sent zero, a string, or omitted entirely + couldn't fall back | Use a numeric `agent_id`; if omitted is intended, the agent must exist as the calling agent |
| `configure_tools: operations[0][item]` | Sent inner arrays wrapped as `{item: [...]}` (an OpenAI-tools serialization quirk) | Send the array literally: `[{name: "now", enabled: true}]` — the tool auto-unwraps the `{item: …}` quirk defensively, but plain arrays are preferred |
| `configure_tools: tool entry #N must be an object` | Sent the tool entry as a string or array | Wrap each entry in `{...}` |
| `configure_tools: settings[0] 'X' is not a setting on <FQCN>` | Misspelled or invented a setting key | Use a key the tool declares; the refusal lists the valid ones |
| `configure_tools: settings[0] 'allowed_skills' must be an array of strings` | Sent a multi-select as a string, or as an array of objects | Send `["time-arithmetic"]`, not `"time-arithmetic"` or `[{name: "..."}]` |
| `configure_tools: settings[0] 'allowed_target_agents' must be an array of agent ids` | Sent an agent-resolved multi-select as strings | Send `[3, 4]` — this one is stored as `int[]`, unlike `allowed_skills` |
| `configure_tools: settings[0] 'X' is a credential` | Tried to write a `type: 'password'` setting | Operator-only, through the settings panel. This is not a bug to work around |
| `configure_tools: settings[0] 'allowed_skills' names 'X', which is not available to this principal` | Named a skill the current principal cannot see | Read `get_available_tools` → `skills.visible` and pick from it |
| `configure_tools: tool entry #N 'enabled' must be true or false` | Sent `"yes"` / `2` / `null` — nothing that names a flag | Send `true` / `false`, `"true"` / `"false"`, or `0` / `1`. Quoted scalars are read as the value they name, so `"false"` really does disable |
| `create_agent: \`max_steps\` must be a whole number in 1..100` | Sent `"999"`, `12.5`, or `"lots"` | A quoted number is read as the number, then range-checked — so `"25"` works and `"999"` does not. Only whole numbers in 1..100 |
| `configure_tools: operations[N][M] names 'X', which is not an operation on <FQCN>` | Misspelled or invented an operation name | The refusal lists the valid names. Get them from `get_available_tools` → that tool's `operations[]` |

After three identical validation errors, **stop and ask the operator** — re-reading this skill won't help if the schema is genuinely unknown to you.

`enabled` is tri-state: `true` enables, `false` removes the tool, and **omitting the key
leaves the tool as it is**. An entry that carries only `settings` or `operations` never
grants the tool as a side effect — which is what you want for the minimal toolset. And
`tools: []` is a no-op, not a revoke-all.

## `update_agent` workflow (in-place edits to **any** agent)

Use this for editing any agent you own, not for creating a new one.

Accepts an `agent_id` (numeric pk) and a partial `agent` object. Omitting `agent_id` patches the calling agent (back-compat path). The result is the canonical manifest (Markdown + `result_data`).

```json
{
  "action": "update_agent",
  "agent_id": 6,
  "agent": {
    "description": "Updated description.",
    "system_prompt": "Updated persona…"
  }
}
```

Allowed keys inside the `agent` object — these are the only ones that are yours to
write:

- `name`, `description`, `system_prompt`
- `max_steps`, `allow_followup`, `retry_after_minutes`, `max_retries`
- `is_pinned`, `is_archived`

**Silent drops:**

- `notes` — stripped by the tool before the DB write. Use `write_notes` (append/prepend) or `write_notes_overwrite` (destructive, requires approval) instead.
- `is_favorite` — not part of this patch. The favourite is per-user; there is no such thing as favouriting an agent for everybody.
- Any other key — dropped before the DB write, against the service's allowlist.

**Not writable through this tool, and refused rather than dropped:** `llm_driver_config_id`,
`speech_driver_config_id` and `voice_message_retention_count` all sit in the service's
allowlist but are not part of this surface. The first two decide which model and credential
set the agent runs on, and you have no way to read the valid ids, so sending one would be
guessing at a consequential value. An operator sets those. The refusal names the key and
**the whole patch is refused** — so if you want a different model on an agent, say so
rather than trying to write the id.

**Types are checked here too, unlike a bare database write.** The patch is coerced and
range-checked before anything is written, so `"is_archived": "false"` really does
unarchive, `"max_steps": "25"` is accepted, and `"max_steps": "999"` is refused. One
bad field means the whole patch is refused and nothing is written.

To verify a write took effect, call `read_agent(agent_id: <id>)` (or just `read_agent` for the calling agent) and look at the manifest. If a field you sent is not in the returned payload, it was dropped — don't assume success.

## `read_agent` (read any agent by id; or read self)

- `read_agent` with no `agent_id` reads the calling agent (same semantics as the legacy `read_agent_configuration`, which is a soft-redirect).
- `read_agent(agent_id: N)` reads any agent whose principal you control — your own, or a group you own or administer. An id outside that scope returns "not found or not owned".
- Result: canonical manifest (Markdown + result_data).

## Approval

- `create_agent` — operator approval per call. Briefly state what the new agent will do before submitting, so the operator can sign off without re-reading the payload.
- `configure_tools` — operator approval per call. Brief summary of the toolset change.
- `update_agent` — operator approval per call. Brief summary of the patch (which `agent_id`, which keys).
- `list_agents` — no approval.
- `read_agent` — no approval.

## Operator-upload shape (file upload endpoint only)

For completeness: an operator uploading an agent template via the dashboard file-upload endpoint (`POST /api/v1/agent-templates/import`) sends the **full** skeleton-plus-toolset in one shot, mirroring `agent-template.schema.json`. The LLM-facing flow deliberately does not see this shape — that's why `create_agent` rejects nested objects with the "do NOT wrap fields in an agent{} block" error. The two surfaces (LLM-facing slim vs. operator-upload full) are explicit by design.

The full schema lives in `agent-template.schema.json` and is exercised by `tests/Unit/AgentTemplates/AgentTemplateImporterTest.php`. Operator-upload templates are also stored at `skills/agent-creation/example.json` for round-trip tests.

Agent creation

When the user asks for a new agent, follow this protocol end-to-end. Do NOT try to invent the call sequence from memory — read this skill first, then drive the flow through get_available_tools and the three agent-tool calls.

When to use this skill

Trigger on any of:

  • "Create me an agent that …", "Set up a weather agent …", "Scaffold a translator …"
  • "I need a sub-agent for X" or "Spin up an assistant that only uses Y"
  • "Configure a new assistant like this one but with different tools"
  • Any time the operator's intent is "make me a new agent" or "add a new assistant".

Do NOT use this skill for editing the current agent's notes (use write_notes) or for changing the current agent's name/system prompt (use update_agent directly — it's the right tool for in-place edits).

List your existing agents before creating a new one

Before driving the create-agent flow, run agent(action: "list_agents") if there's any chance the user already has agents you could reuse or rename. The operator's most common reason to ask "create me a translator agent" is that they lost the agent_id after a turn boundary — create_agent returns a fresh pk each time it runs, so the cheap recovery is:

{ "action": "list_agents" }

Returns a slim list of {agent_id, name, description, is_archived} rows (#4 Custom Agent — does X, with (archived) appended for an archived agent) visible to the current user — their own agents, plus the agents of every group they belong to — ordered newest-first. Empty list means "go ahead and create". If the agent you want already exists at row N, skip step 1 — call update_agent(agent_id: N, agent: {…}) or configure_tools(agent_id: N, tools: […]) directly. update_agent accepts an optional agent_id; omitting it edits the calling agent.

A row being listed is not a guarantee you may act on it: a group agent the user merely belongs to is listed, but update_agent / configure_tools on it are refused unless they own or administer the group.

Mandatory pre-flight

Before driving the flow:

  1. Read the current agent's manifest via agent(action: "read_agent") (no agent_id — reads the calling agent) so you know what an existing agent looks like in this codebase. The result has two JSON blocks in result_content: a Base config block and a Tool config block (see Canonical agent manifest below).
  2. List available tools via agent(action: "get_available_tools"). The version-2 payload tells you, per tool:
    • tool_class — the FQCN. Use this for configure_tools. NOT call_name and NOT tool_name (both removed in v2).
    • plugin_slug — null for core tools, the slug (e.g. "weather") for plugin tools. Informational: it tells you where a plugin tool came from, nothing more.
    • enabled — current state on the calling agent (informational; new agents start fresh).
    • ready_to_enable — whether configure will succeed without operator setup.
    • missing_required — list of setting keys that block enablement (e.g. ["api_key"]).
    • operations[] — each {name, description, enabled, requires_approval}. Convert to [{name: "..."}] objects before placing inside configure_tools's tools[].operations.
    • skills — the skill discovery block: allowed is the calling agent's allowed_skills list right now, and visible[] is every skill the current principal can see, as {name, description, active}. Read visible to pick names; a name you did not read there is refused by configure_tools, and a name you read there is one the principal can be granted.

Minimal-toolset protocol

Prefer fewer tools. Each one is an attack surface and a cost. Ask the user only when the answer materially changes the design:

  • If the user said exactly which tools to enable, do that.
  • If the request is open-ended ("a weather agent"), decide on the smallest reasonable toolset (e.g. weather plugin's API tool + the core Time tool).
  • If two intents are ambiguous, ask one short clarifying question — never invent.

Prefer core tools (plugin_slug: null) over plugin tools when both would suffice. Plugins add install/runtime cost and operator approval.

Canonical agent manifest (the wire shape)

Every agent read/write operation speaks this shape. result_content is the Markdown wrapper; result_data is the same structure as structured JSON.

## Agent #6 — Weather Agent

1 of 18 tools enabled. 0 missing required config.

### Base config

```json
{
  "agent_id": 6,
  "name": "Weather Agent",
  "description": "Answers weather questions…",
  "system_prompt": "You are the Weather Agent…",
  "notes": null,
  "template_id": null,
  "version": null,
  "max_steps": 10,
  "allow_followup": true,
  "retry_after_minutes": 0,
  "max_retries": 0,
  "is_pinned": false,
  "is_archived": false
}
```

### Tool config

```json
{
  "tools": [
    {
      "tool_class": "Spora\\Plugins\\Weather\\Tools\\WeatherApiTool",
      "icon": "sun",
      "enabled": true,
      "operations": [
        { "name": "current",  "enabled": true, "requires_approval": false },
        { "name": "forecast", "enabled": true, "requires_approval": false },
        { "name": "search",   "enabled": true, "requires_approval": false },
        { "name": "astronomy","enabled": true, "requires_approval": false }
      ]
    }
  ],
  "missing_required": [],
  "warnings": []
}
```

Key invariants:

  • agent_id is the numeric primary key. template_id is no longer an identifier — templates are creation labels, and multiple agents can share one. Always carry forward the numeric agent_id from create_agent (or list_agents).
  • There is no is_favorite key. Favouriting is per-user and lives outside this payload (POST/DELETE /api/v1/agents/{id}/favorite), so nothing in the manifest reflects it — do not wait for it to appear.
  • Per-tool entries in tools[] carry only tool_class, icon, enabled, and operations[] — slim by design, since read_agent / update_agent / configure_tools responses run on every LLM turn. Browsing-style enrichment (display_name, description) stays on get_available_tools (operator-facing). Pin the slim shape in your reply so an upstream change can't silently bloat the response.
  • tools[] lists every registered tool (with enabled: true|false) so you can see at a glance what's active and what isn't. Per-tool operations[] carries the effective enabled / requires_approval state after per-agent overrides fold in.
  • The Markdown preamble adds a Disabled: ClassA, ClassB, … line under the status line when at least one tool is disabled. The line is omitted entirely on the all-enabled case so the all-green path stays clean.
  • overrides[] carries the per-operation audit trail — {tool_class, operation, enabled, default_requires_approval} rows for every op where the operator actively overrode the tool's default. enabled and default_requires_approval are nullable: null means the operator kept the tool's default for that field, true|false means the operator explicitly set it. This is what proves a configure_tools patch with auto_approve: true actually persisted — the tools[i].operations[j].requires_approval effective value would show false either way, but overrides[] is the only place that records "the operator's call landed on this op". It says nothing about which tools are enabled — that is tools[].enabled. An empty overrides[] after a configure_tools that only touched enabled is correct, not a lost write.
  • missing_required: ["<tool_class>:<setting_key>", ...] lists configuration that blocks enablement. [] means no blockers.
  • warnings: [] is empty on success. Operator-upload templates may populate it with TOOL_PLUGIN_MISSING notes.

Two-phase flow (LLM-facing)

The LLM does NOT create an agent and configure its tools in one call. Use:

  1. create_agent — slim skeletal record. Capture the agent_id from the response.
  2. configure_tools — apply the toolset. Pass agent_id: <the new id from step 1> so the right agent gets configured.
  3. read_agent(agent_id: <the new id>) — verify the toolset is exactly what you wanted.

Call them in order. The result_content of each ends with the canonical manifest (via the two-JSON-block Markdown wrapper) so you can confirm what landed without a follow-up read.

Sub-agent isolation

AgentTool ships with inconsistent defaults by design: three operations are on (read_notes, write_notes, list_agents), the other six are off, and four of those are approval-gated as well. The full per-operation table lives in the agent-tool skill and is not repeated here — two copies of that table is two chances to read a stale row.

What that means for this flow: get_available_tools, create_agent, configure_tools, read_agent and update_agent are all off by default. An operator who enabled the agent tool without enabling those five gave you a tool that refuses every call in this flow — starting with the pre-flight step below — with "operation not available". That is their setting, not a bug; name the operations you need rather than looking for another route in.

If you need to fully lock the sub-agent out of agent — i.e. exclude the tool entirely from its manifest — disable the agent tool at the agent-level in the dashboard, not per-operation. Per-op enabled: false keeps the entries visible in the manifest so you can audit the intent (the operator can still see "this op is disabled on purpose"), while a fully-disabled tool entry is dropped from tools[] entirely.

The Markdown preamble of every read_agent / configure_tools response shows the disabled FQCNs in a single Disabled: ClassA, ClassB, … line so the operator can scan the perimeter at a glance without parsing the trailing tools[] block.

Step 1 — create_agent

{
  "action": "create_agent",
  "payload": {
    "name": "Weather Agent",
    "description": "Answers weather questions: current conditions, forecasts, location search, and astronomy (sunrise/sunset, moon phase) worldwide.",
    "system_prompt": "You are the 'Weather Agent'. Use the Weather API tool to answer questions about weather, forecasts, and astronomy (sunrise/sunset, moon phase). Reply in the user's language. If a location is ambiguous, ask briefly or use the location-search operation. Always state the timezone when the user asks for a time.",
    "max_steps": 10,
    "allow_followup": true
  }
}

Result content (Markdown wrapper — result_data carries the same shape as structured JSON):

Created agent #6 ('Weather Agent'). Configure tools next with
`configure_tools(agent_id: 6, tools: [...])` and verify with
`read_agent(agent_id: 6)`.

## Agent #6 — Weather Agent

0 of 18 tools enabled. 0 missing required config.

### Base config
{ … base-config JSON … }
### Tool config
{ … tool-config JSON … }

Capture the agent_id from result_data.agent_id (or the Markdown preamble) for the next two steps.

Step 2 — configure_tools(agent_id: <new id>)

configure_tools accepts an optional agent_id. Always pass it in the create-then-configure flow so the right agent gets configured; omitting agent_id falls back to the calling agent (in-place edit semantics, useful but rarely what you want after create_agent):

{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Plugins\\Weather\\Tools\\WeatherApiTool",
      "enabled": true,
      "operations": [
        { "name": "current",  "enabled": true },
        { "name": "forecast", "enabled": true },
        { "name": "search",   "enabled": true },
        { "name": "astronomy","enabled": true }
      ]
    },
    {
      "tool_class": "Spora\\Tools\\TimeTool",
      "enabled": true,
      "operations": [
        { "name": "now",    "enabled": true, "auto_approve": true },
        { "name": "format", "enabled": true, "auto_approve": true }
      ]
    }
  ]
}

configure_tools returns the canonical manifest of the targeted agent (same shape as read_agent). Use tools[].operations[] to pick which operations get enabled, and auto_approve: true to skip per-call operator approval.

Step 3 — read_agent(agent_id: <new id>)

{
  "action": "read_agent",
  "agent_id": 6
}

Returns the canonical manifest for agent #6. The Markdown wrapper in result_content lets you scan the pre-flight summary at a glance (2 of 18 tools enabled. 0 missing required config.) and inspect the per-tool/per-op state in the trailing JSON blocks. If a tool is missing or an operation is wrong, call configure_tools(agent_id: 6, tools: [...]) again with the delta — enabled: false on a tool entry removes it; enabled: true re-adds it.

Slim create_agent payload reference

The LLM-facing create_agent accepts only a slim subset of the agent-template schema. The full schema (with id, version, nested agent{}, nested tools[]) is reserved for the operator-upload endpoint at POST /api/v1/agent-templates/import.

Key Required Type Description
name yes string (1..200 chars) Top-level agent name. NOT inside any wrapper.
description no string (≤2000 chars) One-line description shown on the agent card.
system_prompt no string The agent's persona. Edit later via update_agent.
max_steps no int (1..100, default 10) Max LLM tool-call steps per task.
allow_followup no bool, default true Whether followup tasks are allowed.
retry_after_minutes no int, default 0 Cooldown between auto-retries.
max_retries no int, default 0 Max auto-retries per task.

additionalProperties: false — anything else (including the legacy id, version, agent{}, tools[], template_id) is rejected with a literal "send X instead" example. Plugin dependencies are not part of this payload at all: a plugin's tools are named by FQCN in configure_tools after the agent row exists, and installation is out-of-band (dashboard or spora plugin install).

configure_tools shape (LLM-facing)

configure_tools(agent_id?, tools: [...]):

Field Required Description
agent_id no Numeric pk returned by create_agent. Omit to operate on the calling agent. Cross-user agent ids return "not found".
tools yes Array of { tool_class, enabled?, settings?: { … }, operations?: [{ name, enabled?, auto_approve? }] }. An empty array changes nothing — it is not a revoke-all. To strip the toolset, read the current tools and send each with enabled: false.

Writing settings

settings writes a tool's own #[ToolSetting] values, and only keys the tool actually declares are accepted — an unknown key is refused, naming the valid ones.

{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Tools\\SkillTool",
      "enabled": true,
      "settings": { "allowed_skills": ["time-arithmetic", "email"] }
    }
  ]
}

Two things to get right, both of which fail silently if you assume the opposite:

  • A settings write REPLACES the value at that key outright. It does not merge and it does not append. If the agent already holds ["a"] and you send ["b"], the result is ["b"], not ["a", "b"]. Always send the whole list you want.
  • The write lands at the agent level. Entries the agent was inheriting from a group or user level stop being inherited from here on, because the agent now has its own value. This is the intended consequence of a replace, not a bug.

A multi-select takes an array, but the element type depends on the setting: allowed_skills is string[] (names), while allowed_target_agents is int[] (agent ids) — send [3, 4], not ["3", "4"]. A skill name the current principal cannot see is refused outright, with the offending name — pick names from get_available_tools → skills.visible. A type: 'password' setting is refused outright and always will be: a tool call must not be able to write a credential, because the value would land in the call's own recorded arguments. Credentials stay operator-only, through the settings panel.

Operating the tool once you already have it — including granting yourself a skill — is the agent-tool skill. This section covers only the new-agent flow.

Delta syntax

Even for a single-operation change, send the full tool object (including tool_class) inside tools[]. There is no path-tool / op-id dotted-address syntax — every entry is the complete tool record. For example, to flip TimeTool.now.auto_approve from true to false:

{
  "action": "configure_tools",
  "agent_id": 6,
  "tools": [
    {
      "tool_class": "Spora\\Tools\\TimeTool",
      "operations": [
        { "name": "now", "auto_approve": false }
      ]
    }
  ]
}

If the same entry is sent without tool_class, the resolver can't bind the operation to a tool and the call fails validation. On a tool entry, omit a field to keep its current value and send a value to override it. The one exception is an operation's own enabled: omit it and the operation is switched on, not left alone — see below.

Each tool entry:

  • tool_class — FQCN string. Get this from get_available_tools. NOT call_name (v2 removed) and NOT tool_name (v2 removed).
  • enabled — tri-state, and the middle state is the one that bites. true enables the tool on the target agent; false removes it; omitting the key changes nothing. It also accepts "true" / "false" / 0 / 1, and a quoted value is read as the value it names rather than as truthy, so a revocation still revokes. So {"tool_class": "X", "settings": {...}} configures X without turning it on — which is what you want almost every time. The full statement, with the traps table, is in the agent-tool skill.
  • settings — object of {setting_key: value} for the tool's own settings. Omit to leave them alone. See Writing settings above for the replace semantics.
  • operations — array. Omit to inherit the tool's per-operation defaults. Each entry is { name, enabled?, auto_approve? }:
    • name (required) — string from get_available_tools.operations[].name
    • enabled — bool, default true. Unlike the tool's own enabled, this one is NOT tri-state. Naming an operation in operations[] turns it on, so an entry carrying only auto_approve enables the operation too. Send enabled: false to switch one off. The two fields share a name and a grammar and behave differently, which makes this the easiest thing in the whole call to get wrong.
    • auto_approve — bool, default false. When true, the operation runs without per-call operator approval.

Common mistakes

The slim create_agent + configure_tools(agent_id?) flow fixes these directly. Each error message carries a literal "send X instead" example.

Symptom Cause Fix
do NOT wrap fields in an agent{} block Sent legacy agent: { name, description, ... } to update_agent (this is the right shape for update_agent, not create_agent) — or sent agent: {…} to create_agent For create_agent: send the slim flat payload { name, description, ... } at top level. For update_agent: keep the agent: { … } wrapper — that's the canonical shape.
tools[] is no longer accepted here Sent tools: [...] inside create_agent payload Create the agent first, then call configure_tools(agent_id: N, tools: [...])
max_steps must be an integer in 1..100 Sent a string or out-of-range int Send max_steps: 10 (integer, not string)
allow_followup must be a boolean Sent the string "true" Send allow_followup: true (real bool, not string)
\template_id` is no longer an identifier` Sent template_id: "weather-agent" to read_agent, configure_tools, update_agent, or list_agents Use the numeric agent_id you got from create_agent (or list_agents)
\agent_id` must be a positive integer` Sent zero, a string, or omitted entirely + couldn't fall back Use a numeric agent_id; if omitted is intended, the agent must exist as the calling agent
configure_tools: operations[0][item] Sent inner arrays wrapped as {item: [...]} (an OpenAI-tools serialization quirk) Send the array literally: [{name: "now", enabled: true}] — the tool auto-unwraps the {item: …} quirk defensively, but plain arrays are preferred
configure_tools: tool entry #N must be an object Sent the tool entry as a string or array Wrap each entry in {...}
configure_tools: settings[0] 'X' is not a setting on <FQCN> Misspelled or invented a setting key Use a key the tool declares; the refusal lists the valid ones
configure_tools: settings[0] 'allowed_skills' must be an array of strings Sent a multi-select as a string, or as an array of objects Send ["time-arithmetic"], not "time-arithmetic" or [{name: "..."}]
configure_tools: settings[0] 'allowed_target_agents' must be an array of agent ids Sent an agent-resolved multi-select as strings Send [3, 4] — this one is stored as int[], unlike allowed_skills
configure_tools: settings[0] 'X' is a credential Tried to write a type: 'password' setting Operator-only, through the settings panel. This is not a bug to work around
configure_tools: settings[0] 'allowed_skills' names 'X', which is not available to this principal Named a skill the current principal cannot see Read get_available_tools → skills.visible and pick from it
configure_tools: tool entry #N 'enabled' must be true or false Sent "yes" / 2 / null — nothing that names a flag Send true / false, "true" / "false", or 0 / 1. Quoted scalars are read as the value they name, so "false" really does disable
create_agent: \max_steps` must be a whole number in 1..100` Sent "999", 12.5, or "lots" A quoted number is read as the number, then range-checked — so "25" works and "999" does not. Only whole numbers in 1..100
configure_tools: operations[N][M] names 'X', which is not an operation on <FQCN> Misspelled or invented an operation name The refusal lists the valid names. Get them from get_available_tools → that tool's operations[]

After three identical validation errors, stop and ask the operator — re-reading this skill won't help if the schema is genuinely unknown to you.

enabled is tri-state: true enables, false removes the tool, and omitting the key leaves the tool as it is. An entry that carries only settings or operations never grants the tool as a side effect — which is what you want for the minimal toolset. And tools: [] is a no-op, not a revoke-all.

update_agent workflow (in-place edits to any agent)

Use this for editing any agent you own, not for creating a new one.

Accepts an agent_id (numeric pk) and a partial agent object. Omitting agent_id patches the calling agent (back-compat path). The result is the canonical manifest (Markdown + result_data).

{
  "action": "update_agent",
  "agent_id": 6,
  "agent": {
    "description": "Updated description.",
    "system_prompt": "Updated persona…"
  }
}

Allowed keys inside the agent object — these are the only ones that are yours to write:

  • name, description, system_prompt
  • max_steps, allow_followup, retry_after_minutes, max_retries
  • is_pinned, is_archived

Silent drops:

  • notes — stripped by the tool before the DB write. Use write_notes (append/prepend) or write_notes_overwrite (destructive, requires approval) instead.
  • is_favorite — not part of this patch. The favourite is per-user; there is no such thing as favouriting an agent for everybody.
  • Any other key — dropped before the DB write, against the service's allowlist.

Not writable through this tool, and refused rather than dropped: llm_driver_config_id, speech_driver_config_id and voice_message_retention_count all sit in the service's allowlist but are not part of this surface. The first two decide which model and credential set the agent runs on, and you have no way to read the valid ids, so sending one would be guessing at a consequential value. An operator sets those. The refusal names the key and the whole patch is refused — so if you want a different model on an agent, say so rather than trying to write the id.

Types are checked here too, unlike a bare database write. The patch is coerced and range-checked before anything is written, so "is_archived": "false" really does unarchive, "max_steps": "25" is accepted, and "max_steps": "999" is refused. One bad field means the whole patch is refused and nothing is written.

To verify a write took effect, call read_agent(agent_id: <id>) (or just read_agent for the calling agent) and look at the manifest. If a field you sent is not in the returned payload, it was dropped — don't assume success.

read_agent (read any agent by id; or read self)

  • read_agent with no agent_id reads the calling agent (same semantics as the legacy read_agent_configuration, which is a soft-redirect).
  • read_agent(agent_id: N) reads any agent whose principal you control — your own, or a group you own or administer. An id outside that scope returns "not found or not owned".
  • Result: canonical manifest (Markdown + result_data).

Approval

  • create_agent — operator approval per call. Briefly state what the new agent will do before submitting, so the operator can sign off without re-reading the payload.
  • configure_tools — operator approval per call. Brief summary of the toolset change.
  • update_agent — operator approval per call. Brief summary of the patch (which agent_id, which keys).
  • list_agents — no approval.
  • read_agent — no approval.

Operator-upload shape (file upload endpoint only)

For completeness: an operator uploading an agent template via the dashboard file-upload endpoint (POST /api/v1/agent-templates/import) sends the full skeleton-plus-toolset in one shot, mirroring agent-template.schema.json. The LLM-facing flow deliberately does not see this shape — that's why create_agent rejects nested objects with the "do NOT wrap fields in an agent{} block" error. The two surfaces (LLM-facing slim vs. operator-upload full) are explicit by design.

The full schema lives in agent-template.schema.json and is exercised by tests/Unit/AgentTemplates/AgentTemplateImporterTest.php. Operator-upload templates are also stored at skills/agent-creation/example.json for round-trip tests.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums