Skip to main content

Orchestrate AI skills in composable patterns

Project description

SkillChain

Orchestrate AI skills in composable patterns.

SkillChain is a Python SDK for chaining AI skills together — each potentially using a different LLM model — through sequential, parallel, conditional, map-reduce, and loop patterns. It follows the agentskills.io specification and provides built-in OpenTelemetry tracing with GenAI semantic conventions.

Where LangChain orchestrates agents, SkillChain orchestrates skills — portable, spec-compliant units of procedural knowledge that any agentskills.io-compatible tool can load and execute.


Table of Contents


Installation

# Core SDK
pip install skillchain

# With OpenTelemetry tracing support
pip install skillchain[telemetry]

Dependencies: LiteLLM (100+ model support), PyYAML, httpx, Pydantic

Python: >= 3.10


Quick Start

from skillchain import skill

# Define skills — each can use a different model
@skill(name="summarize", model="claude-sonnet-4-6")
async def summarize(ctx):
    return f"Summarize this text in 2 sentences:\n\n{ctx['text']}"

@skill(name="translate", model="claude-haiku-4-5-20251001")
async def translate(ctx):
    return f"Translate to French:\n\n{ctx['summarize']}"

# Chain them with >>
chain = summarize >> translate

# Run (sync wrapper for scripts/notebooks)
result = chain.run_sync({"text": "Long article about AI..."})
print(result["summarize"])   # English summary
print(result["translate"])   # French translation

Set your API key first:

export ANTHROPIC_API_KEY="your-key-here"

Architecture

Core Abstractions

SkillChain is built on four core abstractions:

+------------------+     +------------------+     +------------------+
|      Skill       |     |   SkillContext    |     | ExecutionEngine  |
|------------------|     |------------------|     |------------------|
| name             |     | _data (dict)     |     | provider         |
| description      |     | history          |     |                  |
| model            |---->| results          |<----|  execute()       |
| instructions     |     |                  |     |    |             |
|                  |     | get/set/merge    |     |    v             |
| run(ctx) --------|---->| snapshot()       |     | ModelProvider    |
| build_prompt()   |     | record()         |     |   call() -----> LLM
| process_output() |     +------------------+     +------------------+
+------------------+
        |
        | implements
        v
+------------------+
|      Chain       |  (Chain is itself a Skill — composite pattern)
|------------------|
| _skills: [Skill] |
| run(ctx) loops   |
|   each skill     |
| >> operator      |
+------------------+

Skill — The atomic unit. Wraps a prompt template + model config. Two ways to define: @skill decorator or Skill subclass.

SkillContext — A dict-like object that flows through the chain. Every skill reads from it and writes to it. Tracks execution history and intermediate results.

ExecutionEngine — Runs a skill's prompt through LiteLLM (or returns it directly for local skills with model=None).

Chain — A composed sequence of skills. Built via the >> operator. A Chain is itself a Skill, so chains nest inside chains.

How Context Flows Between Skills

The SkillContext is the single shared data bus. Here is exactly what happens when a skill runs:

                         SkillContext
                    +--------------------+
                    | "text": "Hello..." |
                    | "language": "FR"   |
                    +--------------------+
                              |
                    +---------v----------+
                    |   summarize.run()  |
                    |                    |
                    |  1. build_prompt() |  <-- reads ctx["text"]
                    |     prompt = "..." |
                    |                    |
                    |  2. engine.execute |  <-- sends prompt to LLM
                    |     raw = "Short.." |
                    |                    |
                    |  3. process_output |  <-- transforms raw output
                    |     result = "..." |
                    |                    |
                    |  4. Store result:  |
                    |     STRING -> ctx[skill_name] = result
                    |     DICT   -> ctx.merge(result)
                    |                    |
                    |  5. Record history |
                    +---------+----------+
                              |
                    +--------------------+
                    | "text": "Hello..." |
                    | "language": "FR"   |
                    | "summarize": "..." |  <-- new key added
                    +--------------------+
                              |
                    +---------v----------+
                    |  translate.run()   |
                    |                    |
                    |  reads ctx["summarize"]
                    |  writes ctx["translate"]
                    +---------+----------+
                              |
                    +--------------------+
                    | "text": "Hello..." |
                    | "language": "FR"   |
                    | "summarize": "..." |
                    | "translate": "..." |  <-- another key added
                    +--------------------+

