> Nivel 8 de Agent Harness Patterns, un recorrido de patrones sobre cómo funcionan los agentes de IA. Versión web: https://harnesspatterns.dev/es/patterns/skills · Todos los patrones (en inglés): https://harnesspatterns.dev/llms.txt

# Skills bajo demanda

Una skill es un manual para un tipo de trabajo. El agente siempre ve la lista de manuales, y lee uno solo cuando un pedido lo requiere.

## El problema

El asistente de pedidos de una panadería necesita conocer las reglas de la casa. Qué tamaño de pastel alcanza para 20 personas. Que los pasteles sin frutos secos solo se hornean los viernes por la mañana. Cuánto es el anticipo. Cómo cotizar una bandeja de catering. Qué lleva cada producto que ya está en los estantes. El modelo no sabe nada de eso.

El arreglo obvio es escribirlo todo y pegarlo en el system prompt. Funciona el primer día. Después los manuales crecen, y ya son diez. Cada pedido carga ahora todos los manuales, en cada turno, ya sea que el cliente quiera un pastel de bodas o solo el horario de atención. Pagas por todo eso, los pedidos se vuelven más lentos, y la única regla que importa queda enterrada entre las que no.

Ese es Tomebloat, el system prompt inflado. Alimenta a Gulp, del nivel 7: un prompt que ya está lleno deja menos lugar para la conversación en sí.

## La solución

Divide cada manual en dos. Una **descripción** de una o dos líneas dice cuándo se aplica la skill. El **cuerpo** dice cómo hacer el trabajo. Solo las descripciones van en el system prompt, como un catálogo. Cuando un pedido coincide con una, el modelo pide su cuerpo, y el cuerpo se suma a la conversación de ahí en adelante.

Eso es progressive disclosure (revelación progresiva): primero mostrar el índice, y el detalle solo cuando hace falta. El formato habitual es una carpeta por skill con un archivo `SKILL.md`. El frontmatter del principio (el bloque entre las líneas `---`) tiene el nombre y la descripción. Todo lo que está debajo es el cuerpo.

**skills/custom-cake-order/SKILL.md**

```md
---
name: custom-cake-order
description: Cakes made to order. Use when a customer wants a cake baked for them — size by guests, flavors, allergies, lead time, deposit.
---

# Custom cake orders

1. Size by guests: up to 12 → 18 cm, up to 24 → 24 cm, more → two tiers.
2. Flavors: chocolate, vanilla, dulce de leche. Nothing else.
3. Nut allergy → the nut-free line. It only bakes on Friday mornings.
4. Check the calendar before you promise a date. Never less than 72 hours.
5. Quote the 50% deposit, and ask before you book. Never book on your own.
```

Hay tres formas comunes de entregarle skills al modelo. astorlm soporta las tres con `skillMode`:

- `all`
   El cuerpo completo de cada skill va en el system prompt, desde el primer pedido.
   Sin llamadas extra, y el modelo no puede saltarse un manual. Sirve para dos o tres skills cortas que casi todos los pedidos necesitan. Más allá de eso, es Tomebloat.
- `on-demand`
   El system prompt lista el nombre y la descripción de cada skill. Una herramienta load_skill devuelve el cuerpo completo cuando el modelo lo pide.
   Escala a docenas de skills. Cuesta una llamada a herramienta por cada skill cargada, y a veces los modelos chicos responden sin cargar la skill que necesitaban.
- `filesystem`
   El catálogo también da la ruta de cada SKILL.md, y el modelo lo abre con su herramienta común de lectura de archivos.
   La misma idea sin una herramienta especial. Así lo hacen Claude Code, Codex y Gemini CLI, por eso la misma carpeta de skills funciona en todos. Necesita un agente que pueda leer archivos.

Una skill no es una herramienta. Una herramienta es algo que el bucle ejecuta para el modelo. Una skill es algo que el modelo lee, y puede decirle qué herramientas llamar y en qué orden, como la receta que dice “revisa el calendario antes de prometer una fecha”.

## El elenco

El mismo elenco de siempre, esta vez en la cocina de una panadería.

