Skip to content

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.


Developer
Writes Function

Function
(Custom Logic)

MCP Server
AI agents call tools

Skill
Scheduled workflows

Bot
Chat platforms

Agent Endpoint
REST for AI

AI Agents
Claude · GPT · Cursor

Automation
Cron · Events · Triggers

Chat Platforms
Discord · Telegram · Slack

AI Applications
Custom clients

You write the Function. Aerostack routes it to AI agents, bots, schedules, and webhooks.


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.

Validated
Filtered
Enriched

Data Source
DB · API · Webhook · User

Your Function
(Edge Processing)

LLM
Orchestrator

Your Function
(Post-Processing)

Response
Bot · MCP · API

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.

Your Function handles the logic the LLM cannot:

StepWhat Your Function DoesWhy the LLM Can’t
ValidateCheck permissions, verify ownership, enforce business rulesLLMs have no concept of authorization
FilterStrip PII, remove stale records, limit result sizeLLMs will happily process and leak everything
TransformReshape data, compute aggregates, join tablesLLMs are unreliable at math and data manipulation
EnrichAdd context from cache, cross-reference other tablesLLMs can only work with what you give them
Post-processParse LLM output, enforce schema, apply fallbacksLLM output is unpredictable — your Function makes it safe

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 order
Recent 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

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.


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.

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 Function
interface 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:

  1. Go to your Workspace in the Admin dashboard
  2. Click Add ToolFunction
  3. Select your deployed Function
  4. 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 results
Agent: "I found 3 wireless headphones under $50: ..."

Skills are automated workflows that run on schedules or respond to events. Your Function provides the logic that the Skill executes.

A Skill that runs every morning, analyzes yesterday’s sales data using AI, and sends a summary to Slack.

// src/index.ts — Scheduled Function
interface 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 UTC

Deploy as a Skill and it runs automatically — no manual triggers needed.


Bots run on Discord, Telegram, Slack, and WhatsApp. Your Function provides the intelligence — querying databases, running AI analysis, and returning structured responses.

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 layer
interface 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.


Agent Endpoints expose your Function as a REST API designed for AI agents. Agents can call your endpoint autonomously as part of larger workflows.

An Agent Endpoint that receives a pull request diff and returns a code review.

// src/index.ts — Agent Endpoint
interface 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:

Your Workspace

Product Search
(MCP)

Order Management
(MCP)

Customer Data
(MCP)

Daily Digest
(Skill)

Anomaly Alert
(Skill)

Support Bot
(Discord)

Sales Bot
(Telegram)

Your Functions
(Custom Logic)

Database

AI

Vector Search

Cache

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.