Tutorial: Build a Web App

Build a web application with HTTP routes, a REST API, an AI-powered chat endpoint, and real-time WebSocket updates. By the end you'll understand how the web middleware dispatches requests to skills, how code and markdown skills serve different roles in a web context, and how events connect skills together.

What You'll Build

An item management app with:

  • A code skill that serves the homepage (HTML)
  • A REST API endpoint for managing items (JSON)
  • An AI-powered chat endpoint that answers questions about the app
  • A WebSocket connection for real-time updates when items change

Prerequisites

  • Node.js 22+
  • AWS credentials with Bedrock access — for the AI chat endpoint. See AWS & Bedrock Setup. The code routes (Steps 2–3) work without it.

Step 1: Create the Project

mkdir my-web-app && cd my-web-app
agent-apps init

Update main.skill.md:

---
name: my-web-app
metadata:
  role: main
  paths: [./skills]
  web:
    port: 8080
    routes:
      - method: GET
        path: /
        ref: home
      - method: "*"
        path: /api/items
        ref: api-items
      - method: POST
        path: /api/chat
        ref: chat
  websocket:
    routes:
      - socket: updates
        listen: true
        ref: updates-handler
  event:
    routes:
      - topic: updates
        ref: broadcast
  model:
    id: us.anthropic.claude-sonnet-4-6
    region: us-east-1
---

Step 2: A Code Route — The Homepage

Create skills/home.skill.js:

export const frontmatter = {
  name: 'home',
};

export default async function() {
  return `<!DOCTYPE html>
<html><head><title>Item Manager</title></head>
<body>
  <h1>Item Manager</h1>
  <div id="items"></div>
  <form id="add">
    <input name="name" placeholder="Name" required>
    <input name="price" type="number" placeholder="Price" required>
    <button>Add</button>
  </form>
  <script>
    async function load() {
      const items = await fetch('/api/items').then(r => r.json());
      document.getElementById('items').innerHTML = items
        .map(i => '<p>' + i.name + ' — $' + i.price + '</p>').join('');
    }
    load();
    document.getElementById('add').onsubmit = async e => {
      e.preventDefault();
      const f = new FormData(e.target);
      await fetch('/api/items', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ name: f.get('name'), price: +f.get('price') })
      });
      e.target.reset();
      load();
    };
  </script>
</body></html>`;
}

How Route Dispatch Works

When a request arrives at GET /, here's what happens:

  1. The web middleware starts an HTTP server on port 8080 when the main skill's pipeline runs.
  2. It reads the routes: array from the middleware config and builds a route table.
  3. When GET / arrives, it matches home (method: GET, path: /).
  4. The web middleware invokes home through the full pipeline — cascade, middleware chain, everything. The skill gets its own context with request data in args.
  5. The skill returns a string → the web middleware sends it as text/html with status 200.

If the skill returned an object instead, the web middleware would send it as application/json. This is the simple convention: strings are HTML, objects are JSON.

What Route Skills Receive

Every route skill gets structured args:

{
  "route": {},
  "query": {},
  "body": null,
  "headers": { "host": "localhost:8080", "accept": "text/html" },
  "method": "GET",
  "path": "/",
  "sessionId": "a1b2c3d4-..."
}
  • route — URL pattern parameters (e.g., { id: "42" } for /users/:id)
  • query — URL query string parameters
  • body — parsed request body (JSON or form-encoded)
  • headers — HTTP request headers
  • sessionId — auto-generated UUID from a _sid cookie (HttpOnly, SameSite=Lax)

Step 3: A REST API — Items CRUD

Create skills/api-items.skill.js:

const items = [];

export const frontmatter = {
  name: 'api-items',
  description: 'Manage items',
  metadata: {
    params: {
      type: 'object',
      properties: {
        method: { type: 'string' },
        body: { type: 'object' }
      }
    }
  }
};

export default async function(ctx, { method, body }) {
  if (method === 'GET') return items;
  if (method === 'POST') {
    const item = { id: items.length + 1, ...body };
    items.push(item);

    // Emit an event so other skills can react
    await ctx.manager.invoke('event-emit', {
      topic: 'updates',
      type: 'item-added',
      data: item
    });

    return { ok: true, item };
  }
  return { error: 'Method not supported' };
}

Test it:

agent-apps
# Serving at http://localhost:8080

In another terminal:

curl http://localhost:8080/api/items
# []

curl -X POST http://localhost:8080/api/items \
  -H "content-type: application/json" \
  -d '{"name": "Widget", "price": 9.99}'
# {"ok":true,"item":{"id":1,"name":"Widget","price":9.99}}

curl http://localhost:8080/api/items
# [{"id":1,"name":"Widget","price":9.99}]

Method Wildcards and URL Patterns

method: '*' in the route config matches any HTTP method. The skill reads args.method to distinguish GET from POST. You could also create separate routes for each method:

# In main skill web config
routes:
  - method: GET
    path: /api/items
    ref: list-items
  - method: POST
    path: /api/items
    ref: create-item

URL patterns support :param segments:

routes:
  - method: GET
    path: /api/items/:id
    ref: get-item

The :id value appears in args.route.id.

Events for Cross-Skill Communication

When an item is added, the skill emits an event to the updates topic. Any other skill can subscribe to this topic and react. This is how the WebSocket handler (Step 5) will push real-time updates to connected clients.

