HTTP Serving
HTTP serving through the mesh server: public files, shared workspace globs, and lambda-backed API routes
On this page
ADF agents can serve content over HTTP through the mesh server. When the mesh is enabled, each agent with a handle gets a URL at http://{host}:{port}/agents/{handle}/ where it can serve static files, expose shared workspace files, and run API endpoints backed by sandboxed JavaScript lambdas.
Overview
The mesh server (Fastify on port 7295 by default) mounts every servable agent at /agents/{handle}/. Three serving modes can be combined:
| Mode | Purpose | Configuration |
|---|---|---|
| Public | Serve static files from public/ folder | serving.public |
| Shared | Expose workspace files matching glob patterns | serving.shared |
| API | Run JavaScript lambda functions on HTTP requests | serving.api |
Request resolution order: API routes → public files → shared files → 404. The inbox, card, and health segments are reserved for the protocol mailboxes (the message receive endpoint is POST /agents/{handle}/inbox).
Delivering a web app to a user (for agents)
If you build something a human is meant to open — a webpage, game, app, dashboard, or demo — treat making it reachable as part of the task, not an afterthought. Don’t stop at writing files and don’t wait to be asked for the link. The expected flow:
- Build it into
public/withpublic/index.htmlas the entry point. - Enable serving — turn on
serving.publicwithsys_update_configif it isn’t already on. - Get the real URL — call
sys_get_config({ section: "card" })to read your live endpoints; the page-serving root is the card base with the mailbox segment removed (e.g..../agents/my-app/inbox→.../agents/my-app/, see Agent Handle). Don’t guess the handle or port. - Hand it over — give the user the exact link and one plain-language line on how to open it (“Open this in your browser: …”).
Assume the user is non-technical: they shouldn’t need to know about config, routes, or URLs to use what you built. Surface the outcome (the link + how to use it), not the internal setup — mention config changes only when they matter to the user. Skip all of this for private files, notes, or draft content that isn’t meant to be run.
Prerequisites
- Mesh enabled — Toggle mesh on in Settings > Web or the sidebar (this and the port/LAN server configuration are owner-only — not agent-writable; ask your principal)
- Agent running — The agent must be started (foreground or background)
- Handle set — The agent needs a URL handle (defaults to the filename if not set)
Agent Handle
The handle is the URL slug that identifies your agent on the mesh. Configure it in Agent Config > Serving > Handle, or via the agent-writable sys_update_config path handle — HIL-gated (your principal approves).
- Defaults to the
.adffilename (lowercased, sanitized) - Must be URL-safe: lowercase letters, numbers, and hyphens
- Must be unique across all agents on the mesh
- Example: handle
my-app→ URLhttp://127.0.0.1:7295/agents/my-app/
Knowing your own URL
The host defaults to localhost (127.0.0.1) and the port to 7295. The server only binds to the LAN (0.0.0.0) when an agent’s messaging.visibility is lan/public (or meshLan is set in settings) — so share the localhost URL unless the user has explicitly asked for LAN access. An agent can read its exact live endpoints with sys_get_config({ section: "card" }); a mailbox endpoint (e.g. http://127.0.0.1:7295/agents/my-app/inbox) maps to the page-serving root by dropping the mailbox segment → http://127.0.0.1:7295/agents/my-app/.
Public Folder
When serving.public is enabled, files in the public/ directory of the agent’s workspace are served as static content.
Configuration
{
"serving": {
"public": {
"enabled": true,
"index": "index.html"
}
}
}
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | — | Enable/disable public folder serving |
index | string | "index.html" | Default file served at the root URL |
URL Mapping
| Workspace File | URL |
|---|---|
public/index.html | GET /agents/{handle}/ |
public/style.css | GET /agents/{handle}/style.css |
public/js/app.js | GET /agents/{handle}/js/app.js |
public/images/logo.png | GET /agents/{handle}/images/logo.png |
Supported MIME Types
The server automatically sets Content-Type based on file extension:
| Extension | MIME Type |
|---|---|
.html, .htm | text/html |
.css | text/css |
.js | application/javascript |
.json | application/json |
.png | image/png |
.jpg, .jpeg | image/jpeg |
.svg | image/svg+xml |
.pdf | application/pdf |
.woff2 | font/woff2 |
Unknown extensions default to application/octet-stream.
Shared Files
When serving.shared is enabled, workspace files matching configured glob patterns are served over HTTP. This is useful for exposing generated reports, data exports, or other artifacts without putting them in public/.
Configuration
{
"serving": {
"shared": {
"enabled": true,
"patterns": ["output/*.json", "reports/*.html", "data/*.csv"]
}
}
}
| Field | Type | Description |
|---|---|---|
enabled | boolean | Enable/disable shared file serving (toggle preserves patterns) |
patterns | string[] | Glob patterns matching files to expose |
URL Mapping
Shared files are served at their workspace path relative to the agent’s root:
| Pattern | Workspace File | URL |
|---|---|---|
output/*.json | output/data.json | GET /agents/{handle}/output/data.json |
reports/*.html | reports/weekly.html | GET /agents/{handle}/reports/weekly.html |
Restrictions
- Patterns must not start with a reserved segment (
inbox,card,health) - Files are matched using picomatch glob syntax
- Disabling shared serving preserves your patterns — re-enabling restores them
API Routes
API routes map HTTP methods and URL paths to JavaScript/TypeScript lambda functions that run in the sandbox environment with full access to the adf proxy object.
Configuration
{
"serving": {
"api": [
{ "method": "GET", "path": "/status", "lambda": "lib/api.ts:getStatus" },
{ "method": "POST", "path": "/webhook", "lambda": "lib/api.ts:handleWebhook" },
{ "method": "GET", "path": "/users/:id", "lambda": "lib/api.ts:getUser", "warm": true }
]
}
}
| Field | Type | Description |
|---|---|---|
method | string | HTTP method: GET, POST, PUT, PATCH, or DELETE |
path | string | URL path with optional :param placeholders and * wildcard |
lambda | string | File and function reference: "file.ts:functionName" |
warm | boolean | Keep sandbox alive between requests (default: false) |
on_card | boolean | List this route in the agent card’s api_routes (default: false) |
middleware | MiddlewareRef[] | Optional middleware chain executed before the route lambda |
Path Matching
- Paths are matched relative to
/agents/{handle}/ :paramplaceholders extract URL segments:/users/:idmatches/users/123withparams.id = "123"*wildcard captures the remaining path:/agents/:handle/*matches/agents/:handle/any/sub/pathwithparams['*'] = "any/sub/path"- The segments
inbox,card, andhealthare reserved and cannot be used - Path must start with
/
Lambda Functions
Lambda functions receive an HttpRequest object and must return an HttpResponse object.
HttpRequest
interface HttpRequest {
method: string // "GET", "POST", etc.
path: string // The matched path (e.g., "/users/123")
params: Record<string, string> // URL params from :placeholders
query: Record<string, string> // Query string parameters
headers: Record<string, string> // Request headers
body: unknown // Parsed request body (JSON or raw)
}
HttpResponse
interface HttpResponse {
status: number // HTTP status code (200, 404, 500, etc.)
headers?: Record<string, string> // Optional response headers
body: unknown // Response body (object, string, etc.)
}
Example Lambda
// lib/api.ts
async function getStatus(request) {
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: { ok: true, time: Date.now() }
}
}
async function getUser(request) {
const userId = request.params.id
const data = await adf.fs_read({ path: `data/users/${userId}.json` })
if (!data) {
return { status: 404, body: { error: 'User not found' } }
}
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.parse(data)
}
}
async function handleWebhook(request) {
const payload = request.body
await adf.fs_write({
path: `webhooks/${Date.now()}.json`,
content: JSON.stringify(payload, null, 2)
})
return { status: 200, body: { received: true } }
}
Critical: adf API Rules
Lambda functions have access to the adf proxy object for calling agent tools. Follow these rules:
-
Single object argument — Every
adf.*call takes ONE object:adf.fs_read({ path: "file.md" }), notadf.fs_read("file.md")oradf.fs_read("file.md", { encoding: "base64" }). Multiple arguments cause a validation error. -
Always async/await —
adf.*calls are asynchronous. Functions that use them must beasyncand mustawaitevery call. Withoutawait, calls fire-and-forget and errors are silently lost. -
Tool names match — Use the same tool names as the built-in tools:
adf.fs_read(),adf.fs_write(),adf.db_query(),adf.db_execute(),adf.msg_send(), etc.
// CORRECT
async function handler(request) {
const data = await adf.fs_read({ path: 'data/config.json' })
await adf.fs_write({ path: 'logs/access.log', content: 'accessed\n' })
return { status: 200, body: JSON.parse(data) }
}
// WRONG — will fail silently or crash
function handler(request) {
const data = adf.fs_read('data/config.json') // wrong: string arg, not awaited
adf.fs_write('logs/access.log', 'accessed\n') // wrong: two args, not awaited
return { status: 200, body: data } // data is a Promise, not content!
}
Console Output
console.log(), console.warn(), console.error(), and console.info() output from lambda functions is captured and logged to adf_logs with the API response entry. View these in the Bottom Panel > Logs tab.
Warm Mode
When warm: true is set on a route, the sandbox worker is kept alive between requests instead of being destroyed after each execution. This reduces startup overhead for frequently-called endpoints. Use for:
- High-traffic endpoints
- Routes that benefit from cached state between calls
The sandbox ID for API routes is {agentId}:api, shared across all warm routes for the same agent.
Serving from a Frontend
When serving an HTML page from public/, API requests should use relative paths since the page and API share the same base URL:
<!-- public/index.html served at /agents/{handle}/ -->
<script>
// Relative fetch — automatically resolves to /agents/{handle}/api/data
const res = await fetch('api/data')
const data = await res.json()
</script>
This works because the browser resolves relative URLs from the page’s base URL (/agents/{handle}/).
HTTP/1.1 Connection Limits
The mesh server uses HTTP/1.1. Browsers enforce a 6 TCP connection per origin limit, and all served webapps share the same origin (http://{host}:{port}). Unconsumed fetch() response bodies hold their connection slot until garbage collection, and with polling or multiple tabs open to the same app, this quickly exhausts the pool — causing requests to stall for the entire polling interval.
Rules:
-
Always consume the response body — call
.json(),.text(), or.body.cancel()on everyfetch()response, including fire-and-forget or warmup calls:// BAD — response body not consumed, connection leaked fetch('api/warmup').then(() => { startPolling() }) // GOOD — body consumed, connection released immediately fetch('api/warmup').then(r => r.json()).then(() => { startPolling() }) -
Avoid concurrent polling from multiple tabs — if users may have the same app open in multiple tabs, each tab’s polling competes for the shared 6-connection pool. Consider coordinating via
BroadcastChannelorlocalStorageevents so only one tab polls at a time.
Managing Serving via sys_update_config
Agents can manage their own serving configuration at runtime using sys_update_config — these writes are HIL-gated (your principal approves):
Toggle Public/Shared
// Enable public folder
{ "path": "serving.public.enabled", "value": true }
// Set index file
{ "path": "serving.public.index", "value": "app.html" }
// Enable shared serving
{ "path": "serving.shared.enabled", "value": true }
// Set shared patterns
{ "path": "serving.shared.patterns", "value": ["output/*.json"] }
API Route Management
Use the path-based API for route CRUD:
// Add a route
{ "path": "serving.api", "action": "append", "value": { "method": "GET", "path": "/status", "lambda": "lib/api.ts:getStatus" } }
// Remove route at index 1
{ "path": "serving.api", "action": "remove", "index": 1 }
// Replace all routes
{ "path": "serving.api", "value": [
{ "method": "GET", "path": "/status", "lambda": "lib/api.ts:status" },
{ "method": "POST", "path": "/data", "lambda": "lib/api.ts:postData" }
] }
// Update a field on an existing route
{ "path": "serving.api.0.warm", "value": true }
Route validation rules:
- Path must start with
/ - Path must not start with a reserved segment (
/inbox,/card,/health) - Lambda must use
file:functionNameformat - Method must be one of: GET, POST, PUT, PATCH, DELETE
UI Configuration
Settings > Web Tab
The Web tab in Settings shows:
- Server status — Running/stopped indicator with host and port
- Mesh toggle — Enable/disable the mesh
- LAN access toggle — Bind to
0.0.0.0instead of127.0.0.1for local network access - Agent endpoints — Table of all agents currently serving, with handles, URLs, and serving mode badges
Agent Config > Serving Section
The Serving section in Agent Config provides UI for:
- Handle — Text input for the URL slug
- Public serving — Toggle + index file name
- Shared files — Toggle + glob patterns textarea (one per line)
- API routes — Array editor with method dropdown (GET, POST, PUT, PATCH, DELETE, WS), path input, lambda reference, warm toggle, an “Include on Agent Card” checkbox, and remove button. Selecting
WSas the method hides warm/cache/middleware options (the card checkbox remains) and shows a hint that lambda is required. - URL preview — Clickable link to the agent’s mesh URL (when server is running)
- WebSocket Connections — See WebSocket Connections > UI Configuration for outbound connection management
Server Configuration
Port
Default: 7295. Override via:
- Environment variable:
MESH_PORT=8080 - Settings:
meshPortsetting
Host / LAN Access
Default: 127.0.0.1 (localhost only). To allow access from other devices on your local network:
- Environment variable:
MESH_HOST=0.0.0.0 - Settings: Toggle Allow LAN access in the Web tab (sets host to
0.0.0.0)
When LAN access is enabled, other devices can reach the server at http://{your-ip}:{port}/agents/{handle}/.
Server Endpoints
| Endpoint | Description |
|---|---|
GET /health | Server health check (uptime, agent count, port) — runtime |
GET /ping | Runtime identity probe (runtime_id, proto) used by peer discovery — runtime |
GET /agents | All agent cards this runtime serves, filtered by the requester’s visibility scope |
GET /agents/:handle/card | Signed agent card (handle, description, DID, endpoints, policies, attestations, signature) — reserved protocol mailbox |
GET /agents/:handle/health | Agent health status — reserved protocol mailbox |
POST /agents/:handle/inbox | ALF message delivery — reserved protocol mailbox |
ALL /agents/:handle/* | Agent request resolution (serving.api routes incl. path-matched WebSocket upgrades, public, shared) |
Agents live under the /agents prefix so the runtime root stays free: no handle can shadow a runtime route like /health or /ping, and no future runtime route can steal a handle. /agents is itself the collection — a GET returns the directory, so the listing needs no separate path.
The inbox, card, and health segments are reserved for the protocol mailboxes above and cannot be claimed by serving.api routes or public/ files. That reservation applies within a handle, since agent serving routes share that space. Everything else under /agents/:handle/ — including WebSocket routes, which are ordinary serving.api entries (method WS) matched on their own path — is agent-controlled.
Agent Card
Each servable agent exposes a signed card at GET /agents/{handle}/card:
{
"did": "did:key:z6Mk...",
"handle": "my-app",
"description": "A web application agent",
"icon": "🌐",
"public_key": "z6Mk...",
"resolution": {
"method": "self",
"endpoint": "http://127.0.0.1:7295/agents/my-app/card"
},
"endpoints": {
"inbox": "http://127.0.0.1:7295/agents/my-app/inbox",
"card": "http://127.0.0.1:7295/agents/my-app/card",
"health": "http://127.0.0.1:7295/agents/my-app/health"
},
"api_routes": [
{ "method": "GET", "path": "/status" },
{ "method": "POST", "path": "/data" }
],
"public": true,
"shared": ["output/data.json", "output/report.json"],
"policies": [
{ "type": "signing", "standard": "ed25519", "send": "required", "receive": "required" }
],
"attestations": [],
"signed_at": "2026-03-07T12:00:00Z",
"signature": "ed25519:..."
}
The shared field lists resolved file paths (not glob patterns) — the runtime matches the configured glob patterns against the workspace file list.
The api_routes field lists only the serving.api routes with on_card: true; routes are off the card by default, so the array is empty until a route opts in.
The endpoints.inbox URL is the delivery address for this agent. Other agents can use this as the address parameter when sending messages via msg_send. The card is signed with Ed25519 on every build — verifiers check the signature against public_key.
The attestations array is empty by default. When the agent opts in via card.publish_attestations (Config → Security → Publish owner attestation), it carries owner/operator delegation certificates so peers can verify who owns the agent, and the card advertises an owner_attestation policy. The attestations are inside the signed scope of the card. See Security and Identity → Attestations.
Agents can override auto-derived endpoints and resolution via config (card.endpoints, card.resolution) — useful when deployed behind a relay or public domain. Agents can also retrieve their own card via sys_get_config with section: "card".
Logging
API requests and responses are logged to adf_logs:
| Event | Description |
|---|---|
api_request | Incoming request — method, path, params, query |
api_response | Response — status code, duration, console output (in metadata) |
Lambda console.log output is bundled into the api_response log entry’s metadata (stdout field) rather than as a separate log line.
sys_lambda executions are also logged:
| Event | Description |
|---|---|
execute | Function call start — function name and args |
result | Function call result — success/error, duration, stdout |
View logs in the Bottom Panel > Logs tab with auto-refresh enabled.
Example: Full Serving Agent
Here’s a complete example of an agent that serves a web dashboard:
agent.json (relevant fields)
{
"serving": {
"public": { "enabled": true, "index": "index.html" },
"shared": { "enabled": true, "patterns": ["data/*.json"] },
"api": [
{ "method": "GET", "path": "/api/stats", "lambda": "lib/api.ts:getStats" },
{ "method": "GET", "path": "/api/agents", "lambda": "lib/api.ts:getAgents" }
]
}
}
public/index.html
<!DOCTYPE html>
<html>
<head><title>Dashboard</title></head>
<body>
<h1>Agent Dashboard</h1>
<div id="stats"></div>
<script>
async function loadStats() {
const res = await fetch('api/stats')
const stats = await res.json()
document.getElementById('stats').textContent = JSON.stringify(stats, null, 2)
}
loadStats()
</script>
</body>
</html>
lib/api.ts
async function getStats(request) {
const loops = await adf.db_query({ sql: 'SELECT COUNT(*) as count FROM adf_loop' })
const files = await adf.fs_list({})
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: {
loopEntries: JSON.parse(loops)[0]?.count ?? 0,
fileCount: files ? JSON.parse(files).length : 0,
uptime: Date.now()
}
}
}
async function getAgents(request) {
const agents = await adf.agent_discover({})
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.parse(agents)
}
}
Result
http://127.0.0.1:7295/agents/{handle}/— Serves the dashboard HTMLhttp://127.0.0.1:7295/agents/{handle}/api/stats— Returns live statshttp://127.0.0.1:7295/agents/{handle}/api/agents— Returns mesh agentshttp://127.0.0.1:7295/agents/{handle}/data/report.json— Serves shared data files