Return value convention:

  • String return -> stored as ctx[skill_name] (e.g., skill named "summarize" stores in ctx["summarize"])
  • Dict return -> merged into context (e.g., return {"summary": "...", "keywords": [...]} adds both keys)

Accessing previous skill outputs:

@skill(name="step-two", model="claude-sonnet-4-6")
async def step_two(ctx):
    # Read output from a skill that returned a string
    previous_output = ctx["step-one"]      # key = skill name

    # Read output from a skill that returned a dict
    specific_field = ctx["summary"]        # key = dict key

    # Check if a key exists
    optional = ctx.get("maybe-missing", "default value")

    return f"Process: {previous_output}"

Context in different patterns:

Pattern Context behavior
Sequential (>>) Same context flows A -> B -> C. Each skill sees all previous outputs.
Parallel Each parallel skill gets an isolated snapshot. Results merge back by named key.
Conditional The selected route skill gets the full context.
MapReduce Mapper gets a snapshot + ctx["item"] per item. Reducer gets ctx["results"] (list of mapper outputs).
Loop Same context across iterations. Skill keeps overwriting the same keys.

Inspecting execution history:

result = await chain.run({"text": "Hello"})

# All intermediate results by skill name
result.results["summarize"]    # output of summarize skill
result.results["translate"]    # output of translate skill

# Full execution history: list of (skill_name, input_snapshot, output)
for name, input_snap, output in result.history:
    print(f"{name}: {output}")

Progressive Disclosure

SkillChain follows the agentskills.io progressive disclosure model. Skills loaded from directories go through three stages, loading more detail only when needed:

Stage 1: DISCOVERED          Stage 2: ACTIVATED           Stage 3: RESOURCES_LOADED
~100 tokens                  < 5000 tokens                Variable

+------------------+         +------------------+         +------------------+
| name             |         | name             |         | name             |
| description      |         | description      |         | description      |
|                  |  -----> | instructions     |  -----> | instructions     |
|  (nothing else)  | on first| metadata         | on first| metadata         |
|                  |  run()  | model            |  run()  | model            |
+------------------+         +------------------+         | build_prompt()   |
                                                          | process_output() |
                                                          +------------------+
     Skill.from_directory()       _ensure_activated()        _ensure_resources_loaded()
     SkillRegistry.scan()         automatic in run()         automatic in run()

This means you can register hundreds of skills in a registry and only pay the token cost for the ones that actually execute.

# Stage 1 only — just name + description loaded
s = Skill.from_directory("./my-skill/")
s.disclosure_stage   # DisclosureStage.DISCOVERED
s.instructions       # "" (not loaded yet)

# Stages 2+3 happen automatically on first run
await s.run({"input": "hello"})
s.disclosure_stage   # DisclosureStage.RESOURCES_LOADED
s.instructions       # "Full SKILL.md body here..."

Package Structure

