# Create a Skill

> Step-by-step guide to building a skill — file structure, configuration, and three complete examples.

This guide walks through the full process of building a skill from scratch. You will learn the file structure, the `aerostack.json` configuration, and see three complete, copy-paste ready examples.

## Skill Structure

Every skill follows the same minimal layout:

```
my-skill/
  src/
    index.ts          # Skill logic — standard Cloudflare Worker
  aerostack.json      # Skill metadata + tool definition
  aerostack.toml      # Cloudflare Worker config (for local dev)
  tsconfig.json       # TypeScript config
  package.json
```

The key file is `aerostack.json`. It tells Aerostack what your skill does, what input it accepts, and how to present it to LLM clients.

## aerostack.json Reference

```json
{
  "name": "my-skill-slug",
  "type": "skill",
  "description": "Human-readable description of what this skill does",
  "tool": {
    "name": "tool_function_name",
    "description": "Description shown to LLMs when deciding whether to call this tool",
    "input_schema": {
      "type": "object",
      "properties": {
        "param_name": {
          "type": "string",
          "description": "What this parameter is for"
        }
      },
      "required": ["param_name"]
    }
  }
}
```

| Field | Required | Description |
|---|---|---|
| `name` | Yes | URL-safe slug. Used as the skill identifier across the platform. |
| `type` | Yes | Must be `"skill"`. |
| `description` | Yes | Shown in the Admin Dashboard and Hub listing. |
| `tool.name` | Yes | The tool name exposed to LLMs. Use `snake_case`. Namespaced automatically as `{skill-slug}__{tool_name}` in workspaces. |
| `tool.description` | Yes | LLMs read this to decide when to call the tool. Be specific. |
| `tool.input_schema` | Yes | JSON Schema defining the tool's input parameters. |

  The `tool.description` is what LLMs use to decide whether to call your skill. Write it like you are explaining the tool to a colleague — specific, actionable, and honest about what it does and does not do.

## Prerequisites

- [Aerostack CLI installed](/cli) (`npm install -g @aerostack/cli`)
- An Aerostack account with an active project
- Node.js 18+

---

## Example 1: Email Sender Skill

A skill that sends transactional emails via Resend. Useful for confirmation emails, alerts, or notifications triggered by bot workflows.

1. **Scaffold the project**

   ```bash
   mkdir send-email-skill && cd send-email-skill
   npm init -y
   npm install -D typescript @cloudflare/workers-types
   ```

1. **Create aerostack.json**

   ```json
   {
     "name": "send-email",
     "type": "skill",
     "description": "Send a transactional email via Resend",
     "tool": {
       "name": "send_email",
       "description": "Sends an email to a recipient. Supports plain text and HTML body. Use this when a user or workflow needs to send an email notification, confirmation, or alert.",
       "input_schema": {
         "type": "object",
         "properties": {
           "to": {
             "type": "string",
             "description": "Recipient email address"
           },
           "subject": {
             "type": "string",
             "description": "Email subject line"
           },
           "body": {
             "type": "string",
             "description": "Email body content (plain text or HTML)"
           },
           "from": {
             "type": "string",
             "description": "Sender email address. Defaults to the configured sender if omitted."
           }
         },
         "required": ["to", "subject", "body"]
       }
     }
   }
   ```

1. **Create tsconfig.json**

   ```json
   {
     "compilerOptions": {
       "target": "ES2022",
       "module": "ES2022",
       "moduleResolution": "bundler",
       "lib": ["ES2022"],
       "types": ["@cloudflare/workers-types"],
       "strict": true,
       "outDir": "dist",
       "rootDir": "src"
     },
     "include": ["src"]
   }
   ```

1. **Create aerostack.toml**

   ```toml
   name = "send-email-skill"
   main = "src/index.ts"
   compatibility_date = "2024-12-01"
   ```

