# Plan: Customizable A2A Server Agent Card

## Problem

When running `swival --serve`, the Agent Card served at `/.well-known/agent-card.json` has a hardcoded name, a generic description, and an empty skills list. Clients that discover this agent have no meaningful way to know what it's good at or when to route work to it. The LLM on the client side only sees "A coding agent powered by swival" which is too vague for multi-agent setups.

## Goal

Let users customize the agent card's name, description, and skills via CLI flags and config, so that client agents can make informed routing decisions.

## Design

### 1. CLI flags (agent.py)

Add three new `--serve-*` arguments next to the existing ones (~line 2053):

| Flag | Type | Default | Description |
|------|------|---------|-------------|
| `--serve-name` | `str` | `_UNSET` | Custom agent name (falls back to `swival (provider/model)`) |
| `--serve-description` | `str` | `_UNSET` | Custom agent description (falls back to the current generic string) |
| `--serve-skills` | `str` | `None` | Path to a TOML file defining skills |

Use `_UNSET` for name/description so `apply_config_to_args` can fill them from config. `--serve-skills` is a file path and stays `None`-defaulted (file loading happens separately).

### 2. Config loading (config.py)

**Not a nested `[a2a_serve]` table.** Use flat keys to match the existing config pattern and avoid nested-table merge headaches.

Add to `CONFIG_KEYS` (~line 28):

```python
"serve_name": str,
"serve_description": str,
```

Add to `_ARGPARSE_DEFAULTS` (~line 88):

```python
"serve_name": None,
"serve_description": None,
```

These flow through `apply_config_to_args()` like any other flat key: config key `serve_name` maps to argparse dest `serve_name` (no rename needed). CLI `--serve-name` uses `_UNSET` default, so config fills it when CLI doesn't set it. Project config wins over global via the existing `{**global_config, **project_config}` shallow merge.

**Skills in config** use the same pop-before-validate pattern as `mcp_servers` and `a2a_servers`. In `_load_single()`, pop `serve_skills` before validation, validate it separately, and re-attach:

```python
serve_skills = config.pop("serve_skills", None)
# ... existing validation ...
if serve_skills is not None:
    if not isinstance(serve_skills, list):
        raise ConfigError(f"{label}: 'serve_skills' must be an array of tables")
    _validate_serve_skills(serve_skills, label)
    known["serve_skills"] = serve_skills
```

**Config merge for `serve_skills`**: project replaces global wholesale (no per-skill merging). This is intentional — skills define the full identity of a served agent, and partial merges would be confusing.

In `load_config()`, after the shallow merge (~line 510), handle `serve_skills` similarly to `a2a_servers`:

```python
global_serve_skills = global_config.pop("serve_skills", None)
project_serve_skills = project_config.pop("serve_skills", None)
# ... existing shallow merge ...
serve_skills = project_serve_skills if project_serve_skills is not None else global_serve_skills
if serve_skills is not None:
    merged["serve_skills"] = serve_skills
```

In `main()`, stash before `apply_config_to_args` (next to the existing stash lines ~2168):

```python
args._serve_skills_config = file_config.pop("serve_skills", None)
```

**`config_to_session_kwargs()` (~line 663)**: Add `serve_name`, `serve_description`, and `serve_skills` to `_DROP_KEYS`. These are A2A server concerns, not `Session` constructor kwargs. Without this, `config_to_session_kwargs()` would pass them through as unknown kwargs and `Session.__init__` would raise `TypeError`.

```python
_DROP_KEYS = {
    "color",
    "reviewer",
    # ... existing entries ...
    "serve_name",
    "serve_description",
    "serve_skills",
}
```

Example `swival.toml`:

```toml
serve_name = "Code Review Bot"
serve_description = "Reviews Python code for bugs, security issues, and style"

[[serve_skills]]
id = "review"
name = "Code Review"
description = "Analyze code for correctness, security, and style"
examples = ["Review this pull request", "Check this function for bugs"]

[[serve_skills]]
id = "explain"
name = "Code Explanation"
description = "Explain how a piece of code works"
examples = ["What does this function do?"]
```

### 3. Skill validation

`_validate_serve_skills(skills, label)` in config.py:

- Each skill must be a dict
- `id` is **required** — this prevents collisions after sanitization since the user explicitly controls the ID
- `id` must be a **stable identifier**: `sanitize_skill_id(id) == id` must hold. This means the ID must already be in sanitized form (`[a-zA-Z0-9_-]+`, no double underscores, no leading/trailing `_-`). Reject IDs that would silently transform (e.g. `-review-` becoming `review`, or `my  skill` becoming `my_skill`). The error message should show both the raw and sanitized forms so the user knows what to fix
- `id` must be unique across all skills in the list
- `name`, `description`, `examples` are optional
- `examples` must be a list of strings if present
- Unknown keys produce a warning (forward-compat)

The `--serve-skills` TOML file uses the same `[[skills]]` format:

```toml
[[skills]]
id = "review"
name = "Code Review"
description = "Analyze code for correctness, security, and style"
examples = ["Review this pull request"]
```

**`_load_serve_skills_file(path)` error handling** (follows the `load_a2a_config()` pattern at config.py:448):

- File not found or unreadable: raise `ConfigError` with the path and OS error
- Invalid TOML: raise `ConfigError` with parse error details
- Missing top-level `skills` key: raise `ConfigError` — the key is required. If you want to explicitly serve no skills, use `skills = []`
- `skills` is not a list: raise `ConfigError`
- Non-empty list: validate each entry with `_validate_serve_skills()`