skillchain/
├── __init__.py                 # Public API: Skill, skill, Chain, Parallel, etc.
├── exceptions.py               # SkillError, ChainError, ModelError, etc.
├── core/
│   ├── skill.py                # Skill base class, @skill decorator, from_directory()
│   ├── context.py              # SkillContext (shared data bus)
│   ├── chain.py                # Chain class (>> operator)
│   ├── engine.py               # ExecutionEngine (runs prompts through LLM)
│   └── disclosure.py           # ProgressiveLoader (3-stage lazy loading)
├── patterns/
│   ├── parallel.py             # Parallel(**named_skills)
│   ├── conditional.py          # Conditional(condition, routes, default)
│   ├── map_reduce.py           # MapReduce(mapper, reducer, input_key)
│   └── loop.py                 # Loop(skill, until, max_iterations)
├── models/
│   └── provider.py             # LiteLLM wrapper (ModelProvider)
├── registry/
│   ├── registry.py             # SkillRegistry (discover + load skills)
│   ├── parser.py               # SKILL.md YAML frontmatter parser
│   └── loaders/
│       ├── local.py            # Load from local directories
│       ├── url.py              # Load from URLs
│       └── package.py          # Load from pip packages
├── skills/                     # Built-in skills (agentskills.io format)
│   ├── read-file/SKILL.md
│   ├── write-file/SKILL.md
│   ├── list-files/SKILL.md
│   ├── summarize/SKILL.md
│   ├── extract-json/SKILL.md
│   └── classify/SKILL.md
└── telemetry/
    ├── tracing.py              # SkillTracer (OTel GenAI spans)
    └── attributes.py           # gen_ai.* attribute constants

Orchestration Patterns

All five patterns implement the Skill interface, so they compose with each other and with the >> operator.

1. Sequential (>>)

Skills execute one after another. Each skill sees all previous outputs in the context.

chain = skill_a >> skill_b >> skill_c
result = await chain.run({"input": "data"})

Context flow:

ctx --> [skill_a] --> ctx' --> [skill_b] --> ctx'' --> [skill_c] --> ctx'''

2. Parallel

Skills execute concurrently. Each gets an isolated snapshot of the context. Results merge back with named keys.

from skillchain import Parallel

analysis = Parallel(
    sentiment=analyze_sentiment,    # result -> ctx["sentiment"]
    entities=extract_entities,      # result -> ctx["entities"]
    keywords=extract_keywords,      # result -> ctx["keywords"]
)
result = await analysis.run({"text": "..."})

Context flow:

           +---> [sentiment] ---+
           |   (isolated copy)  |
ctx -------+---> [entities]  ---+----> ctx (merged results)
           |   (isolated copy)  |
           +---> [keywords]  ---+
              (isolated copy)

The named keys (sentiment=, entities=, keywords=) determine where each skill's output is stored in the merged context.

3. Conditional

Routes to different skills based on a condition function that inspects the context.

from skillchain import Conditional

router = Conditional(
    condition=lambda ctx: ctx["language"],   # returns route key
    routes={
        "python": review_python,
        "javascript": review_javascript,
    },
    default=review_generic,                  # fallback
)

Context flow:

ctx --> condition(ctx) = "python" --> [review_python] --> ctx'
                         "javascript" --> [review_javascript]
                         (other) --> [review_generic]

4. MapReduce

Fan out a mapper skill across a list of items (in parallel), then reduce all results.

from skillchain import MapReduce

batch_summarize = MapReduce(
    mapper=summarize_chunk,      # runs once per item
    reducer=combine_summaries,   # merges all mapper outputs
    input_key="chunks",          # ctx key containing the list
)

Context flow:

ctx["chunks"] = [item1, item2, item3]

          +---> [mapper](item=item1) ---> result1 ---+
          |                                           |
ctx ------+---> [mapper](item=item2) ---> result2 ---+---> ctx["results"] = [r1, r2, r3]
          |                                           |         |
          +---> [mapper](item=item3) ---> result3 ---+         v
                                                          [reducer](results) --> ctx'

Each mapper invocation receives a context snapshot with ctx["item"] set to the current item. The reducer receives all mapper outputs in ctx["results"].

5. Loop

Repeat a skill until a condition is met or max iterations reached.

from skillchain import Loop

refine = Loop(
    skill=improve_draft,
    until=lambda ctx: ctx.get("quality_score", 0) > 0.9,
    max_iterations=5,
)

Context flow:

ctx --> [improve_draft] --> check until() --> false --> [improve_draft] --> check until()
                                                                              |
                                                              true (or max) --+--> ctx
                                                                              |
                                                              ctx["loop_iterations"] = N