1. **Write the skill**

   Create `src/index.ts`:

   ```typescript
   interface Env {
     RESEND_API_KEY: string
     DEFAULT_FROM_EMAIL: string
   }

   interface SendEmailInput {
     to: string
     subject: string
     body: string
     from?: string
   }

   export default {
     async fetch(request: Request, env: Env): Promise {
       if (request.method !== 'POST') {
         return Response.json({ error: 'Method not allowed' }, { status: 405 })
       }

       const input = await request.json()

       if (!input.to || !input.subject || !input.body) {
         return Response.json(
           { error: 'to, subject, and body are required' },
           { status: 400 }
         )
       }

       const from = input.from || env.DEFAULT_FROM_EMAIL || 'noreply@example.com'

       const response = await fetch('https://api.resend.com/emails', {
         method: 'POST',
         headers: {
           'Authorization': `Bearer ${env.RESEND_API_KEY}`,
           'Content-Type': 'application/json'
         },
         body: JSON.stringify({
           from,
           to: input.to,
           subject: input.subject,
           html: input.body
         })
       })

       if (!response.ok) {
         const error = await response.text()
         return Response.json(
           { error: 'Failed to send email', details: error },
           { status: 502 }
         )
       }

       const result = await response.json<{ id: string }>()

       return Response.json({
         success: true,
         email_id: result.id,
         to: input.to,
         subject: input.subject
       })
     }
   }
   ```

1. **Configure secrets**

   ```bash
   # Set the Resend API key as a secret (not stored in code)
   aerostack secrets set RESEND_API_KEY re_xxxxxxxxxxxxx
   aerostack secrets set DEFAULT_FROM_EMAIL notifications@yourdomain.com
   ```

  Secrets are encrypted at rest and injected into `env` at runtime. Never hardcode API keys in your skill code.

---

## Example 2: PDF Generator Skill

A skill that generates a PDF from HTML content and stores it in Storage. Returns a download URL. Useful for invoices, reports, or certificates.

1. **Scaffold the project**

   ```bash
   mkdir generate-pdf-skill && cd generate-pdf-skill
   npm init -y
   npm install -D typescript @cloudflare/workers-types
   ```

1. **Create aerostack.json**

   ```json
   {
     "name": "generate-pdf",
     "type": "skill",
     "description": "Generate a PDF from HTML content and store it in cloud storage",
     "tool": {
       "name": "generate_pdf",
       "description": "Generates a PDF document from HTML content. Stores the PDF in cloud storage and returns a download URL. Use this for invoices, reports, certificates, or any document that needs to be rendered as PDF.",
       "input_schema": {
         "type": "object",
         "properties": {
           "html": {
             "type": "string",
             "description": "HTML content to render as PDF"
           },
           "filename": {
             "type": "string",
             "description": "Output filename (without extension). Defaults to a generated UUID."
           },
           "title": {
             "type": "string",
             "description": "Document title for metadata"
           }
         },
         "required": ["html"]
       }
     }
   }
   ```

1. **Write the skill**

   Create `src/index.ts`:

   ```typescript
   interface Env {
     STORAGE: R2Bucket
     PDF_SERVICE_URL: string
     PDF_SERVICE_KEY: string
   }

   interface GeneratePdfInput {
     html: string
     filename?: string
     title?: string
   }

   export default {
     async fetch(request: Request, env: Env): Promise {
       if (request.method !== 'POST') {
         return Response.json({ error: 'Method not allowed' }, { status: 405 })
       }

       const input = await request.json()

       if (!input.html) {
         return Response.json(
           { error: 'html is required' },
           { status: 400 }
         )
       }

       // Generate PDF using an external rendering service
       const pdfResponse = await fetch(env.PDF_SERVICE_URL, {
         method: 'POST',
         headers: {
           'Authorization': `Bearer ${env.PDF_SERVICE_KEY}`,
           'Content-Type': 'application/json'
         },
         body: JSON.stringify({
           html: input.html,
           options: {
             format: 'A4',
             margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' }
           }
         })
       })

       if (!pdfResponse.ok) {
         return Response.json(
           { error: 'PDF generation failed' },
           { status: 502 }
         )
       }

       const pdfBuffer = await pdfResponse.arrayBuffer()
       const filename = (input.filename || crypto.randomUUID()) + '.pdf'
       const storagePath = `pdfs/${filename}`

       // Store in Storage
       await env.STORAGE.put(storagePath, pdfBuffer, {
         httpMetadata: {
           contentType: 'application/pdf',
           contentDisposition: `attachment; filename="${filename}"`
         },
         customMetadata: {
           title: input.title || filename,
           generated_at: new Date().toISOString()
         }
       })

       return Response.json({
         success: true,
         filename,
         path: storagePath,
         size_bytes: pdfBuffer.byteLength
       })
     }
   }
   ```

