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.jsonThe 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"]
}
}
}| 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 (
npm install -g aerostack) - 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.
Scaffold the project
mkdir send-email-skill && cd send-email-skill
npm init -y
npm install -D typescript @cloudflare/workers-typesCreate 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-typesCreate 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-typesCreate 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.sqlCreate 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
- Deploy and test — deploy your skill and verify it works in a workspace
- Publish to Hub — share your skill with the community