Composing Patterns

Every pattern is a Skill, so patterns nest inside each other:

# Parallel inside a chain
chain = fetch_data >> Parallel(
    analysis=analyze,
    summary=summarize,
) >> generate_report

# Loop containing a chain
refinement = Loop(
    skill=critique >> improve,          # chain inside loop
    until=lambda ctx: ctx.get("score", 0) > 0.9,
    max_iterations=3,
)

# MapReduce inside a chain with Conditional
pipeline = split_text >> MapReduce(
    mapper=Conditional(                 # conditional inside mapreduce
        condition=lambda ctx: ctx.get("type", "text"),
        routes={"code": analyze_code, "text": analyze_text},
    ),
    reducer=merge_results,
    input_key="chunks",
) >> format_output

Defining Skills

Decorator Style

The simplest way — for inline skills with straightforward logic:

from skillchain import skill

@skill(name="summarize", description="Summarize text", model="claude-sonnet-4-6")
async def summarize(ctx):
    # Return a string: sent to the LLM as the prompt
    # LLM response is stored in ctx["summarize"] (the skill name)
    return f"Summarize this:\n\n{ctx['text']}"

@skill(name="extract-data", description="Extract fields", model="claude-sonnet-4-6")
async def extract_data(ctx):
    # Return a dict: merged directly into context (no LLM call for the merge)
    return {"title": "extracted", "date": "2026-01-01"}

@skill(name="uppercase", description="Uppercase locally", model=None)
async def uppercase(ctx):
    # model=None: no LLM call, the return value IS the output
    return {"upper_text": ctx["text"].upper()}

Class Style

For skills with custom pre/post processing:

from skillchain import Skill, SkillContext

class SummarizeSkill(Skill):
    name = "summarize"
    description = "Summarize text concisely"
    model = "claude-sonnet-4-6"

    async def build_prompt(self, ctx: SkillContext) -> str:
        return f"Summarize in {ctx.get('max_sentences', 3)} sentences:\n\n{ctx['text']}"

    async def process_output(self, raw: str, ctx: SkillContext) -> dict:
        # Custom post-processing — return dict to merge into context
        return {
            "summary": raw,
            "summary_length": len(raw),
        }

summarize = SummarizeSkill()
chain = summarize >> next_skill

From SKILL.md Directory (agentskills.io)

Load skills from agentskills.io-compliant directories:

my-skill/
├── SKILL.md              # Frontmatter + instructions
└── scripts/
    └── handler.py        # build_prompt() + process_output()
<!-- my-skill/SKILL.md -->
---
name: my-skill
description: Does something useful. Use when the user needs X.
metadata:
  model: claude-sonnet-4-6
---

You are a helpful skill. Follow these instructions carefully.

## Steps
1. Read the input
2. Process it
3. Return the result
# my-skill/scripts/handler.py
async def build_prompt(ctx):
    return f"Process this: {ctx['input']}"

async def process_output(raw, ctx):
    return {"result": raw}
from skillchain import Skill

my_skill = Skill.from_directory("./my-skill/")
result = my_skill.run_sync({"input": "hello"})

Built-in Skills

SkillChain ships with six built-in skills, each as a proper agentskills.io directory:

Skill Model Reads Writes
read_file None (local) ctx["file_path"] ctx["content"]
write_file None (local) ctx["output_path"], ctx["content"] ctx["write_status"]
list_files None (local) ctx["directory"], optional ctx["pattern"] ctx["files"]
summarize claude-sonnet-4-6 ctx["text"] ctx["summary"]
extract_json claude-sonnet-4-6 ctx["text"], optional ctx["schema"] ctx["extracted"]
classify claude-sonnet-4-6 ctx["text"], ctx["categories"] ctx["classification"]
from skillchain.skills import read_file, write_file, summarize

# Chain built-in skills with custom skills
pipeline = read_file >> summarize >> write_file
pipeline.run_sync({
    "file_path": "article.txt",
    "output_path": "summary.txt",
})

