Write and check a structured capability profile for an agent: skills with levels, tools with access, stated limits, evidence for each claim, and a freshness date, so a routing, delegation, or selection decision can rest on checked claims. Use when an agent must declare what it can do, or when profiles must be compared before work is assigned.
./install.sh agent-capability-profilesname: agent_capability_profiles description: "Write and check a structured capability profile for an agent: skills with levels, tools with access, stated limits, evidence for each claim, and a freshness date, so a routing, delegation, or selection decision can rest on checked claims. Use when an agent must declare what it can do, or when profiles must be compared before work is assigned." metadata: { "includeInPrompt": true }
Build a capability profile: a single document that declares what an agent can do, at what level, with which tools and access, within which limits, and with evidence for each claim. A reviewer uses the profile to route a task, delegate work, or compare agents for a selection decision. A profile with claims and no evidence is advertising copy. This skill turns the declaration into a checked record with a date, a version, and a pass or fail result from a fixed check routine.
Scope:
python3 --version returned on the drafting machine. The script uses only the Python standard library (argparse, json, sys, and datetime), so no package version is quoted and no install step is needed. Every number in the Output Contract and in the expected output block comes from a real run of that script against its fixture.agent_id that stays the same across versions. Use an identifier that already exists for the agent in the working context. Do not invent a registry identifier, and do not change the agent_id to mark a new version.version as a string that rises with each published revision, for example "1", "2", "3". A claim change, an access change, or a limits change each require a new version. A spelling fix with no claim change can keep the version, and the notes should say that is what happened.declared_at to the date the claims were last checked, in YYYY-MM-DD form. This date moves only when claims are rechecked in step 10. Copying an old profile forward with a new date and no recheck is a false claim about freshness.name, level, and evidence. Keep names short, lower case, and hyphenated, for example web-search, data-cleaning, code-review. One name covers one skill. A combined entry such as research-and-writing hides which half is weak.basic: handles simple, well defined cases of the skill, with review of the result advised.proficient: handles routine cases without step by step direction, and handles common edge cases.advanced: handles hard and ambiguous cases, and can judge the quality of work in this skill.proficient at most. If evidence covers a single simple case, the level is basic.name, access, and evidence. Use the tool name as it appears in the working context, so a reviewer can match the profile line to the tool list for the run.none: the tool is named for completeness and the agent has no access to it. No evidence is required for a none claim.read: the agent can read, search, list, or fetch through the tool, and cannot change stored state through it.write: the agent can create or change stored state through the tool.execute: the agent can run code, commands, or actions through the tool.read, even if a higher access might be available. Access that has not been exercised is not evidence backed.write claim with a partial scope and no scope note will be read as full write access.limits is a list of short plain statements. Cover at least these areas when they apply: task types the agent will not take, domains or file types outside scope, size or volume caps, access the agent lacks that related tasks often need, and conditions that require a fresh check before work starts.basic with a narrow range, a tool with rate caps, or a language or format the agent handles poorly. A weak spot stated in limits protects the routing decision. A weak spot found during a run damages trust in every other claim in the profile.none, needs at least one evidence entry. Each evidence entry has two fields: kind and ref.link: a URL a reviewer can open to see the work or the record.artifact: an identifier for a produced file, report, or other output that a reviewer can retrieve.test: an identifier for a named check, suite, or task run with a recorded result.log: an identifier for a run record or transcript that shows the work.advanced level claim needs evidence from a hard case, not three easy ones. A write or execute access claim needs evidence that shows a change or a run, not a read only record.declared_at and a stated as-of date, not from memory. Age in days is the as-of date minus declared_at. The profile is stale when age is over the freshness limit, which the check script sets at 90 days.python3 profile_check.py profile.json --as-of 2026-10-15.write or execute access the task does not need raises the risk of the assignment, and the decision record should note the excess and any guard placed on it.agent_id and version used, the as-of date, the stale state, the match table, and the outcome. A decision recorded without a profile version cannot be audited later, because the profile will have changed.version, set declared_at to the recheck date, and run the check script again. Keep the prior version and its check output in the working record. The history shows whether claims are stable, rising, or repeatedly failing checks.Deliver the profile document plus the check output and a decision ready summary, with these sections, in this order:
agent_id, version, declared_at, the as-of date used for the check, age in days, and stale state (yes or no).skills, tools, and limits. Each skill shows name, level, and evidence. Each tool shows name, access, and evidence when access is above none.declared_at and every as-of date use YYYY-MM-DD form and refer to the date claims were checked or the date the check was run. A profile with a missing date, a malformed date, or a declared date after the as-of date fails the check.Run: python3 profile_check.py --make-fixture --as-of 2026-10-15 reproduces the worked example. Run it on a real profile with python3 profile_check.py profile.json --as-of 2026-10-15. If --as-of is omitted, the current date is used, and the result cannot be repeated on a later day.
Expected output for the fixture run:
text
agent_id: research-agent-07
version: 3
declared_at: 2026-10-01
age_days: 14
stale: no
skills: 4: code-review, data-cleaning, report-writing, web-search
levels: basic 1, proficient 2, advanced 1
skills_without_evidence: 1: report-writing
tools: 3: browser, payment-api, python
access: none 0, read 1, write 1, execute 1
tools_without_evidence: 1: payment-api
limits: 2
evidence_entries: 6
errors: 2
error: skill report-writing: no evidence
error: tool payment-api: no evidence
warnings: 0
verdict: FAIL
The fixture is built to trip the evidence rule twice: the report-writing skill carries an empty evidence list, and the payment-api tool claims write access with an empty evidence list. All other fixture claims carry evidence, the date math gives an age of 14 days for a declared date of 2026-10-01 checked as of 2026-10-15, and the profile is not stale under the 90 day limit.
profile_check.py (uses only the Python standard library). Save this code as profile_check.py:
python
#!/usr/bin/env python3
"""Check an agent capability profile for completeness and freshness.
Usage:
python3 profile_check.py profile.json --as-of 2026-10-15
python3 profile_check.py --make-fixture --as-of 2026-10-15
The profile is a JSON object with these top level fields:
agent_id (string), version (string), declared_at (YYYY-MM-DD),
skills (list), tools (list), limits (list).
Each skill entry needs name, level, and evidence.
Level must be one of: basic, proficient, advanced.
Each tool entry needs name, access, and evidence when access is not none.
Access must be one of: none, read, write, execute.
Each evidence entry needs kind and ref.
Kind must be one of: link, artifact, test, log.
Freshness: age is as_of minus declared_at in days.
A profile is stale when age is over 90 days.
Exit code is 0 when there are no errors, 1 when there is at least one error.
Warnings do not change the exit code.
"""
import argparse
import json
import sys
from datetime import date
LEVELS = {"basic", "proficient", "advanced"}
ACCESSES = {"none", "read", "write", "execute"}
KINDS = {"link", "artifact", "test", "log"}
STALE_DAYS = 90
FIXTURE = {
"agent_id": "research-agent-07",
"version": "3",
"declared_at": "2026-10-01",
"skills": [
{"name": "web-search", "level": "proficient",
"evidence": [{"kind": "log", "ref": "run-2026-09-28-search"}]},
{"name": "data-cleaning", "level": "advanced",
"evidence": [{"kind": "artifact", "ref": "clean-report-v2"},
{"kind": "test", "ref": "clean-suite-14"}]},
{"name": "report-writing", "level": "basic", "evidence": []},
{"name": "code-review", "level": "proficient",
"evidence": [{"kind": "link", "ref": "https://example.org/reviews/41"}]},
],
"tools": [
{"name": "browser", "access": "read",
"evidence": [{"kind": "log", "ref": "browser-check-09"}]},
{"name": "python", "access": "execute",
"evidence": [{"kind": "test", "ref": "python-smoke-03"}]},
{"name": "payment-api", "access": "write", "evidence": []},
],
"limits": [
"No access to private mailboxes.",
"Code review covers Python only.",
],
}
def parse_date(text):
parts = text.split("-")
if len(parts) != 3:
return None
try:
return date(int(parts[0]), int(parts[1]), int(parts[2]))
except ValueError:
return None
def check_evidence(evidence, label, errors, warnings):
if not isinstance(evidence, list) or len(evidence) == 0:
errors.append(f"{label}: no evidence")
return 0
good = 0
for entry in evidence:
if not isinstance(entry, dict):
errors.append(f"{label}: evidence entry is not an object")
continue
kind = entry.get("kind")
ref = entry.get("ref")
if kind not in KINDS:
errors.append(f"{label}: bad evidence kind {kind!r}")
continue
if not isinstance(ref, str) or not ref.strip():
errors.append(f"{label}: evidence ref is empty")
continue
good += 1
return good
def check_profile(profile, as_of):
errors = []
warnings = []
if not isinstance(profile, dict):
return ["profile is not a JSON object"], warnings, {}
agent_id = profile.get("agent_id")
if not isinstance(agent_id, str) or not agent_id.strip():
errors.append("agent_id: missing or empty")
agent_id = "-"
version = profile.get("version")
if not isinstance(version, str) or not version.strip():
errors.append("version: missing or empty")
version = "-"
declared_raw = profile.get("declared_at")
declared = parse_date(declared_raw) if isinstance(declared_raw, str) else None
if declared is None:
errors.append("declared_at: missing or not YYYY-MM-DD")
age_days = -1
stale = "unknown"
else:
age_days = (as_of - declared).days
if age_days < 0:
errors.append("declared_at: date is after as_of")
stale = "unknown"
elif age_days > STALE_DAYS:
stale = "yes"
warnings.append(f"profile is stale: age {age_days} days over limit {STALE_DAYS}")
else:
stale = "no"
skills = profile.get("skills")
if not isinstance(skills, list):
errors.append("skills: missing or not a list")
skills = []
tools = profile.get("tools")
if not isinstance(tools, list):
errors.append("tools: missing or not a list")
tools = []
limits = profile.get("limits")
if not isinstance(limits, list):
errors.append("limits: missing or not a list")
limits = []
elif len(limits) == 0:
warnings.append("limits: empty list, no limits declared")
skill_names = []
skills_without = []
evidence_total = 0
level_counts = {"basic": 0, "proficient": 0, "advanced": 0}
for entry in skills:
if not isinstance(entry, dict):
errors.append("skills: entry is not an object")
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
errors.append("skills: entry with missing name")
continue
skill_names.append(name)
level = entry.get("level")
if level not in LEVELS:
errors.append(f"skill {name}: bad level {level!r}")
else:
level_counts[level] += 1
before = len(errors)
evidence_total += check_evidence(entry.get("evidence"), f"skill {name}", errors, warnings)
if len(errors) > before:
skills_without.append(name)
dup_skills = sorted({n for n in skill_names if skill_names.count(n) > 1})
tool_names = []
tools_without = []
access_counts = {"none": 0, "read": 0, "write": 0, "execute": 0}
for entry in tools:
if not isinstance(entry, dict):
errors.append("tools: entry is not an object")
continue
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
errors.append("tools: entry with missing name")
continue
tool_names.append(name)
access = entry.get("access")
if access not in ACCESSES:
errors.append(f"tool {name}: bad access {access!r}")
continue
access_counts[access] += 1
if access == "none":
continue
before = len(errors)
evidence_total += check_evidence(entry.get("evidence"), f"tool {name}", errors, warnings)
if len(errors) > before:
tools_without.append(name)
dup_tools = sorted({n for n in tool_names if tool_names.count(n) > 1})
for n in dup_skills:
errors.append(f"skills: duplicate name {n}")
for n in dup_tools:
errors.append(f"tools: duplicate name {n}")
stats = {
"agent_id": agent_id,
"version": version,
"declared_at": declared_raw if isinstance(declared_raw, str) else "-",
"age_days": age_days,
"stale": stale,
"skills": len(skills),
"skill_names": sorted(skill_names),
"level_counts": level_counts,
"skills_without": sorted(skills_without),
"tools": len(tools),
"tool_names": sorted(tool_names),
"access_counts": access_counts,
"tools_without": sorted(tools_without),
"limits": len(limits),
"evidence_total": evidence_total,
"dup_skills": dup_skills,
"dup_tools": dup_tools,
}
return errors, warnings, stats
def print_report(errors, warnings, stats):
print(f"agent_id: {stats.get('agent_id', '-')}")
print(f"version: {stats.get('version', '-')}")
print(f"declared_at: {stats.get('declared_at', '-')}")
print(f"age_days: {stats.get('age_days', -1)}")
print(f"stale: {stats.get('stale', 'unknown')}")
print(f"skills: {stats.get('skills', 0)}: {', '.join(stats.get('skill_names', [])) if stats.get('skill_names') else '-'}")
lc = stats.get("level_counts", {})
print(f"levels: basic {lc.get('basic', 0)}, proficient {lc.get('proficient', 0)}, advanced {lc.get('advanced', 0)}")
sw = stats.get("skills_without", [])
print(f"skills_without_evidence: {len(sw)}: {', '.join(sw) if sw else '-'}")
print(f"tools: {stats.get('tools', 0)}: {', '.join(stats.get('tool_names', [])) if stats.get('tool_names') else '-'}")
ac = stats.get("access_counts", {})
print(f"access: none {ac.get('none', 0)}, read {ac.get('read', 0)}, write {ac.get('write', 0)}, execute {ac.get('execute', 0)}")
tw = stats.get("tools_without", [])
print(f"tools_without_evidence: {len(tw)}: {', '.join(tw) if tw else '-'}")
print(f"limits: {stats.get('limits', 0)}")
print(f"evidence_entries: {stats.get('evidence_total', 0)}")
print(f"errors: {len(errors)}")
for e in errors:
print(f" error: {e}")
print(f"warnings: {len(warnings)}")
for w in warnings:
print(f" warning: {w}")
verdict = "FAIL" if errors else "PASS"
print(f"verdict: {verdict}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("path", nargs="?", default=None)
parser.add_argument("--make-fixture", action="store_true")
parser.add_argument("--as-of", default=None)
args = parser.parse_args()
if args.as_of:
as_of = parse_date(args.as_of)
if as_of is None:
print("error: --as-of must be YYYY-MM-DD")
return 2
else:
as_of = date.today()
if args.make_fixture:
profile = FIXTURE
elif args.path:
try:
with open(args.path, "r", encoding="utf-8") as fh:
profile = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read profile: {exc}")
return 2
else:
print("error: give a profile path or --make-fixture")
return 2
errors, warnings, stats = check_profile(profile, as_of)
print_report(errors, warnings, stats)
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.