Power Your AI Apps with Functions
A Function by itself is a backend API. But when you connect it to Aerostack’s AI infrastructure — MCP servers, Skills, Bots, Agent Endpoints — it becomes the brain of an AI product.
This page shows how your custom Function logic powers every type of AI application on Aerostack.
The Big Picture
Section titled “The Big Picture”You write the Function. Aerostack routes it to AI agents, bots, schedules, and webhooks.
Why Functions Sit Between Data and AI
Section titled “Why Functions Sit Between Data and AI”In every AI app there is a gap between raw data and what the LLM actually needs. Data comes from a database, an API, a webhook, a user message — but it is never ready to send to an LLM as-is. You need to validate it, filter it, transform it, enrich it, or apply business rules first.
That processing layer is your Function. It runs at the edge, before data reaches the LLM orchestrator.
What happens without a Function
Section titled “What happens without a Function”The LLM gets raw, unprocessed data. It hallucinates on stale records, leaks PII it should not see, processes 10,000 rows when it only needed 10, and returns unstructured output that your app cannot parse.
What happens with a Function at the edge
Section titled “What happens with a Function at the edge”Your Function handles the logic the LLM cannot:
| Step | What Your Function Does | Why the LLM Can’t |
|---|---|---|
| Validate | Check permissions, verify ownership, enforce business rules | LLMs have no concept of authorization |
| Filter | Strip PII, remove stale records, limit result size | LLMs will happily process and leak everything |
| Transform | Reshape data, compute aggregates, join tables | LLMs are unreliable at math and data manipulation |
| Enrich | Add context from cache, cross-reference other tables | LLMs can only work with what you give them |
| Post-process | Parse LLM output, enforce schema, apply fallbacks | LLM output is unpredictable — your Function makes it safe |
Pattern: MCP Tool with Edge Processing
Section titled “Pattern: MCP Tool with Edge Processing”An MCP tool that fetches customer data from the database. Before the LLM sees the data, the Function validates access, strips PII, and formats the response.
interface Env { DB: Database CACHE: Cache AI: AI}
export default { async fetch(request: Request, env: Env): Promise<Response> { const { customerId, requestedBy, action } = await request.json<{ customerId: string requestedBy: string action: 'summary' | 'history' | 'risk-score' }>()
// 1. VALIDATE — check if the requester has access to this customer const access = await env.DB .prepare('SELECT role FROM team_members WHERE user_id = ? AND customer_id = ?') .bind(requestedBy, customerId) .first()
if (!access) { return Response.json({ error: 'No access to this customer' }, { status: 403 }) }
// 2. FETCH — get customer data from DB const customer = await env.DB .prepare('SELECT * FROM customers WHERE id = ?') .bind(customerId) .first()
const { results: orders } = await env.DB .prepare('SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT 20') .bind(customerId) .all()
// 3. FILTER — strip PII before sending to LLM const safeCustomer = { id: customer.id, name: customer.name, plan: customer.plan, signupDate: customer.created_at, // Intentionally omit: email, phone, address, payment_method }
// 4. TRANSFORM — compute aggregates the LLM would get wrong const totalSpend = orders.reduce((sum, o: any) => sum + o.total, 0) const avgOrderValue = orders.length > 0 ? totalSpend / orders.length : 0 const daysSinceLastOrder = orders.length > 0 ? Math.floor((Date.now() - new Date(orders[0].created_at as string).getTime()) / 86400000) : null
// 5. SEND TO LLM — only clean, pre-processed data const analysis = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: `Analyze this customer and provide a ${action}:Customer: ${JSON.stringify(safeCustomer)}Order stats: ${orders.length} orders, $${totalSpend.toFixed(2)} total, $${avgOrderValue.toFixed(2)} avg, ${daysSinceLastOrder} days since last orderRecent orders: ${JSON.stringify(orders.slice(0, 5).map((o: any) => ({ total: o.total, date: o.created_at, status: o.status })))}` }], max_tokens: 400 })
// 6. POST-PROCESS — structure the LLM output for the consumer return Response.json({ customerId, action, analysis: analysis.response, stats: { totalSpend, avgOrderValue, orderCount: orders.length, daysSinceLastOrder }, generatedAt: new Date().toISOString() }) }}The LLM never sees the customer’s email, phone, or payment info. It receives pre-computed stats instead of raw rows. And the response is wrapped in a structured JSON envelope — not raw LLM text.
Pattern: Bot with Pre-Processing and Post-Processing
Section titled “Pattern: Bot with Pre-Processing and Post-Processing”A Telegram bot that answers questions about inventory. The Function validates the question, queries the database, sends only relevant data to the LLM, and formats the response for the chat platform.
interface Env { DB: Database AI: AI CACHE: Cache}
export default { async fetch(request: Request, env: Env): Promise<Response> { const { message, userId } = await request.json<{ message: string; userId: string }>()
// PRE-PROCESS: classify intent before hitting the LLM const intent = classifyIntent(message)
if (intent === 'greeting') { // No LLM needed — save cost and latency return Response.json({ reply: 'Hi! Ask me about inventory, stock levels, or reorder status.' }) }
if (intent === 'unknown') { return Response.json({ reply: 'I can only help with inventory questions. Try: "What\'s the stock level for SKU-123?"' }) }
// PRE-PROCESS: extract structured data from the message const skuMatch = message.match(/SKU-?\d+/i) const categoryMatch = message.match(/\b(electronics|clothing|food|tools)\b/i)
// FETCH: query only what's relevant (don't dump entire inventory to LLM) let inventoryData if (skuMatch) { inventoryData = await env.DB .prepare('SELECT sku, name, quantity, reorder_point, last_restocked FROM inventory WHERE sku = ?') .bind(skuMatch[0].toUpperCase()) .first() } else if (categoryMatch) { const { results } = await env.DB .prepare('SELECT sku, name, quantity, reorder_point FROM inventory WHERE category = ? ORDER BY quantity ASC LIMIT 10') .bind(categoryMatch[1].toLowerCase()) .all() inventoryData = results } else { // Low stock items as default context const { results } = await env.DB .prepare('SELECT sku, name, quantity, reorder_point FROM inventory WHERE quantity <= reorder_point LIMIT 10') .all() inventoryData = results }
// SEND TO LLM: only the relevant, filtered data const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [ { role: 'system', content: 'You are an inventory assistant. Give concise answers about stock levels. Use the data provided — never guess quantities.' }, { role: 'user', content: `Question: ${message}\n\nInventory data:\n${JSON.stringify(inventoryData, null, 2)}` } ], max_tokens: 200 })
// POST-PROCESS: format for chat platform (Telegram has 4096 char limit) let reply = response.response || 'Sorry, I could not generate a response.' if (reply.length > 4000) { reply = reply.substring(0, 3997) + '...' }
return Response.json({ reply }) }}
function classifyIntent(message: string): 'inventory' | 'greeting' | 'unknown' { const lower = message.toLowerCase() if (/^(hi|hello|hey|sup)\b/.test(lower)) return 'greeting' if (/stock|inventory|sku|quantity|reorder|restock|supply|warehouse/.test(lower)) return 'inventory' return 'unknown'}What the Function does that the LLM cannot:
- Classifies intent without burning LLM tokens (simple regex = free, instant)
- Extracts SKU numbers and categories from natural language
- Queries only the relevant rows (not dumping 50,000 SKUs to context)
- Truncates output to fit platform limits
- Returns a structured JSON envelope, not raw text
Pattern: Skill with Data Validation
Section titled “Pattern: Skill with Data Validation”A scheduled Skill that monitors sales. The Function computes anomaly thresholds (math the LLM would get wrong), then asks the LLM only for the narrative analysis.
interface Env { DB: Database AI: AI QUEUE: Queue}
export default { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { // COMPUTE: aggregates and thresholds (LLMs are bad at math) const { results: todaySales } = await env.DB .prepare(`SELECT product_id, SUM(quantity) as units, SUM(total) as revenue FROM orders WHERE created_at > datetime('now', '-1 day') GROUP BY product_id`) .all()
const { results: avgSales } = await env.DB .prepare(`SELECT product_id, AVG(daily_units) as avg_units, AVG(daily_revenue) as avg_revenue FROM daily_sales_summary WHERE date > datetime('now', '-30 days') GROUP BY product_id`) .all()
// VALIDATE: find anomalies using math (not LLM guessing) const avgMap = new Map(avgSales.map((a: any) => [a.product_id, a])) const anomalies = todaySales.filter((t: any) => { const avg = avgMap.get(t.product_id) as any if (!avg) return true // New product = anomaly const revenueChange = ((t.revenue - avg.avg_revenue) / avg.avg_revenue) * 100 return Math.abs(revenueChange) > 30 // >30% deviation }).map((t: any) => { const avg = avgMap.get(t.product_id) as any return { productId: t.product_id, todayRevenue: t.revenue, avgRevenue: avg?.avg_revenue || 0, changePercent: avg ? (((t.revenue - avg.avg_revenue) / avg.avg_revenue) * 100).toFixed(1) : 'new', todayUnits: t.units, avgUnits: avg?.avg_units || 0 } })
if (anomalies.length === 0) return // Nothing to report
// LLM: only for narrative — the math is already done const analysis = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: `Write a 3-sentence Slack alert for these sales anomalies. Be specific about the numbers:\n${JSON.stringify(anomalies)}` }], max_tokens: 200 })
// SEND: structured alert to notification queue await env.QUEUE.send({ type: 'slack-alert', channel: '#sales-alerts', text: `*Sales Anomaly Alert* (${anomalies.length} products)\n\n${analysis.response}`, anomalies }) }}The rule of thumb: Your Function does the math, validation, filtering, and access control. The LLM does the natural language understanding and generation. Never ask the LLM to do what your code can do better.
Functions + MCP Servers
Section titled “Functions + MCP Servers”MCP (Model Context Protocol) lets AI agents call tools. When you write a Function and expose it as an MCP server, any AI agent — Claude, GPT, Cursor, Windsurf — can call your code.
Example: Product Search MCP
Section titled “Example: Product Search MCP”Your Function provides the search logic. The MCP server wraps it as a tool that AI agents can discover and call.
// src/index.ts — Your Functioninterface Env { DB: Database AI: AI VECTORIZE: VectorSearch}
export default { async fetch(request: Request, env: Env): Promise<Response> { const { query } = await request.json<{ query: string }>()
// 1. Embed the search query const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: query })
// 2. Vector search for similar products const results = await env.VECTORIZE.query(embedding.data[0], { topK: 10, returnMetadata: 'all', })
// 3. Enrich with DB data const productIds = results.matches.map(m => m.metadata?.productId) const products = await env.DB .prepare(`SELECT * FROM products WHERE id IN (${productIds.map(() => '?').join(',')})`) .bind(...productIds) .all()
return Response.json({ results: products.results, scores: results.matches.map(m => ({ id: m.metadata?.productId, score: m.score })) }) }}Once deployed, add this Function as an MCP tool in your Workspace:
- Go to your Workspace in the Admin dashboard
- Click Add Tool → Function
- Select your deployed Function
- Define the tool name and description for AI agents
Now any AI agent connected to your Workspace can search your product catalog:
User: "Find me wireless headphones under $50"Agent → calls your MCP tool → your Function queries vector DB → returns resultsAgent: "I found 3 wireless headphones under $50: ..."Functions + Skills
Section titled “Functions + Skills”Skills are automated workflows that run on schedules or respond to events. Your Function provides the logic that the Skill executes.
Example: Daily Sales Digest
Section titled “Example: Daily Sales Digest”A Skill that runs every morning, analyzes yesterday’s sales data using AI, and sends a summary to Slack.
// src/index.ts — Scheduled Functioninterface Env { DB: Database AI: AI CACHE: Cache}
export default { async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { // 1. Query yesterday's sales const { results: sales } = await env.DB.prepare(` SELECT p.name, COUNT(*) as units, SUM(o.total) as revenue FROM orders o JOIN products p ON o.product_id = p.id WHERE o.created_at > datetime('now', '-1 day') GROUP BY p.id ORDER BY revenue DESC `).all()
// 2. AI analysis const analysis = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: `Analyze yesterday's sales and give 3 key insights:\n${JSON.stringify(sales)}` }], max_tokens: 500 })
// 3. Send to Slack (via webhook) await fetch(env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `*Daily Sales Digest*\n\n${analysis.response}\n\nTotal: ${sales.length} products sold` }) }) },
async fetch(request: Request, env: Env): Promise<Response> { return Response.json({ status: 'ok', description: 'Daily sales digest skill' }) }}Configure the schedule in aerostack.toml:
[triggers]crons = ["0 9 * * *"] # Every day at 9 AM UTCDeploy as a Skill and it runs automatically — no manual triggers needed.
Functions + Bots
Section titled “Functions + Bots”Bots run on Discord, Telegram, Slack, and WhatsApp. Your Function provides the intelligence — querying databases, running AI analysis, and returning structured responses.
Example: Support Bot Intelligence Layer
Section titled “Example: Support Bot Intelligence Layer”Your Function acts as the backend brain for a customer support bot. The bot platform handles messaging; your Function handles logic.
// src/index.ts — Bot intelligence layerinterface Env { DB: Database AI: AI VECTORIZE: VectorSearch CACHE: Cache}
export default { async fetch(request: Request, env: Env): Promise<Response> { const { message, userId, platform } = await request.json<{ message: string userId: string platform: 'discord' | 'telegram' | 'slack' }>()
// 1. Check if user has an open ticket const ticket = await env.DB .prepare('SELECT * FROM tickets WHERE user_id = ? AND status = ?') .bind(userId, 'open') .first()
// 2. Search knowledge base const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: message }) const docs = await env.VECTORIZE.query(embedding.data[0], { topK: 3, returnMetadata: 'all' })
const context = docs.matches.map(m => m.metadata?.text).filter(Boolean).join('\n\n')
// 3. Generate response with context const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: [ { role: 'system', content: `You are a helpful support agent. Answer based on this context:\n${context}\n\nIf you can't answer confidently, say you'll escalate to a human.` }, { role: 'user', content: message } ], max_tokens: 300 })
// 4. Log the interaction await env.DB .prepare('INSERT INTO support_logs (user_id, platform, message, response, ticket_id) VALUES (?, ?, ?, ?, ?)') .bind(userId, platform, message, response.response, ticket?.id || null) .run()
return Response.json({ reply: response.response, sources: docs.matches.map(m => m.metadata?.title), hasOpenTicket: !!ticket }) }}This single Function powers the bot across all 4 platforms. The bot configuration in Aerostack routes messages to your Function regardless of whether the user is on Discord, Telegram, Slack, or WhatsApp.
Functions + Agent Endpoints
Section titled “Functions + Agent Endpoints”Agent Endpoints expose your Function as a REST API designed for AI agents. Agents can call your endpoint autonomously as part of larger workflows.
Example: Code Review Agent
Section titled “Example: Code Review Agent”An Agent Endpoint that receives a pull request diff and returns a code review.
// src/index.ts — Agent Endpointinterface Env { AI: AI DB: Database}
export default { async fetch(request: Request, env: Env): Promise<Response> { const { diff, language, context } = await request.json<{ diff: string language: string context?: string }>()
const review = await env.AI.run('@cf/meta/llama-3.1-70b-instruct', { messages: [ { role: 'system', content: `You are a senior ${language} code reviewer. Review this diff for bugs, security issues, performance problems, and style. Be specific and actionable.${context ? `\n\nProject context: ${context}` : ''}` }, { role: 'user', content: diff } ], max_tokens: 1000 })
// Log review for analytics await env.DB .prepare('INSERT INTO reviews (language, diff_size, review) VALUES (?, ?, ?)') .bind(language, diff.length, review.response) .run()
return Response.json({ review: review.response, timestamp: new Date().toISOString() }) }}AI agents can call this endpoint as part of a CI/CD pipeline — every PR gets an automatic code review powered by your custom logic.
Combining Everything: A Complete AI Product
Section titled “Combining Everything: A Complete AI Product”Here’s what a real AI product looks like on Aerostack — all powered by Functions:
One set of Functions. Multiple AI interfaces. One Workspace URL.
An AI agent connected to this Workspace can:
- Search your product catalog (MCP)
- Place orders on behalf of customers (MCP)
- Look up customer history (MCP)
- Get automated daily reports (Skill)
- Respond to customer questions on Discord (Bot)
All of this is powered by the Functions you write. The rest is routing, auth, and infrastructure — handled by Aerostack.