SkillsCreate a Skill

Create a Skill

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

{
  "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"]
    }
  }
}
FieldRequiredDescription
nameYesURL-safe slug. Used as the skill identifier across the platform.
typeYesMust be "skill".
descriptionYesShown in the Admin Dashboard and Hub listing.
tool.nameYesThe tool name exposed to LLMs. Use snake_case. Namespaced automatically as {skill-slug}__{tool_name} in workspaces.
tool.descriptionYesLLMs read this to decide when to call the tool. Be specific.
tool.input_schemaYesJSON 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


Example 1: Email Sender Skill

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

Scaffold the project

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

Create aerostack.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"]
    }
  }
}

Create tsconfig.json

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

Create aerostack.toml

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

Write the skill

Create src/index.ts:

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<Response> {
    if (request.method !== 'POST') {
      return Response.json({ error: 'Method not allowed' }, { status: 405 })
    }
 
    const input = await request.json<SendEmailInput>()
 
    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 || '[email protected]'
 
    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
    })
  }
}

Configure secrets

# 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 [email protected]

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.

Scaffold the project

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

Create aerostack.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"]
    }
  }
}

Write the skill

Create src/index.ts:

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<Response> {
    if (request.method !== 'POST') {
      return Response.json({ error: 'Method not allowed' }, { status: 405 })
    }
 
    const input = await request.json<GeneratePdfInput>()
 
    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
    })
  }
}

Create aerostack.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.

Scaffold the project

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

Create aerostack.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"]
    }
  }
}

Write the skill

Create src/index.ts:

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']
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') {
      return Response.json({ error: 'Method not allowed' }, { status: 405 })
    }
 
    const input = await request.json<EnrichInput>()
 
    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<EnrichedCompany>()
 
    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' })
  }
}

Create the database table

Create schema.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:

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

Create aerostack.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.

// Good — structured, parseable
return Response.json({ success: true, email_id: 'abc123', to: '[email protected]' })
 
// 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