Skill Registry

Discover and load skills from multiple sources:

from skillchain import SkillRegistry

registry = SkillRegistry()

# Local directories (scans for SKILL.md files)
registry.register_directory("./my-skills/")

# Remote URL
await registry.register_url("https://example.com/skills/my-skill/")

# pip-installed packages (Python entry points)
registry.discover_packages()

# Use a skill
skill = registry.get("data-analysis")
chain = skill >> format_output

# List all registered skills
print(registry.list())  # ["data-analysis", "other-skill", ...]

Skills in the registry stay in Stage 1 (discovery) — only name + description loaded. Full instructions and scripts load lazily when the skill actually runs.


Per-Skill Model Selection

Each skill can use a different LLM via LiteLLM model strings:

# Fast + cheap for simple tasks
@skill(name="classify", model="claude-haiku-4-5-20251001")
async def classify(ctx): ...

# Powerful for complex reasoning
@skill(name="analyze", model="claude-sonnet-4-6")
async def analyze(ctx): ...

# Use OpenAI for a specific skill
@skill(name="generate-image-prompt", model="gpt-4o")
async def generate_image_prompt(ctx): ...

# Override model for an entire chain
chain = (classify >> analyze).with_default_model("claude-sonnet-4-6")

Set API keys via environment variables:

export ANTHROPIC_API_KEY="..."    # Claude models
export OPENAI_API_KEY="..."       # GPT models
export GEMINI_API_KEY="..."       # Gemini models

OpenTelemetry Tracing

SkillChain integrates OpenTelemetry with GenAI semantic conventions for full observability of skill orchestration.

pip install skillchain[telemetry]

Enable tracing

from skillchain.telemetry import SkillTracer
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter

# Configure with console output (or any OTel exporter: Jaeger, OTLP, etc.)
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
SkillTracer.configure(provider=provider)

# All skill executions now produce spans automatically
chain = summarize >> translate
await chain.run({"text": "..."})   # spans emitted!

Span hierarchy

For a chain summarize >> Parallel(topics, sentiment) >> report:

invoke_workflow "chain-summarize-parallel-..." (sequential)
├── invoke_agent "summarize"
│   └── chat "claude-sonnet-4-6" (CLIENT, provider=anthropic)
├── invoke_workflow "parallel-sentiment-topics" (parallel)
│   ├── invoke_agent "sentiment"
│   │   └── chat "claude-haiku-4-5" (CLIENT, provider=anthropic)
│   └── invoke_agent "topics"
│       └── chat "claude-haiku-4-5" (CLIENT, provider=anthropic)
└── invoke_agent "report"
    └── chat "claude-sonnet-4-6" (CLIENT, provider=anthropic)

Span types and attributes

Span OTel Operation Kind Key Attributes
Skill execution invoke_agent INTERNAL gen_ai.agent.name, gen_ai.request.model
Chain/Pattern invoke_workflow INTERNAL gen_ai.workflow.name, skillchain.pattern.type
LLM call chat CLIENT gen_ai.request.model, gen_ai.provider.name
Local skill execute_tool INTERNAL gen_ai.tool.name, gen_ai.tool.type

Tracing is no-op when OTel is not installed — no performance impact.


Examples

Example 1: Simple Chain with Claude

from skillchain import skill

@skill(name="summarize", model="claude-sonnet-4-6")
async def summarize(ctx):
    return f"Summarize this text in 2 sentences:\n\n{ctx['text']}"

@skill(name="translate", model="claude-haiku-4-5-20251001")
async def translate(ctx):
    language = ctx.get("language", "French")
    return f"Translate to {language}:\n\n{ctx['summarize']}"

chain = summarize >> translate
result = chain.run_sync({
    "text": "SkillChain is a Python SDK for orchestrating AI skills...",
    "language": "Spanish",
})
print(result["summarize"])    # English summary
print(result["translate"])    # Spanish translation

Example 2: Parallel Analysis

Run multiple analyses concurrently, then combine:

