Skip to content

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.

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.

{
"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.

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

  1. Scaffold the project

    Terminal window
    mkdir send-email-skill && cd send-email-skill
    npm init -y
    npm install -D typescript @cloudflare/workers-types
  2. 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"]
    }
    }
    }
  3. Create tsconfig.json

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

    name = "send-email-skill"
    main = "src/index.ts"
    compatibility_date = "2024-12-01"
  5. 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 || '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
    })
    }
    }
  6. Configure secrets

    Terminal window
    # 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

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

    Terminal window
    mkdir generate-pdf-skill && cd generate-pdf-skill
    npm init -y
    npm install -D typescript @cloudflare/workers-types
  2. 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"]
    }
    }
    }
  3. 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
    })
    }
    }
  4. 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"

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

    Terminal window
    mkdir enrich-company-skill && cd enrich-company-skill
    npm init -y
    npm install -D typescript @cloudflare/workers-types
  2. 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"]
    }
    }
    }
  3. 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' })
}
}
  1. 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:

    Terminal window
    aerostack db execute --local --file=schema.sql
  2. 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"

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

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.

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

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: 'user@example.com' })
// Bad — unstructured text
return new Response('Email sent!')

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