turul-a2a-patterns
Reusable abstractions for A2A skill authoring — handler trait, registry, SKILL.md manifest parser, progress sink, terminal hook, JSON Schema validation helper.
Status: published on crates.io alongside the other
turul-a2a-*crates (see ADR-021). It depends onturul-a2a-protoandturul-a2a-typesonly — never on theturul-a2aserver runtime.
What it is
A foundational abstraction layer for writing A2A skills. It defines traits and types that adopter agents and the framework's server runtime both build on. It does not change the A2A wire contract; extension machinery that does change the wire lives elsewhere (see ADR-021 §1.2).
What it is not
- Not a server. Not a transport. Not an LLM client. Not a profile
extension. Not the
AgentSkillproto type (that'sturul-a2a-proto/turul-a2a-types— discovery metadata only). - Not a bucket for every useful idiom. Per ADR-021 §2.2, additions to the public surface require an ADR amendment.
Public surface
| Symbol | Purpose |
|---|---|
SkillHandler |
Async trait. Adopter impls write async fn run(&self, params, sink) -> Result<Value, SkillError>. #[async_trait]. |
SkillProgressSink + ProgressState + SinkError |
Trait the handler emits status updates and artifacts through during execution. ProgressState is #[non_exhaustive] and contains only non-terminal states (Working, InputRequired, AuthRequired) — terminal commits are framework-owned. SinkError::{Closed, Backend(String)} for best-effort failure semantics. #[async_trait]. |
SkillRegistry + InMemorySkillRegistry |
Maps AgentSkill.id → SkillHandler. In-memory impl is the default; alternative impls (e.g. dynamic discovery) are adopter-owned. |
SkillCard + ExecutionHints |
SKILL.md manifest type: three-section frontmatter (AgentSkill discovery fields / provider-neutral execution metadata / opaque providerConfig) plus markdown body. SkillCard::parse(text) is the only constructor (no Deserialize); it rejects unsupported keywords and uncompilable schemas. validate_input / validate_output use [validate_json]. to_agent_skill() projects to the proto AgentSkill. |
SkillDescriptor |
Introspection struct (id, card, params_schema). Single source of truth for the schema: for manifest-backed skills, derived from the manifest; for programmatic skills, supplied once at registration. Never adopter-fillable independently. |
SkillError |
Two variants only: InvalidRequest(String) (maps to A2A InvalidRequest) and Internal(String) (maps to A2A Internal). |
TerminalHook + SkillOutcome |
Post-execution observer fired by adopter dispatch code when SkillHandler::run returns. Best-effort (return type ()). SkillOutcome::{Success(&Value), Failure(&SkillError)} (#[non_exhaustive]). Minimal generic variant only — richer extractor-registry / isolation semantics remain deferred (ADR-021 §9 Q5). #[async_trait]. |
validate_json(schema, instance) |
Public free helper. JSON Schema 2020-12 validation; strict-reject of unsupported keywords. Returns first violation as a structured ValidationError. |
SKILL.md format
A portable, LLM-friendly skill manifest. The three-section frontmatter
is camelCase (#[serde(rename_all = "camelCase")], no snake_case
aliases). Body is an opaque template string consumed by prompt
rendering (or human-readable instructions, for non-LLM skills).
# Section 1: AgentSkill discovery fields (always required)
id: greet
name: Greet
description: Greet a named user in a chosen style.
tags:
examples:
inputModes:
outputModes:
securityRequirements: # scheme-name strings; ADR-021 §2.2 item 3
# Section 2: provider-neutral execution metadata (optional — LLM-backed)
inputSchema: # JSON Schema 2020-12
type: object
properties:
user:
style:
required:
outputSchema:
type: object
properties:
greeting:
required:
executionHints: # optional, only meaningful for LLM-backed
maxTokens: 128
temperature: 0.2
topP: 0.9
# Section 3: opaque provider_config block (optional — vendor-specific)
providerConfig:
vendor: ollama
model: "llama3.1"
endpoint: "/api/chat"
format: json
The patterns crate does not interpret providerConfig. Adopter
code reads it to drive the actual model call. For non-LLM skills
(Rust-native handlers, MCP-tool-backed, deterministic logic), omit
sections 2 and 3 entirely and use the manifest only for discovery
metadata + I/O schemas.
Trust model — security boundary
The parser treats SKILL.md as a trusted static manifest + static prompt template. Concretely it MUST NOT:
- Execute shell commands or evaluate code in any field.
- Resolve
!include-style directives or load arbitrary support files. - Interpret command-injection syntax or MCP tool references.
- Make outbound network calls during parsing.
Adopters that need richer manifest semantics extend the format under a successor ADR — not by side-channel features in the parser.
Contracts (load-bearing for stable tests)
- Template grammar: minimal
{{ name }}substitution; dotted paths ({{ user.id }}); no logic / conditionals / loops; literal{{escaped as\{{. - Variable resolution: missing variables produce a structured
RenderError. Never silent empty substitution. - JSON Schema dialect: JSON Schema 2020-12 (strict).
- Unsupported JSON Schema keywords: rejected at parse time, not silently ignored.
- Error reporting: structured (
location,reason) for both schema validation and template rendering.
Example use
use Arc;
use async_trait;
use ;
use ;
async
Working end-to-end examples live in
examples/skill-manifest-ollama-agent,
examples/agent-role-planner-router-agent,
examples/agent-role-critic-agent,
and examples/post-task-hook-agent.
Each shows the bridge-adapter pattern:
The named-field shape (event_sink:, not .0) is a CLAUDE.md /
AGENTS.md rule for showcase examples: the wrapped framework value
is part of the lesson, so the field name should make that obvious
at the call site. Orphan-rule rationale for needing a local wrapper
at all: SkillProgressSink lives in this crate, EventSink lives
in turul-a2a, and the example crate is a third crate — a direct
impl SkillProgressSink for EventSink from the example is illegal
Rust.
Module layout
src/
├── lib.rs # crate docs + pub use re-exports
├── error.rs # SkillError, SinkError, ManifestError, RenderError, ValidationError
├── sink.rs # SkillProgressSink, ProgressState
├── handler.rs # SkillHandler trait
├── registry.rs # SkillRegistry, InMemorySkillRegistry, SkillDescriptor
├── manifest.rs # SkillCard, ExecutionHints, SKILL.md parser + AgentSkill projection
├── template.rs # prompt rendering
├── schema.rs # validate_json, Draft-2020-12 strict-keyword check
└── hook.rs # TerminalHook, SkillOutcome
Testing
Not in scope
- Dispatcher (skill-invocation profile extension) — wire-affecting; belongs in a future profile-extension crate per ADR-021 §5. See ADR-022.
- LLM client — orthogonal concern (cadence/audience/deps differ). See ADR-023.
- Agent role abstractions (planner, router, coordinator, critic,
gateway/facade) — the patterns crate ships zero role types today.
The example agents under
examples/agent-role-*/are reference implementations, not framework types (ADR-021 §9 Q3). - Provider-specific code (Ollama / OpenAI / Anthropic adapters) — lives in example crates; never in this crate.
See also
- ADR-021 — this crate's design record (Accepted).
- ADR-022 — Proposed dispatcher profile extension.
- ADR-023 — Proposed LlmClient abstraction.
examples/interop-clients/CLIENT_MATRIX.md— cross-language interop evidence (Python / Go / Rust × 4 showcase agents).
License
MIT OR Apache-2.0, matching the rest of the turul-a2a workspace.