from skillchain import skill, Parallel

@skill(name="sentiment", model="claude-haiku-4-5-20251001")
async def analyze_sentiment(ctx):
    return f"Sentiment (one word: positive/negative/neutral):\n\n{ctx['text']}"

@skill(name="entities", model="claude-haiku-4-5-20251001")
async def extract_entities(ctx):
    return f"Extract named entities as comma-separated list:\n\n{ctx['text']}"

@skill(name="report", model="claude-sonnet-4-6")
async def generate_report(ctx):
    return (
        f"Analysis report given:\n"
        f"Sentiment: {ctx['sentiment']}\n"
        f"Entities: {ctx['entities']}\n"
        f"Text: {ctx['text']}"
    )

chain = Parallel(sentiment=analyze_sentiment, entities=extract_entities) >> generate_report
result = chain.run_sync({"text": "Apple CEO Tim Cook announced..."})

Example 3: Document Pipeline with Built-in Skills

from skillchain import skill, MapReduce
from skillchain.skills import read_file, write_file

@skill(name="chunk-text", description="Split into chunks", model=None)
async def chunk_text(ctx):
    text = ctx["content"]
    size = ctx.get("chunk_size", 2000)
    return {"chunks": [text[i:i+size] for i in range(0, len(text), size)]}

@skill(name="summarize-chunk", model="claude-sonnet-4-6")
async def summarize_chunk(ctx):
    return f"Summarize:\n\n{ctx['item']}"

@skill(name="combine", model="claude-sonnet-4-6")
async def combine(ctx):
    parts = "\n---\n".join(r.get("summarize-chunk", str(r)) for r in ctx["results"])
    return f"Combine into one summary:\n\n{parts}"

pipeline = read_file >> chunk_text >> MapReduce(
    mapper=summarize_chunk,
    reducer=combine,
    input_key="chunks",
) >> write_file

pipeline.run_sync({
    "file_path": "long_document.txt",
    "output_path": "summary.txt",
})

Example 4: Conditional Routing

from skillchain import skill, Conditional

@skill(name="detect-lang", model="claude-haiku-4-5-20251001")
async def detect_language(ctx):
    return f"What language is this code? Reply: python/javascript/other\n\n{ctx['code']}"

@skill(name="review-python", model="claude-sonnet-4-6")
async def review_python(ctx):
    return f"Review this Python code:\n\n{ctx['code']}"

@skill(name="review-js", model="claude-sonnet-4-6")
async def review_js(ctx):
    return f"Review this JavaScript code:\n\n{ctx['code']}"

@skill(name="review-generic", model="claude-sonnet-4-6")
async def review_generic(ctx):
    return f"Review this code:\n\n{ctx['code']}"

chain = detect_language >> Conditional(
    condition=lambda ctx: ctx["detect-lang"].strip().lower(),
    routes={"python": review_python, "javascript": review_js},
    default=review_generic,
)
result = chain.run_sync({"code": "def hello(): print('hi')"})

Example 5: All Five Patterns Combined

No API key needed — runs entirely locally:

from skillchain import skill, Parallel, Conditional, MapReduce, Loop

@skill(name="generate-data", model=None)
async def generate_data(ctx):
    return {"items": ["apple", "banana", "cherry", "date", "elderberry"]}

@skill(name="count-chars", model=None)
async def count_chars(ctx):
    return {"counted": {"word": ctx["item"], "length": len(ctx["item"])}}

@skill(name="find-longest", model=None)
async def find_longest(ctx):
    longest = max(ctx["results"], key=lambda r: r["counted"]["length"])
    return {"longest": longest["counted"]["word"]}

@skill(name="categorize", model=None)
async def categorize(ctx):
    return {"category": "long" if len(ctx["longest"]) > 5 else "short"}

@skill(name="handle-long", model=None)
async def handle_long(ctx):
    return {"verdict": f"'{ctx['longest']}' is long ({len(ctx['longest'])} chars)"}