Events are fire-and-forget by default — the emitter doesn't wait for subscribers to finish. This keeps the API response fast.

Step 4: An AI Chat Endpoint

Create skills/chat.skill.md:

---
name: chat
description: Answer questions about the app
allowed-tools: skill-introspect
metadata:
  params:
    type: object
    properties:
      body: { type: object }
  returns:
    type: object
    properties:
      json:
        type: object
        properties:
          text: { type: string }
        required: [text]
    required: [json]
---

You are a helpful assistant embedded in a web app. Answer the user's question from body.question.

Use skill-introspect to look up anything about Agent Apps. Keep answers concise.

Test it:

curl -X POST http://localhost:8080/api/chat \
  -H "content-type: application/json" \
  -d '{"question": "What is a skill?"}'
# {"text":"A skill is a markdown or code file that the Agent Apps framework discovers and runs..."}

This takes a few seconds — the agent is reasoning and may call skill-introspect to look up framework docs.

How Markdown Routes Work

This is where code routes and markdown routes diverge:

Code routes run the function directly. Fast, deterministic, you control the output.

Markdown routes send the prompt to the AI agent. The agent interprets the prompt, uses tools, and returns a result. The web middleware automatically injects a returns schema so the agent knows to call ctx.manager.finish() with the HTTP response shape.

The returns schema here uses json — a special key that tells the web middleware to serialize the value as JSON. You can also use html for HTML responses, or status and headers for custom status codes and headers.

Notice that the chat skill doesn't declare web, websocket, or model in its metadata. It inherits model from the main skill through the cascade, and the web middleware dispatches to it based on the route — the skill doesn't need to know it's being served over HTTP.

Step 5: WebSocket — Real-Time Updates

Create skills/updates-handler.skill.js:

export const frontmatter = {
  name: 'updates-handler',
};

export default async function(ctx, { message }) {
  // Echo messages back to the client
  return `Echo: ${message}`;
}

The websocket config in main.skill.md declared a route with socket: updates and listen: true, which creates a WebSocket endpoint. This skill handles messages on that socket.

How WebSocket Dispatch Works

  1. The websocket middleware creates a WebSocket server (reusing the HTTP server from web if both are configured).
  2. Clients connect to ws://localhost:8080/updates — the socket name becomes the URL path.
  3. Each incoming message invokes the updates-handler skill with { message, socket } as args.
  4. The return value is sent back to the client.

You can also send messages programmatically from any skill:

await ctx.manager.invoke('websocket-send', { socket: 'updates', data: 'New item added!' });

This broadcasts to all connected clients on the updates socket.

Connecting Events to WebSocket

To push real-time updates when items are added, create a skill that subscribes to the updates event topic and broadcasts via WebSocket:

Create skills/broadcast.skill.js:

export const frontmatter = {
  name: 'broadcast',
};

export default async function(ctx, event) {
  await ctx.manager.invoke('websocket-send', {
    socket: 'updates',
    data: JSON.stringify({ type: event.type, data: event.data })
  });
}

Now when api-items emits an item-added event, broadcast picks it up and pushes it to all WebSocket clients. The event system connects the REST API to the WebSocket layer without either skill knowing about the other.

Step 6: Static Files

If your project has a public/ directory, static files are served automatically when no route matches:

mkdir public
echo '<h1>404 — Not Found</h1>' > public/404.html
echo 'body { font-family: sans-serif; }' > public/style.css

Files in public/ are served at their path: public/style.csshttp://localhost:8080/style.css.

Step 7: Run It

agent-apps

You'll see:

Serving at http://localhost:8080
WebSocket listening: updates

Visit http://localhost:8080 to see the item manager. Add items via the form. Open the browser console and connect to the WebSocket to see real-time updates:

const ws = new WebSocket('ws://localhost:8080/updates');
ws.onmessage = e => console.log('Update:', e.data);

What You Built

my-web-app/
  main.skill.md                   ← Main skill: web + websocket config
  skills/
    home.skill.js               ← GET / → HTML homepage
    api-items.skill.js          ← */api/items → JSON REST API
    chat.skill.md               ← POST /api/chat → AI-powered endpoint
    updates-handler.skill.js    ← WebSocket message handler
    broadcast.skill.js          ← Event → WebSocket bridge
  public/                       ← Static files (auto-served)

What You Learned

  • Routes are declared in the main skill's transport config. The web middleware's routes: array maps HTTP methods and paths to skills. The websocket middleware's routes: array maps socket names to skills.
  • Code skills return strings (HTML) or objects (JSON). The web middleware handles content-type and serialization.
  • Markdown skills work as API endpoints. The agent interprets the prompt and returns structured data via returns schema. The json/html keys control the response format.
  • The cascade shares config automatically. Route skills inherit model from the main skill without declaring it. Infrastructure keys like web and websocket are kept private automatically — route skills don't need to see them.
  • URL patterns support :param segments. Extracted into args.route.
  • Events connect skills without coupling. event-emit publishes, the event middleware's routes: config subscribes. The REST API and WebSocket layer don't know about each other — events bridge them.
  • WebSocket reuses the HTTP server. When both web and websocket are configured, they share a port.
  • Static files are automatic. Put files in public/, they're served when no route matches.
  • Session cookies are automatic. Every request gets a _sid cookie with a UUID, available as args.sessionId.

Next Steps

Ask AI