Skip to content

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 skills extra installs skill-sdk into the environment of the package-sdk tool but does not expose it on PATH, hence the path through uv tool dir. uv tool install --editable ./sdk/skill-sdk puts it on PATH separately; then the third-party dependencies of the integration code must be added there too (--with).
  • PYTHONPATH. The integration code lives in integration/src, and without it on sys.path the module is not importable (ModuleNotFoundError). The contracts stage of package-sdk test adds the path itself, so CI needs no manual call: test compares the contracts even without the command on PATH.
# 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
  • key is the skill name and spec.version is its version; together they form name@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 version in the decorator, a new file, a new version in the catalog. When plan sees a difference in protocol, sideEffects, riskLevel, or contract of 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, apply moves the address by editing the version, without a new version.
  • description, config, inputSchema, outputSchema outside the contract are mutable fields: apply edits 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_used on 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 gets 409 approval_required;
  • or a process step: a call step 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 invokeSkill is written with a version: the core rejects publishing an outcome that resolves to external_write without a pinned version;
  • in the acceptance criteria of a task type, an external-write skill is allowed only after a human decision criterion (human or llm_judge) with the same when; otherwise publishing the type gives 422 invalid_acceptance_spec, cause: external_write_without_decision (see Type acceptance);
  • a draft that a human will check is a none or external_read skill, and writing the result out is a separate external_write skill 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's config) and showing it in describe is not possible yet; see Package agents, the "Execution" section;
    • secrets (ctx.secret): for a host placed by a node, the names from placement.secrets of its agent: the node puts the file /run/secrets/<name>, and ctx.secret("<name>") reads it. For a host outside a node (your own http or mcp service), 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\n are trimmed from the value, and the name agent-pat is reserved for the agent PAT. A rule violation is not "no secret" but secret_file_rejected with a reason (see skill-sdk, the "Secrets" section). - LLM. The installation chooses the provider with the SKILL_LLM_PROVIDER variable: openai (the default, SKILL_LLM_BASE_URL, SKILL_LLM_API_KEY, SKILL_LLM_MODELS) or claude-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.query pages through the core itself and returns all entities; more than max_items is the knowledge_query_too_large error, not truncation. - The cost of a call is the LLM tokens and the add_cost units; the core writes it into the call.

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"
  • FakeLlm answers with a single response, a list in order, or a function of the call; calls accumulate in llm.calls after the personal data guard and are counted into the cost.
  • skill_sdk.testing.FakeCore is an in-memory core for ctx.artifacts and ctx.knowledge (connected with configure_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 in mocks.skills (name@version → responses in call order), the output is checked against the skill schema from the catalog, and the expected calls with invokeSkill in expect.

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