@skill(name="handle-short", model=None)
async def handle_short(ctx):
    return {"verdict": f"'{ctx['longest']}' is short ({len(ctx['longest'])} chars)"}

@skill(name="get-upper", model=None)
async def get_upper(ctx):
    return {"upper": ctx["longest"].upper()}

@skill(name="get-reverse", model=None)
async def get_reverse(ctx):
    return {"reverse": ctx["longest"][::-1]}

@skill(name="add-emphasis", model=None)
async def add_emphasis(ctx):
    ctx["emphasis_count"] = ctx.get("emphasis_count", 0) + 1
    return {"emphasized": ctx.get("emphasized", ctx["verdict"]) + "!"}

# Compose all 5 patterns:
chain = (
    generate_data                                          # Sequential
    >> MapReduce(mapper=count_chars,                        # MapReduce
                 reducer=find_longest, input_key="items")
    >> categorize
    >> Parallel(upper=get_upper, reverse=get_reverse)      # Parallel
    >> Conditional(condition=lambda ctx: ctx["category"],   # Conditional
                   routes={"long": handle_long, "short": handle_short})
    >> Loop(skill=add_emphasis,                             # Loop
            until=lambda ctx: ctx.get("emphasis_count", 0) >= 3,
            max_iterations=5)
)

result = chain.run_sync({})
print(result["longest"])      # "elderberry"
print(result["upper"])        # "ELDERBERRY"
print(result["reverse"])      # "yrrebredle"
print(result["emphasized"])   # "'elderberry' is long (10 chars)!!!"

Example 6: Custom Skills from Directories (agentskills.io)

Create your own skills as directories with SKILL.md files, then load and orchestrate them. This is the agentskills.io-native way to build reusable, portable skills.

Directory structure:

my-project/
├── main.py
└── skills/
    ├── extract-keywords/
    │   ├── SKILL.md
    │   └── scripts/handler.py
    ├── generate-hashtags/
    │   ├── SKILL.md
    │   └── scripts/handler.py
    └── format-post/
        ├── SKILL.md
        └── scripts/handler.py

Skill 1 — skills/extract-keywords/SKILL.md:

---
name: extract-keywords
description: Extract keywords from text. Reads ctx['text'], writes ctx['keywords'] as a list.
metadata:
  model: claude-haiku-4-5-20251001
---

You are a keyword extraction specialist. Given any text, identify the most important keywords and phrases.

## Instructions

1. Read the input text carefully
2. Identify 5-8 key terms that capture the main topics
3. Return them as a comma-separated list
4. Focus on nouns and noun phrases, not generic words

Skill 1 — skills/extract-keywords/scripts/handler.py:

async def build_prompt(ctx):
    return (
        "Extract 5-8 important keywords from this text. "
        "Return only a comma-separated list, nothing else.\n\n"
        f"{ctx['text']}"
    )


async def process_output(raw, ctx):
    keywords = [k.strip() for k in raw.split(",")]
    return {"keywords": keywords}

Skill 2 — skills/generate-hashtags/SKILL.md:

---
name: generate-hashtags
description: Generate social media hashtags from keywords. Reads ctx['keywords'], writes ctx['hashtags'].
metadata:
  model: claude-haiku-4-5-20251001
---

You are a social media expert. Convert keywords into engaging hashtags suitable for LinkedIn and Twitter.

## Instructions

1. Take the provided keywords
2. Transform each into a hashtag (camelCase, no spaces)
3. Add 2-3 trending/general hashtags relevant to the topic
4. Return as a space-separated list of hashtags

Skill 2 — skills/generate-hashtags/scripts/handler.py:

async def build_prompt(ctx):
    keywords = ", ".join(ctx["keywords"])
    return (
        "Convert these keywords into social media hashtags. "
        "Use camelCase, add 2-3 general trending hashtags. "
        "Return only the hashtags as a space-separated list.\n\n"
        f"Keywords: {keywords}"
    )