- **El Oráculo** (el modelo): El chef detrás del pase. Lee lo que tenga enfrente, en cada turno, y no cocina nada él mismo.
- **Las comandas** (el catálogo): Una por skill, en el riel sobre el pase: un nombre y una línea sobre cuándo usarla. Son parte del system prompt, así que van en cada pedido.
- **El estante** (los cuerpos de las skills): Un libro de recetas por skill, cerrado. Cuanto más grueso el libro, más tokens pesa. Nada de eso le llega al modelo hasta que alguien lo busca.
- **El libro abierto** (una skill cargada): `load_skill` devuelve el cuerpo como resultado de herramienta (el señalador verde). Entra en el bandoneón como cualquier resultado, así que queda abierto sobre el pase por el resto de la ejecución.
- **El horno** (check_calendar): Una herramienta común. Es la receta la que le dice al modelo que la use.
- **La barra** (tamaño del pedido): Cuántos tokens lleva el próximo pedido. Su largo completo es lo que llevaría con los tres libros pegados en el system prompt.

En el panel EventBus, `load_skill` aparece como un `tool_execution_start` y un `tool_execution_end` comunes. El bucle no tiene idea de que existen las skills: para él, cargar un manual es una llamada a herramienta más.

## El código

**Con astorlm:** Apunta `skillSources` a una carpeta de skills. El valor por defecto `skillMode: 'on-demand'` pone el catálogo en el system prompt y agrega la herramienta `load_skill` por ti.

**Desde cero:** Lee cada `SKILL.md`, pon una línea por skill en el system prompt y agrega una herramienta `load_skill` que devuelva un cuerpo. El bucle del nivel 2 no cambia en absoluto.

**Con astorlm**

```ts
import { OpenAIProvider, createFileSystemSkillSource, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const checkCalendar = tool({
  name: 'check_calendar',
  description: 'Free baking slots and pickup times for a day, per production line.',
  schema: z.object({ day: z.string(), line: z.enum(['regular', 'nut-free']) }),
  execute: async ({ day, line }) => bakerySlots(day, line), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  tools: [checkCalendar],
  // Every folder in ./skills with a SKILL.md is one skill:
  //   skills/custom-cake-order/SKILL.md, skills/catering-quote/SKILL.md, …
  skillSources: [createFileSystemSkillSource({ dir: './skills' })],
  // The default. Only each skill's name and description go in the system prompt,
  // and the agent gets a load_skill tool to fetch a full body when it needs one.
  skillMode: 'on-demand',
  maxTurns: 10,
})

agent.on('tool-end', ({ name, output }) => {
  if (name === 'load_skill') console.log('loaded:', output.slice(0, 60))
})

const last = await agent.run('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.')
console.log(last.content)
```

**TypeScript**

```ts
// On-demand skills, from scratch. Plain fetch and node:fs, no SDK.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
  model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
  apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

// 1. Read the skills: one folder per skill, each with a SKILL.md.
//    The frontmatter (the block between the --- lines) holds name and description.
type Skill = { name: string; description: string; body: string }

function readSkill(file: string): Skill {
  const [, front = '', body = ''] = readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) ?? []
  const field = (key: string) => front.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? ''
  return { name: field('name'), description: field('description'), body: body.trim() }
}

const SKILLS_DIR = './skills'
const skills = new Map(
  readdirSync(SKILLS_DIR, { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => readSkill(join(SKILLS_DIR, entry.name, 'SKILL.md')))
    .map((skill) => [skill.name, skill]),
)

// 2. The catalog goes in the system prompt: one line per skill, never the body.
const catalog = [...skills.values()].map((s) => `- \`${s.name}\` — ${s.description}`).join('\n')
const SYSTEM = `You are the order assistant of a bakery.

<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
${catalog}
</available-skills>`

// 3. load_skill is a tool like any other. Its result is the body.
const loadSkill = ({ name }: { name: string }): string => {
  const skill = skills.get(name)
  if (!skill) throw new Error(`Unknown skill "${name}". Available: ${[...skills.keys()].join(', ')}`)
  return `<skill name="${skill.name}">\n${skill.body}\n</skill>`
}

type ToolFn = (args: Record<string, string>) => string | Promise<string>
const tools: Record<string, ToolFn> = {
  load_skill: (args) => loadSkill({ name: args.name ?? '' }),
  check_calendar: checkCalendar, // your code
}
const toolSchemas = [
  {
    type: 'function',
    function: {
      name: 'load_skill',
      description: "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
      parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
    },
  },
  /* check_calendar's schema */
]

