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:
- The
webmiddleware starts an HTTP server on port 8080 when the main skill's pipeline runs. - It reads the
routes:array from the middleware config and builds a route table. - When
GET /arrives, it matcheshome(method: GET, path: /). - The web middleware invokes
homethrough the full pipeline — cascade, middleware chain, everything. The skill gets its own context with request data in args. - The skill returns a string → the web middleware sends it as
text/htmlwith 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 parametersbody— parsed request body (JSON or form-encoded)headers— HTTP request headerssessionId— auto-generated UUID from a_sidcookie (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
- The
websocketmiddleware creates a WebSocket server (reusing the HTTP server fromwebif both are configured). - Clients connect to
ws://localhost:8080/updates— the socket name becomes the URL path. - Each incoming message invokes the
updates-handlerskill with{ message, socket }as args. - 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.css → http://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
webmiddleware'sroutes:array maps HTTP methods and paths to skills. Thewebsocketmiddleware'sroutes: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
returnsschema. Thejson/htmlkeys control the response format. - The cascade shares config automatically. Route skills inherit
modelfrom the main skill without declaring it. Infrastructure keys likewebandwebsocketare kept private automatically — route skills don't need to see them. - URL patterns support
:paramsegments. Extracted intoargs.route. - Events connect skills without coupling.
event-emitpublishes, theeventmiddleware'sroutes:config subscribes. The REST API and WebSocket layer don't know about each other — events bridge them. - WebSocket reuses the HTTP server. When both
webandwebsocketare 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
_sidcookie with a UUID, available asargs.sessionId.
Next Steps
- Chat Agent Tutorial — Build a persistent conversational agent
- Automation Tutorial — Webhooks, subagents, events, scheduling
- Cookbook — Web — Web configuration details
- Skills Reference — Web — Full web skills reference