1. **Create aerostack.toml**

   ```toml
   name = "generate-pdf-skill"
   main = "src/index.ts"
   compatibility_date = "2024-12-01"

   # Storage binding — provided by Aerostack when deployed.
   # For local dev, the CLI creates a local bucket automatically.
   [[r2_buckets]]
   binding = "STORAGE"
   bucket_name = "aerostack-storage"
   ```

---

## Example 3: Data Enrichment Skill

A skill that enriches a company record using AI and caches the result. Demonstrates using Database, AI, and Cache bindings together.

1. **Scaffold the project**

   ```bash
   mkdir enrich-company-skill && cd enrich-company-skill
   npm init -y
   npm install -D typescript @cloudflare/workers-types
   ```

1. **Create aerostack.json**

   ```json
   {
     "name": "enrich-company",
     "type": "skill",
     "description": "Enrich a company record with AI-generated insights using public data",
     "tool": {
       "name": "enrich_company",
       "description": "Takes a company name or domain and returns enriched data: industry, size estimate, description, and key products. Results are cached for 24 hours. Use this to augment CRM records or qualify leads.",
       "input_schema": {
         "type": "object",
         "properties": {
           "company": {
             "type": "string",
             "description": "Company name or domain (e.g. 'Cloudflare' or 'cloudflare.com')"
           },
           "fields": {
             "type": "array",
             "items": { "type": "string" },
             "description": "Specific fields to enrich. Options: industry, size, description, products, headquarters. Defaults to all."
           }
         },
         "required": ["company"]
       }
     }
   }
   ```

1. **Write the skill**

   Create `src/index.ts`:

```typescript
interface Env {
  DB: D1Database
  CACHE: KVNamespace
  AI: Ai
}

interface EnrichInput {
  company: string
  fields?: string[]
}

interface EnrichedCompany {
  company: string
  industry: string
  size: string
  description: string
  products: string[]
  headquarters: string
  enriched_at: string
}

const ALL_FIELDS = ['industry', 'size', 'description', 'products', 'headquarters']

  async fetch(request: Request, env: Env): Promise {
    if (request.method !== 'POST') {
      return Response.json({ error: 'Method not allowed' }, { status: 405 })
    }

    const input = await request.json()

    if (!input.company) {
      return Response.json(
        { error: 'company is required' },
        { status: 400 }
      )
    }

    const company = input.company.trim().toLowerCase()
    const fields = input.fields?.length ? input.fields : ALL_FIELDS
    const cacheKey = `enrich:${company}:${fields.sort().join(',')}`

    // 1. Check cache
    const cached = await env.CACHE.get(cacheKey, 'json')
    if (cached) {
      return Response.json({
        ...(cached as EnrichedCompany),
        source: 'cache'
      })
    }

    // 2. Check if we have a recent DB record (< 24h)
    const existing = await env.DB
      .prepare(
        `SELECT * FROM enriched_companies
         WHERE company = ? AND enriched_at > datetime('now', '-24 hours')`
      )
      .bind(company)
      .first()

    if (existing) {
      await env.CACHE.put(cacheKey, JSON.stringify(existing), {
        expirationTtl: 86400
      })
      return Response.json({ ...existing, source: 'database' })
    }

    // 3. Run AI enrichment
    const prompt = `You are a company data enrichment service. Given the company "${input.company}", provide the following fields as JSON: ${fields.join(', ')}.