async def process_output(raw, ctx):
    hashtags = [h.strip() for h in raw.split() if h.startswith("#")]
    if not hashtags:
        hashtags = [f"#{h.strip()}" for h in raw.split() if h.strip()]
    return {"hashtags": hashtags}

Skill 3 — skills/format-post/SKILL.md:

---
name: format-post
description: Format a social media post from text, keywords, and hashtags. Reads ctx['text'], ctx['keywords'], ctx['hashtags'], writes ctx['post'].
metadata:
  model: claude-sonnet-4-6
---

You are a content writer. Create an engaging social media post that summarizes the original content and incorporates the provided hashtags.

## Instructions

1. Read the original text for context
2. Write a concise, engaging post (2-3 sentences max)
3. Append the hashtags at the end
4. Make it suitable for LinkedIn

Skill 3 — skills/format-post/scripts/handler.py:

async def build_prompt(ctx):
    keywords = ", ".join(ctx["keywords"])
    hashtags = " ".join(ctx["hashtags"])
    return (
        "Write a short, engaging LinkedIn post (2-3 sentences) based on this content. "
        "End with the hashtags on a new line.\n\n"
        f"Original text: {ctx['text']}\n\n"
        f"Keywords: {keywords}\n\n"
        f"Hashtags to include: {hashtags}"
    )


async def process_output(raw, ctx):
    return {"post": raw}

Orchestrator — main.py:

import os
import sys
from pathlib import Path
from skillchain import Skill, SkillRegistry

skills_dir = Path(__file__).parent / "skills"

# --- Option A: Load individual skills by path ---

extract_keywords = Skill.from_directory(str(skills_dir / "extract-keywords"))
generate_hashtags = Skill.from_directory(str(skills_dir / "generate-hashtags"))
format_post = Skill.from_directory(str(skills_dir / "format-post"))

# At this point, only name + description are loaded (Stage 1: DISCOVERED)
print(extract_keywords.disclosure_stage)  # DisclosureStage.DISCOVERED

# Chain them
chain = extract_keywords >> generate_hashtags >> format_post

result = chain.run_sync({
    "text": "SkillChain is a new Python SDK that lets developers orchestrate AI skills "
            "in composable patterns. It supports sequential, parallel, conditional, "
            "map-reduce, and loop patterns with per-skill model selection."
})

# After run, all stages completed (Stage 3: RESOURCES_LOADED)
print(f"Keywords: {result['keywords']}")
print(f"Hashtags: {' '.join(result['hashtags'])}")
print(f"Post:\n{result['post']}")


# --- Option B: Use SkillRegistry to scan a directory ---

registry = SkillRegistry()
registry.register_directory(str(skills_dir))

print(registry.list())  # ['extract-keywords', 'generate-hashtags', 'format-post']

kw = registry.get("extract-keywords")
ht = registry.get("generate-hashtags")
fp = registry.get("format-post")

chain2 = kw >> ht >> fp
result2 = chain2.run_sync({"text": "OpenTelemetry provides observability for distributed systems."})
print(result2["post"])

Run:

export ANTHROPIC_API_KEY="your-key-here"
python main.py

Error Handling

from skillchain.exceptions import (
    SkillError,            # Base for all SkillChain errors
    SkillNotFoundError,    # Skill not in registry
    SkillExecutionError,   # Skill failed (wraps original error + context snapshot)
    SkillValidationError,  # Invalid SKILL.md
    ModelError,            # LLM call failed (wraps LiteLLM error)
    ChainError,            # Chain failed (includes position in chain)
)

try:
    result = await chain.run({"text": "..."})
except SkillExecutionError as e:
    print(f"Skill '{e.skill_name}' failed: {e.original_error}")
    print(f"Context at failure: {e.context_snapshot}")
except ChainError as e:
    print(f"Chain failed at skill '{e.skill_name}' (position {e.position})")

With OTel tracing enabled, errors are automatically recorded on spans with StatusCode.ERROR, exception details, and full stack traces.


License

MIT

Project details


Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page