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
Section titled “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
Section titled “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. |
Prerequisites
Section titled “Prerequisites”- Aerostack CLI installed (
npm install -g @aerostack/cli) - An Aerostack account with an active project
- Node.js 18+
Example 1: Email Sender Skill
Section titled “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
Terminal window mkdir send-email-skill && cd send-email-skillnpm init -ynpm 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: stringDEFAULT_FROM_EMAIL: string}interface SendEmailInput {to: stringsubject: stringbody: stringfrom?: 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})}} -
Configure secrets
Terminal window # Set the Resend API key as a secret (not stored in code)aerostack secrets set RESEND_API_KEY re_xxxxxxxxxxxxxaerostack secrets set DEFAULT_FROM_EMAIL notifications@yourdomain.com
Example 2: PDF Generator Skill
Section titled “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
Terminal window mkdir generate-pdf-skill && cd generate-pdf-skillnpm init -ynpm 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: R2BucketPDF_SERVICE_URL: stringPDF_SERVICE_KEY: string}interface GeneratePdfInput {html: stringfilename?: stringtitle?: 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 serviceconst 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 Storageawait 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
Section titled “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
Terminal window mkdir enrich-company-skill && cd enrich-company-skillnpm init -ynpm 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:
Terminal window 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
Section titled “Skill Design Guidelines”One tool per skill
Section titled “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
Section titled “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
Section titled “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
Section titled “Return structured JSON”Always return JSON with clear success/error fields. LLMs parse the response to determine what happened.
// Good — structured, parseablereturn Response.json({ success: true, email_id: 'abc123', to: 'user@example.com' })
// Bad — unstructured textreturn new Response('Email sent!')Handle errors gracefully
Section titled “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
Section titled “Next Steps”- Deploy and test — deploy your skill and verify it works in a workspace
- Publish to Hub — share your skill with the community