# Power Your AI Apps with Functions

> How Aerostack Functions connect to MCP servers, Skills, Bots, and Agent Endpoints to build complete AI products.

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

```mermaid
flowchart LR
    DEV["Developer\nWrites Function"]
    DEV --> F["Function\n(Custom Logic)"]

    F --> MCP["MCP Server\nAI agents call tools"]
    F --> SK["Skill\nScheduled workflows"]
    F --> BOT["Bot\nChat platforms"]
    F --> AE["Agent Endpoint\nREST for AI"]
    MCP --> AGENT["AI Agents\nClaude · GPT · Cursor"]
    SK --> AUTO["Automation\nCron · Events · Triggers"]
    BOT --> CHAT["Chat Platforms\nDiscord · Telegram · Slack"]
    AE --> APP["AI Applications\nCustom clients"]

    style DEV fill:#10b981,stroke:#059669,color:#fff
    style F fill:#10b981,stroke:#059669,color:#fff
    style AGENT fill:#3b82f6,stroke:#2563eb,color:#fff
    style AUTO fill:#3b82f6,stroke:#2563eb,color:#fff
    style CHAT fill:#3b82f6,stroke:#2563eb,color:#fff
    style APP fill:#3b82f6,stroke:#2563eb,color:#fff
```

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

---

## 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.

```mermaid
flowchart LR
    SRC["Data Source\nDB · API · Webhook · User"]
    SRC --> FN["Your Function\n(Edge Processing)"]
    FN --> |"Validated\nFiltered\nEnriched"| LLM["LLM\nOrchestrator"]
    LLM --> FN2["Your Function\n(Post-Processing)"]
    FN2 --> OUT["Response\nBot · MCP · API"]

    style SRC fill:#1e293b,stroke:#6b7280,color:#fff
    style FN fill:#10b981,stroke:#059669,color:#fff
    style LLM fill:#3b82f6,stroke:#2563eb,color:#fff
    style FN2 fill:#10b981,stroke:#059669,color:#fff
    style OUT fill:#1e293b,stroke:#6b7280,color:#fff
```

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

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

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.

```typescript
interface Env {
  DB: Database
  CACHE: Cache
  AI: AI
}

  async fetch(request: Request, env: Env): Promise {
    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

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.

```typescript
interface Env {
  DB: Database
  AI: AI
  CACHE: Cache
}

  async fetch(request: Request, env: Env): Promise {
    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

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.

```typescript
interface Env {
  DB: Database
  AI: AI
  QUEUE: Queue
}

  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

[MCP (Model Context Protocol)](/mcp) 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

Your Function provides the search logic. The MCP server wraps it as a tool that AI agents can discover and call.

```typescript
// src/index.ts — Your Function
interface Env {
  DB: Database
  AI: AI
  VECTORIZE: VectorSearch
}

  async fetch(request: Request, env: Env): Promise {
    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](/workspaces):

1. Go to your Workspace in the Admin dashboard
2. Click **Add Tool** → **Function**
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: ..."
```

---

## Functions + Skills

[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

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

```typescript
// src/index.ts — Scheduled Function
interface Env {
  DB: Database
  AI: AI
  CACHE: Cache
}

  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 {
    return Response.json({ status: 'ok', description: 'Daily sales digest skill' })
  }
}
```

Configure the schedule in `aerostack.toml`:

```toml
[triggers]
crons = ["0 9 * * *"]  # Every day at 9 AM UTC
```

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

---

## Functions + Bots

[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

Your Function acts as the backend brain for a customer support bot. The bot platform handles messaging; your Function handles logic.

```typescript
// src/index.ts — Bot intelligence layer
interface Env {
  DB: Database
  AI: AI
  VECTORIZE: VectorSearch
  CACHE: Cache
}

  async fetch(request: Request, env: Env): Promise {
    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

[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

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

```typescript
// src/index.ts — Agent Endpoint
interface Env {
  AI: AI
  DB: Database
}

  async fetch(request: Request, env: Env): Promise {
    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

Here's what a real AI product looks like on Aerostack — all powered by Functions:

```mermaid
flowchart TD
    subgraph "Your Workspace"
        MCP1["Product Search\n(MCP)"]
        MCP2["Order Management\n(MCP)"]
        MCP3["Customer Data\n(MCP)"]
        SK1["Daily Digest\n(Skill)"]
        SK2["Anomaly Alert\n(Skill)"]
        BOT1["Support Bot\n(Discord)"]
        BOT2["Sales Bot\n(Telegram)"]
    end

    MCP1 --> FN["Your Functions\n(Custom Logic)"]
    MCP2 --> FN
    MCP3 --> FN
    SK1 --> FN
    SK2 --> FN
    BOT1 --> FN
    BOT2 --> FN

    FN --> DB["Database"]
    FN --> AI["AI"]
    FN --> VS["Vector Search"]
    FN --> CA["Cache"]

    style FN fill:#10b981,stroke:#059669,color:#fff
    style DB fill:#1e293b,stroke:#6b7280,color:#fff
    style AI fill:#1e293b,stroke:#6b7280,color:#fff
    style VS fill:#1e293b,stroke:#6b7280,color:#fff
    style CA fill:#1e293b,stroke:#6b7280,color:#fff
```

**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.

---