An explicit empty list (`skills = []`) is the way to say "this agent has no skills" (preserving the current default behavior). A file that simply omits the key is treated as an error to catch typos and misconfiguration early.

### 4. build_agent_card changes (a2a_server.py)

Update signature:

```python
def build_agent_card(
    session_kwargs: dict,
    host: str,
    port: int,
    *,
    auth_token: str | None = None,
    name: str | None = None,
    description: str | None = None,
    skills: list[dict] | None = None,
) -> dict:
```

- `name` overrides the auto-generated `swival (provider/model)` name
- `description` overrides the generic description string
- `skills` is a list of dicts with keys `id`, `name`, `description`, `examples`. Each gets serialized into the card's `skills` array following the A2A AgentSkill wire format (camelCase keys)

### 5. A2aServer.__init__ changes (a2a_server.py)

Add `name`, `description`, `skills` parameters and pass them through to `build_agent_card()`.

### 6. Wiring in the serve branch (agent.py ~line 2260)

The serve branch operates from `args`. After `apply_config_to_args()`, the values are:

1. `args.serve_name`: from CLI flag > config > `None` (auto-generate)
2. `args.serve_description`: from CLI flag > config > `None` (auto-generate)
3. Skills resolution (explicit, not through `apply_config_to_args`):
   - If `--serve-skills` is set: load and validate the TOML file
   - Else: use `args._serve_skills_config` (stashed from config)
   - Else: `None` (empty skills)

```python
# Resolve serve skills
serve_skills = None
if args.serve_skills:
    serve_skills = _load_serve_skills_file(args.serve_skills)
else:
    serve_skills = getattr(args, "_serve_skills_config", None)

server = A2aServer(
    session_kwargs=session_kwargs,
    host=args.serve_host,
    port=args.serve_port,
    auth_token=args.serve_auth_token,
    name=args.serve_name,
    description=args.serve_description,
    skills=serve_skills,
)
```

### 7. Documentation and template updates

**docs.md/usage.md** (~line 178, A2A Server Flags section): Add `--serve-name`, `--serve-description`, `--serve-skills` to the flag table.

**docs.md/a2a.md** (~line 157, Serving As An A2A Agent section): Add subsections for customizing the agent card name/description and defining skills. Include config examples.

**config.py `generate_config()`** (~line 705): Add commented-out `serve_name` and `serve_description` as flat key=value lines. For `serve_skills`, add only a **single-line inline example** rather than `[[serve_skills]]` array-of-tables syntax:

```python
'# serve_name = "My Agent"',
'# serve_description = "What this agent does"',
'# serve_skills = [{id = "ask", name = "Ask", description = "Send a question"}]',
```

Reason: the existing `test_generate_config_is_valid_toml` test (test_config.py:125) strips `# ` prefixes, collects lines containing `=`, and parses them as TOML. Commented `[[serve_skills]]` headers don't contain `=` so they'd be dropped, leaving orphaned `id = "..."` lines that break parsing. A single inline array-of-tables on one line avoids this. The real multi-skill `[[serve_skills]]` syntax is documented in docs.md/a2a.md and the plan examples above.

## Files to modify

| File | Changes |
|------|---------|
| `swival/agent.py` | Add `--serve-name`, `--serve-description`, `--serve-skills` args; stash `_serve_skills_config`; wire into `A2aServer()` |
| `swival/a2a_server.py` | Add params to `A2aServer.__init__` and `build_agent_card()` |
| `swival/config.py` | Add `serve_name`/`serve_description` to `CONFIG_KEYS`, `_ARGPARSE_DEFAULTS`, and `_DROP_KEYS` in `config_to_session_kwargs()`; add `serve_skills` pop/validate/stash in `_load_single()` and `load_config()`; add `_validate_serve_skills()`; update `generate_config()` template (inline format) |
| `docs.md/usage.md` | Add new serve flags to A2A Server Flags section |
| `docs.md/a2a.md` | Add agent card customization and skills documentation |
| `tests/test_a2a_server.py` | Test `build_agent_card()` with custom name/description/skills |
| `tests/test_config.py` | Test `serve_skills` loading, validation, merge semantics, and `_validate_serve_skills()` |

## What this does NOT include

- Runtime skill discovery from the session's tool list (possible future work)
- Skill-level input/output mode customization (defaults to `text/plain`)
- Per-skill merge between global and project config (project replaces global wholesale)

## Testing

- `build_agent_card()` with custom name, description, and skills produces correct wire format
- `build_agent_card()` with `None` values preserves current auto-generated defaults
- `_validate_serve_skills()` rejects missing `id`, duplicate IDs, invalid ID characters, non-list `examples`
- `_validate_serve_skills()` rejects IDs that mutate under `sanitize_skill_id()` (e.g. `-review-`, `my  skill`)
- Config loading: `serve_name`/`serve_description` flow through `apply_config_to_args()` correctly
- Config loading: `serve_skills` stashed and available in serve branch
- Config merge: project `serve_skills` replaces global entirely
- CLI precedence: `--serve-name` overrides config `serve_name`
- `--serve-skills` file: loaded, validated, used over config skills
- `_load_serve_skills_file()`: missing file raises `ConfigError`, invalid TOML raises `ConfigError`, missing `skills` key raises `ConfigError`, `skills = []` is accepted (explicitly no skills)
- `config_to_session_kwargs()` drops `serve_name`, `serve_description`, `serve_skills` (does not pass to Session)
- `test_generate_config_is_valid_toml` continues to pass with the new inline template lines
- End-to-end: served agent card contains custom skills visible to client's `_skill_to_tool()`