Return ONLY valid JSON with these exact keys:
- industry (string): primary industry
- size (string): estimated employee count range (e.g. "1,001-5,000")
- description (string): one-paragraph company description
- products (array of strings): key products or services
- headquarters (string): city, country

If you cannot determine a field, use "Unknown".`

    const aiResponse = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
      messages: [{ role: 'user', content: prompt }]
    }) as { response: string }

    let enriched: EnrichedCompany
    try {
      const parsed = JSON.parse(aiResponse.response)
      enriched = {
        company: input.company,
        industry: parsed.industry || 'Unknown',
        size: parsed.size || 'Unknown',
        description: parsed.description || 'Unknown',
        products: parsed.products || [],
        headquarters: parsed.headquarters || 'Unknown',
        enriched_at: new Date().toISOString()
      }
    } catch {
      return Response.json(
        { error: 'AI response could not be parsed' },
        { status: 500 }
      )
    }

    // 4. Store in DB
    await env.DB
      .prepare(
        `INSERT OR REPLACE INTO enriched_companies
         (company, industry, size, description, products, headquarters, enriched_at)
         VALUES (?, ?, ?, ?, ?, ?, ?)`
      )
      .bind(
        company,
        enriched.industry,
        enriched.size,
        enriched.description,
        JSON.stringify(enriched.products),
        enriched.headquarters,
        enriched.enriched_at
      )
      .run()

    // 5. Cache for 24 hours
    await env.CACHE.put(cacheKey, JSON.stringify(enriched), {
      expirationTtl: 86400
    })

    return Response.json({ ...enriched, source: 'ai' })
  }
}
```

1. **Create the database table**

   Create `schema.sql`:

   ```sql
   CREATE TABLE IF NOT EXISTS enriched_companies (
     company TEXT PRIMARY KEY,
     industry TEXT,
     size TEXT,
     description TEXT,
     products TEXT,
     headquarters TEXT,
     enriched_at TEXT
   );
   ```

   Apply locally:

   ```bash
   aerostack db execute --local --file=schema.sql
   ```

1. **Create aerostack.toml**

   ```toml
   name = "enrich-company-skill"
   main = "src/index.ts"
   compatibility_date = "2024-12-01"

   [[d1_databases]]
   binding = "DB"
   database_name = "aerostack-core"
   database_id = "local"

   [kv_namespaces]
   binding = "CACHE"
   ```

---

## Skill Design Guidelines

### One tool per skill

A skill exposes exactly one tool. If you need multiple related tools, that is an MCP Server.

### Write clear tool descriptions

The `tool.description` in `aerostack.json` is read by LLMs to decide when to call your skill. Bad descriptions lead to misuse or the skill never being called.

**Bad:** `"Sends email"` -- too vague, LLM does not know when to use it.

**Good:** `"Sends a transactional email to a recipient. Supports HTML body. Use this when a user or workflow needs to send a notification, confirmation, or alert email."` -- specific, actionable, tells the LLM when to use it.

### Validate input aggressively

Skills are called by AI agents. The input may be malformed, missing fields, or contain unexpected types. Always validate before processing.

### Return structured JSON

Always return JSON with clear success/error fields. LLMs parse the response to determine what happened.

```typescript
// Good — structured, parseable
return Response.json({ success: true, email_id: 'abc123', to: 'user@example.com' })

// Bad — unstructured text
return new Response('Email sent!')
```

### Handle errors gracefully

Return appropriate HTTP status codes and error messages. A 502 for upstream failures, 400 for bad input, 500 for internal errors.

## Next Steps

- [**Deploy and test**](/skills/deploy-test) — deploy your skill and verify it works in a workspace
- [**Publish to Hub**](/skills/publish-to-hub) — share your skill with the community