// 4. The loop from level 2. Nothing in it knows about skills.
export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
  const messages: Message[] = [
    { role: 'system', content: SYSTEM },
    { role: 'user', content: prompt },
  ]

  for (let turn = 1; turn <= maxTurns; turn++) {
    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
    })
    const [choice] = (await res.json()).choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''

    for (const call of reply.tool_calls ?? []) {
      const run = tools[call.function.name]
      let output = `Unknown tool: ${call.function.name}`
      try {
        if (run) output = await run(JSON.parse(call.function.arguments))
      } catch (err) {
        output = `Error: ${err instanceof Error ? err.message : err}`
      }
      // A loaded body lands here, in the history, and every later turn resends it.
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

console.log(await runAgent('I need a cake for 20 people this Saturday. Chocolate, and one guest can’t have nuts.'))
```

**Python**

```python
# On-demand skills, from scratch. Standard library only, no SDK.
import json
import re
import urllib.request
from pathlib import Path

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

# 1. Read the skills: one folder per skill, each with a SKILL.md.
#    The frontmatter (the block between the --- lines) holds name and description.
def read_skill(path):
    match = re.match(r"^---\n(.*?)\n---\n?(.*)$", path.read_text(encoding="utf-8"), re.S)
    front, body = match.groups() if match else ("", "")

    def field(key):
        found = re.search(rf"^{key}:\s*(.+)$", front, re.M)
        return found.group(1).strip() if found else ""

    return {"name": field("name"), "description": field("description"), "body": body.strip()}

SKILLS = {
    skill["name"]: skill
    for skill in (read_skill(folder / "SKILL.md") for folder in Path("skills").iterdir() if folder.is_dir())
}

# 2. The catalog goes in the system prompt: one line per skill, never the body.
CATALOG = "\n".join(f"- `{s['name']}` — {s['description']}" for s in SKILLS.values())
SYSTEM = f"""You are the order assistant of a bakery.

<available-skills>
When the customer's request matches a skill, call load_skill with its name
before you answer, and follow the instructions it returns.
{CATALOG}
</available-skills>"""

# 3. load_skill is a tool like any other. Its result is the body.
def load_skill(name):
    if name not in SKILLS:
        raise ValueError(f'Unknown skill "{name}". Available: {", ".join(SKILLS)}')
    return f'<skill name="{name}">\n{SKILLS[name]["body"]}\n</skill>'

TOOLS = {"load_skill": load_skill, "check_calendar": check_calendar}  # check_calendar: your code
TOOL_SCHEMAS = [
    {
        "type": "function",
        "function": {
            "name": "load_skill",
            "description": "Load a skill's full instructions by name. Skills are listed in <available-skills>.",
            "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
        },
    },
    # check_calendar's schema
]

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS}).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)["choices"][0]

# 4. The loop from level 2. Nothing in it knows about skills.
def run_agent(prompt, max_turns=10):
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}]

    for _ in range(max_turns):
        choice = chat(messages)
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            try:
                output = TOOLS[name](**json.loads(call["function"]["arguments"]))
            except Exception as err:
                output = f"Error: {err}"
            # A loaded body lands here, in the history, and every later turn resends it.
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")

print(run_agent("I need a cake for 20 people this Saturday. Chocolate, and one guest can't have nuts."))
```

## Qué vigilar

- **La descripción es el disparador.** El modelo elige una skill solo por su descripción, igual que elige una herramienta (nivel 3). Di cuándo usarla, no qué contiene: “Úsala cuando un cliente quiere que le horneen un pastel” es mejor que “Información sobre pasteles”. En la animación, `allergen-info` también menciona alergias; solo las descripciones evitan que el modelo cargue el libro equivocado.
- **Los modelos chicos se saltan el paso.** Algunos responden directo sin cargar la skill que necesitaban. Dilo claramente en el system prompt (“carga la skill correspondiente antes de responder”), prueba el modo filesystem o, para una skill que todos los pedidos necesitan, déjala en `all`.
- **Una skill cargada queda cargada.** Su cuerpo está en el historial, así que cada turno posterior lo paga, y la compactación (nivel 7) puede truncarlo más adelante. Mantén los cuerpos cortos y divide las skills grandes en dos.
- **Las skills son instrucciones, así que son código.** Quien escribe un `SKILL.md` dirige a tu agente. No cargues skills desde carpetas o registros que no controlas (nivel 14).

## Patrones relacionados

- [0 · Tu caja de herramientas](https://harnesspatterns.dev/es/patterns/your-toolkit.md)
- [3 · Diseñar una herramienta](https://harnesspatterns.dev/es/patterns/designing-a-tool.md)
- [7 · La mochila se llena](https://harnesspatterns.dev/es/patterns/compaction.md)
- [9 · Memoria](https://harnesspatterns.dev/es/patterns/memory.md)
- [14 · Seguridad y sandboxing](https://harnesspatterns.dev/es/patterns/security.md)
