Package skills¶
A skill is a versioned action with a contract: inputs, outputs, side effects,
risk, timeout, retries, implementation. A package needs skills where a rule, a
process, or a task type has to do or compute something in code: read an external
system, prepare a draft, write a result out. This article is for package authors:
how to write a skill with skill-sdk, put its contract into the package, choose a
hosting method, and route an external write through an approval. Rationale:
TAI-ADR-0045 (skills from code), CP-ADR-0056 (contract and invocation).
The full library API is in the skill-sdk article.
Code is the source, YAML is the export¶
A skill is written once, in the package's integration code. The contract comes from the annotations and the decorator arguments, the description from the first paragraph of the docstring:
# integration/src/claims_integration/skills.py
from pydantic import BaseModel
from skill_sdk import SkillContext, skill
class DraftIn(BaseModel):
claim: str
history: list[str] = []
class DraftOut(BaseModel):
draft: str
@skill("claim.draft_reply", version="1", side_effects="none", risk="low", timeout=120)
async def draft_reply(inputs: DraftIn, ctx: SkillContext) -> DraftOut:
"""Draft a reply to a customer claim for a person to review."""
answer = await ctx.llm.chat_json(
system_prompt="You draft polite replies to customer claims.",
messages=[{"role": "user", "content": inputs.claim}],
response_model=DraftOut,
schema_name="draft",
)
return answer.data
The package file skills/<name>.yaml is generated by skill-sdk and is not
edited by hand:
SKILL_SDK="$(uv tool dir)/package-sdk/bin/skill-sdk" # from the tool's environment
PYTHONPATH=integration/src "$SKILL_SDK" export --package . claims_integration # write skills/*.yaml
PYTHONPATH=integration/src "$SKILL_SDK" export --package . --check claims_integration # code matches YAML
- Where to get the command. The
skillsextra installsskill-sdkinto the environment of thepackage-sdktool but does not expose it onPATH, hence the path throughuv tool dir.uv tool install --editable ./sdk/skill-sdkputs it onPATHseparately; then the third-party dependencies of the integration code must be added there too (--with). PYTHONPATH. The integration code lives inintegration/src, and without it onsys.paththe module is not importable (ModuleNotFoundError). The contracts stage ofpackage-sdk testadds the path itself, so CI needs no manual call:testcompares the contracts even without the command onPATH.
# Generated by skill-sdk from code — edit the code and regenerate (TAI-ADR-0045).
apiVersion: taimen.ai/v1
kind: Skill
key: claim.draft_reply
spec:
version: '1'
description: Draft a reply to a customer claim for a person to review.
sideEffects: none
riskLevel: low
contract:
inputs: {…} # JSON Schema from DraftIn
outputs: {…} # JSON Schema from DraftOut
idempotency: none
timeoutSeconds: 120
retryPolicy: {maxAttempts: 1, backoffSeconds: 0}
implementation:
protocol: local
entrypoint: claims_integration.skills:draft_reply
keyis the skill name andspec.versionis its version; together they formname@version, by which rules, processes, and task types refer to the skill.- The contract of a published version is immutable. If the schemas or the policy
change, that is a new
versionin the decorator, a new file, a new version in the catalog. Whenplansees a difference inprotocol,sideEffects,riskLevel, orcontractof an already published version, it is not built:поднимите spec.version("raise spec.version"). - The exception is the implementation address
implementation.endpoint: it is a property of the installation, not a promise of the version. If the contract differs only in it,applymoves the address by editing the version, without a new version. description,config,inputSchema,outputSchemaoutside the contract are mutable fields:applyedits them on the published version.
package-sdk test reconciles the contracts of the integration code with the
package files using the same skill-sdk export --check, as a separate stage
before the scenarios.
Contract and policy¶
@skill argument |
Contract field | What it sets |
|---|---|---|
side_effects |
sideEffects |
none, external_read, or external_write: what the skill does in the external world |
risk |
riskLevel |
low, medium, high |
idempotency |
idempotency |
required, natural, none |
timeout |
timeoutSeconds |
1–3600 seconds |
retry=(n, s) |
retryPolicy |
maxAttempts 1–10, backoffSeconds 0–3600 |
permissions |
requiredPermissions |
permissions the core checks on the caller in the task's workspace (403 skill_permission_denied) |
cost_model |
costModel |
the unit and the cost estimate of a call |
At import time the SDK rejects what the core would reject: for example,
external_write without idempotency but with retries, since retrying an external
write without a key would produce a second effect.
Outcome versus failure. An expected result of the contract is an output, even
if it is "negative": conflict, not_found, rejected are written as fields of
the output model. SkillError(code, message, retryable=…) is only for a failure,
when there is no result: the external system is unavailable, the time is up.
Who invokes a skill¶
A skill does nothing until something refers to it. References in a package are
closed: a skill in execution, invokeSkill, interpretation.skill, and
call.skill must be declared in the same package or in a package from
requires; otherwise check reports an error. check does not verify an agent's
skills.invoke: the core rejects an unknown version when the agent is published
(422 unknown_reference).
| Where | Field | What happens |
|---|---|---|
| Task type | execution: {skill, version, inputs} |
a task of the type is executed by a single skill call; the inputs default to $.customFields |
| Approval or submission outcome | invokeSkill: {skill: name@version, inputs} |
a call on a human decision; the onSuccess/onFailure reactions follow the result of the call |
| Work rule | interpretation.skill: name@version |
the skill interprets a fact: what it is and which work to create |
| Process step | call: {skill: name@version, input} |
a call from a process instance |
| Agent | skills.invoke: [name@version] |
the agent invokes the skill through the core; the registry assigns the version to its principal |
A rule that interprets a fact cannot call an external-write skill: 422
rule_skill_side_effects, because a rule has no human decision to refer to.
External write and approval¶
sideEffects: external_write is an action in the external world on behalf of the
organization. The core executes such a call only if it has a basis:
- an approved gate approval on the same non-terminal task, not yet used for
this skill version (
409 approval_already_usedon a repeat); - or an execution basis: the task type pinned this version in
execution, and a run of the task is in progress. The basis is valid only while the task has no pending gate approval; otherwise the call gets409 approval_required; - or a process step: a
callstep of a live instance names this skill version. The basis holds while the step is open; a pending call of a closed step is cancelled (process_step_closed), but one already issued is not (see External write from a process).
Without a basis the result is 403 skill_side_effect_not_authorized; for a closed
task, 409 task_terminal. Hence the rules for a package:
- a reference to an external-write skill in
invokeSkillis written with a version: the core rejects publishing an outcome that resolves toexternal_writewithout a pinned version; - in the acceptance criteria of a task type, an external-write skill is allowed
only after a human decision criterion (
humanorllm_judge) with the samewhen; otherwise publishing the type gives422 invalid_acceptance_spec,cause: external_write_without_decision(see Type acceptance); - a draft that a human will check is a
noneorexternal_readskill, and writing the result out is a separateexternal_writeskill in the approval outcome.
# task-types/claim-reply.yaml — spec fragment
approvalSchema:
gates:
default:
outcomes:
approved:
- invokeSkill:
skill: claim.send_reply@1 # external_write: only with a version
inputs: {claim: $.task.customFields.claim, comment: $.approval.comment}
The expressions $.task.… and $.approval.… (id, comment, decidedBy,
decidedAt, outcome) are the same as for the other outcome actions (see
Approvals).
Call context¶
SkillContext member |
Purpose |
|---|---|
ctx.invocation_id, ctx.idempotency_key |
which call this is; a retry with the same key must not produce a second external effect |
ctx.remaining(), ctx.check_deadline() |
how much time is left until the contract timeout |
ctx.config(name, default) |
a hosting parameter: an environment variable of the process that executes the skill |
ctx.secret(name) |
a hosting secret: an environment variable of the process, then the node secret file /run/secrets/<name>; if neither exists, a retryable config_missing |
ctx.llm |
an LLM according to the installation configuration; tokens are counted into the cost automatically |
ctx.add_cost(unit, amount) |
your own consumption: API requests, pages |
ctx.artifacts.read(id) |
artifact content through the core: data, media_type, text() |
ctx.knowledge |
the knowledge base through the core: preview, apply (with stateToken, otherwise SnapshotStale), document, recall, query |
ctx.caller |
the verified caller context (http, mcp-http) |
There is intentionally no Control Plane client in the context: a skill does not
create or move tasks; outcomes and rules do that. ctx.artifacts and
ctx.knowledge are narrow access through the core with the executor's account;
this is the only way a skill sees memory.
-
Skill parameters and secrets are not part of the package. The package names them in the integration documentation, and the installation sets the values:
- parameters (
ctx.config): the environment of the host process. Declaring such a parameter in the package (like an observer'sconfig) and showing it indescribeis not possible yet; see Package agents, the "Execution" section; - secrets (
ctx.secret): for a host placed by a node, the names fromplacement.secretsof its agent: the node puts the file/run/secrets/<name>, andctx.secret("<name>")reads it. For a host outside a node (your ownhttpormcpservice), an environment variable with the same name; it takes precedence over the file.
The rule for reading a secret file is the same for all SDKs, with the canon in
skill_sdk.secrets: an empty or whitespace-only file means there is no secret, only trailing\r\nare trimmed from the value, and the nameagent-patis reserved for the agent PAT. A rule violation is not "no secret" butsecret_file_rejectedwith a reason (see skill-sdk, the "Secrets" section). - LLM. The installation chooses the provider with theSKILL_LLM_PROVIDERvariable:openai(the default,SKILL_LLM_BASE_URL,SKILL_LLM_API_KEY,SKILL_LLM_MODELS) orclaude-code(Claude by subscription through the CLI). Personal data of individuals in the prompt (full name, SNILS, passport, phone, e-mail) is replaced with the[ПДн:вид]("personal data: kind") marker before the model is called. -ctx.knowledge.querypages through the core itself and returns all entities; more thanmax_itemsis theknowledge_query_too_largeerror, not truncation. - The cost of a call is the LLM tokens and theadd_costunits; the core writes it into the call. - parameters (
Hosting¶
Where a skill is executed is a decision of the installation, not of the version.
The contract names the implementation protocol, and the call is executed by
whoever has the skills.execute permission and whose allow-list covers the
implementation.
The integration code is installed into the image of an agent of the skills
kind, and the agent lists the modules in skills.local:
# agents/claims-skills.yaml
spec:
identity: {kind: agent, permissions: [skills.execute]}
executor:
kind: skills
image: registry.example.com/claims/skills:1.0.0
skills:
protocols: [local]
local: [claims_integration.skills]
The image is built by package-sdk image skills --modules claims_integration.skills
(see Integrations). This is the default path: no
service of your own, no network to the skill, with executor isolation.
The skill lives in the author's service (skill-sdk serve http <module> or
skill_sdk.http.create_app(...)), and the contract names the address with an
installation variable:
skill-sdk export --package . --protocol http \
--endpoint '${CLAIMS_SKILLS_URL}' --audience claims-skills claims_integration
The call is POST /skills/{name}@{version} with an IAM token for the skill's
audience; the hosting verifies it (SKILL_SDK_IAM_ISSUER,
SKILL_SDK_AUDIENCE, SKILL_SDK_JWKS_URL) and does not start without
verification. The executor needs the http protocol in skills.protocols,
the service origin in skills.httpOrigins, and the audience in
skills.audiences. A service of your own is justified when the skill holds
state or resources that the skill host does not have.
skill-sdk serve mcp-stdio <module> or serve mcp-http: the skill is a tool
named after the skill, the cost is in _meta["skill/cost"], and the call id
and the idempotency key are in the request's _meta. The executor needs the
mcp protocol and the address in skills.mcpOrigins.
Skill tests¶
# integration/tests/test_skills.py
from skill_sdk.testing import FakeLlm, check_contract, invoke
from claims_integration.skills import draft_reply
def test_contract() -> None:
check_contract(draft_reply) # core validators, if control-plane is next to it
def test_draft_uses_the_claim() -> None:
llm = FakeLlm([{"draft": "Здравствуйте! …"}])
result = invoke(draft_reply, {"claim": "C-1"}, llm=llm)
assert result.outputs["draft"].startswith("Здравствуйте")
assert llm.calls[0].prompt == "C-1"
FakeLlmanswers with a single response, a list in order, or a function of the call; calls accumulate inllm.callsafter the personal data guard and are counted into the cost.skill_sdk.testing.FakeCoreis an in-memory core forctx.artifactsandctx.knowledge(connected withconfigure_core).- These tests are the integration code stage of
package-sdk test. Package scenarios (tests/*.test.yaml) do not execute skills: the responses are set inmocks.skills(name@version→ responses in call order), the output is checked against the skill schema from the catalog, and the expected calls withinvokeSkillinexpect.
Common problems¶
| Symptom | Cause and fix |
|---|---|
plan: контракт версии неизменяем, поднимите spec.version ("the version contract is immutable, raise spec.version") |
the contract changed without a new version in the decorator |
403 skill_permission_denied |
the caller (the process or rule identity, the agent) lacks a permission from the contract's requiredPermissions in the task's workspace |
409 approval_required |
a call on an execution basis while a gate approval is pending on the task |
export --check fails in CI |
the skill code changed but the YAML was not regenerated: run skill-sdk export and commit |
check: a reference to name@version, такого Skill нет ("no such Skill") |
the skill is not declared in the package and its requires, or the version does not match |
422 rule_skill_side_effects |
a rule interprets a fact with an external_write skill: move the write into an approval outcome |
403 skill_side_effect_not_authorized |
an external-write call without an approved gate or an execution basis |
| the call hangs in the queue | no executor with skills.execute has an allow-list that covers the implementation: protocol, module, origin, or audience |
config_missing |
the host lacks a parameter or a secret the skill reads: there is no environment variable and no /run/secrets/<name> file (or it is empty) |
secret_file_rejected, secret_unreadable |
the node secret file exists but is unusable (a link outside the directory, not a regular file, larger than 64 KiB, not UTF-8), or the process user has no read permission |
See also¶
- skill-sdk: the library API
- Package agents: the skill host and permissions
- Integrations: skills as integration actions
- Task types and statuses:
execution,approvalSchema, acceptance - Work rules: interpretation by a skill
- Authorization and permissions:
skills.invoke,skills.execute