On this page

ADF agents can install custom middleware lambdas at four integration points in the message and request pipelines. Middleware functions run in the agent’s sandbox environment and can inspect, transform, or reject data as it flows through.

Overview

PointWhen It RunsData ShapeUse Cases
RouteBefore an API route lambda executesHttpRequestAuth, rate limiting, CORS headers
InboxAfter ingress verification, before message storageAlfMessageContent filtering, spam detection
OutboxAfter message build, before signing/sendingEgressContextPII scrubbing, format transforms, delivery re-routing
FetchBefore sys_fetch makes an HTTP requestFetch paramsInject auth headers, URL allowlisting

Middleware is configured per-agent. Each middleware entry references a lambda function in the agent’s file store using the standard file:functionName format.

Middleware Contract

Every middleware function receives a single MiddlewareInput object and returns a MiddlewareOutput object.

Input

interface MiddlewareInput {
  /** Which pipeline point */
  point: 'route' | 'inbox' | 'outbox' | 'fetch'
  /** The data being processed — shape depends on point */
  data: unknown
  /** Metadata bag — accumulates across the middleware chain */
  meta: Record<string, unknown>
}

The data field shape depends on the pipeline point:

Pointdata shape
routeHttpRequest — { method, path, params, query, headers, body }
inboxAlfMessage — the full message after ingress verification (with verification stamps in meta)
outboxEgressContext — { message: AlfMessage, transport: { address, method, headers? }, agent: { did } }
fetch{ url, method, headers?, body?, timeout_ms? } — the sys_fetch input params

Inbox receives an AlfMessage while outbox receives an EgressContext that wraps the message with transport and agent info. This allows outbox middleware to modify the delivery address (transport.address) and inspect the sender’s DID (agent.did). To query contact data the agent manages itself (e.g. a local_contacts table), use adf.db_query() from within the middleware. Route and fetch keep their own shapes since they are not message-based.

Output

interface MiddlewareOutput {
  /** Replace the data for downstream middleware/handlers (pass-through if omitted) */
  data?: unknown
  /** Merge into meta for downstream middleware */
  meta?: Record<string, unknown>
  /** Reject — short-circuits the chain and returns an error to the caller */
  reject?: { code: number; reason: string }
}

Three behaviors:

  1. Pass-through — Return {} or { meta: { ... } }. Data flows through unchanged.
  2. Transform — Return { data: modifiedData }. Downstream middleware and handlers see the modified data.
  3. Reject — Return { reject: { code: 403, reason: "Blocked" } }. The pipeline stops immediately and the caller gets the rejection.

Configuration

Inbox / Outbox / Fetch Middleware

Configured in the agent’s security section:

{
  "security": {
    "allow_unsigned": true,
    "middleware": {
      "inbox": [
        { "lambda": "lib/middleware.ts:validateContent" },
        { "lambda": "lib/middleware.ts:logInbound" }
      ],
      "outbox": [
        { "lambda": "lib/middleware.ts:scrubPII" }
      ]
    },
    "fetch_middleware": [
      { "lambda": "lib/middleware.ts:injectAuthHeaders" }
    ]
  }
}
FieldTypeDescription
security.middleware.inboxMiddlewareRef[]Middleware chain for inbound messages
security.middleware.outboxMiddlewareRef[]Middleware chain for outbound messages
security.fetch_middlewareMiddlewareRef[]Middleware chain for sys_fetch requests
security.allow_local_fetchbooleanEscape hatch (default false) that relaxes the sys_fetch SSRF egress guard, permitting private/LAN/CGNAT fetches (loopback is allowed by default; the daemon control API and link-local/cloud-metadata addresses stay blocked regardless). Locked by default in the runtime — an agent’s write surfaces as a protection request the owner can approve as a one-time override.

Owner-only. The guard paths security.allow_unsigned, security.require_middleware_authorization, security.middleware.*, and security.fetch_middleware are not agent-writable via sys_update_config — the tool hard-denies them with a plain error (no HIL, no override). Only the owner changes them, in the app UI. This keeps the agent from configuring the middleware chain that is supposed to police it. (security.allow_local_fetch sits one tier down: locked by default rather than hard-denied — a write surfaces as a request the owner can approve as a one-time override.)

Route Middleware

Configured per-route in the serving.api array:

{
  "serving": {
    "api": [
      {
        "method": "POST",
        "path": "/webhook",
        "lambda": "lib/api.ts:handleWebhook",
        "middleware": [
          { "lambda": "lib/auth.ts:checkApiKey" },
          { "lambda": "lib/cors.ts:addHeaders" }
        ]
      }
    ]
  }
}
FieldTypeDescription
route.middlewareMiddlewareRef[]Middleware chain executed before the route lambda

MiddlewareRef

Each middleware reference is an object with a single lambda field:

interface MiddlewareRef {
  lambda: string  // "path/file.ts:functionName"
}

The format is the same as trigger lambdas and API route lambdas: filePath:functionName where filePath is relative to the agent’s file store.

Reference validation. Each lambda ref is validated before use: it must be <path>:<identifier> where the function name matches a JS-identifier regex (^[A-Za-z_$][A-Za-z0-9_$]*$) and the path contains no quotes, backslashes, newlines, or .. traversal. An invalid ref is skipped with a warning log, not executed. This hardened a code-injection surface: the executor interpolates the function name unquoted into the generated wrapper source, so a ref is treated as config, never as a place for JS. Attacker-controlled message bytes reaching the middleware are JSON-encoded into the wrapper (with U+2028/U+2029 escaped), never string-interpolated into source.

Execution

Chain Order

Middleware functions execute in array order. Each function receives the (possibly transformed) data from the previous middleware:

Request → middleware[0] → middleware[1] → middleware[2] → handler

If any middleware returns reject, the chain short-circuits immediately — no further middleware or handler runs.

Sandbox

Middleware lambdas run in the agent’s sandbox environment with access to the adf proxy object. Each pipeline point gets its own sandbox ID ({agentId}:mw:{point}), separate from API route sandboxes.

Error Handling

If a middleware lambda throws an error or the referenced file/function is not found, the error is logged and the middleware is skipped — the chain continues with the next middleware. This prevents a broken middleware from blocking all traffic.

Rejection Behavior

When middleware rejects, the caller receives the error:

PointRejection Result
RouteHTTP response with the rejection code and reason as the error body
Inbox (HTTP)HTTP response with the rejection code and reason
Inbox (local)Sender’s outbox status set to failed, error returned to msg_send
OutboxError returned to msg_send — message is not sent
Fetchsys_fetch returns an error result with the rejection reason

Examples

Auth Middleware for API Routes

// lib/auth.ts

async function checkApiKey(input) {
  const headers = input.data.headers || {}
  const apiKey = headers['x-api-key'] || headers['authorization']

  if (!apiKey) {
    return { reject: { code: 401, reason: 'API key required' } }
  }

  // Validate against stored keys
  const keys = await adf.fs_read({ path: 'config/api-keys.json' })
  const validKeys = JSON.parse(keys)

  if (!validKeys.includes(apiKey)) {
    return { reject: { code: 403, reason: 'Invalid API key' } }
  }

  // Pass through with auth metadata
  return { meta: { authenticated: true, apiKey } }
}

Spam Filter for Inbox

// lib/middleware.ts

function blockSpam(input) {
  const message = input.data
  const content = typeof message.payload.content === 'string'
    ? message.payload.content
    : JSON.stringify(message.payload.content)

  const spamPatterns = ['buy now', 'free money', 'click here']
  const isSpam = spamPatterns.some(p =>
    content.toLowerCase().includes(p)
  )

  if (isSpam) {
    return { reject: { code: 403, reason: 'Message flagged as spam' } }
  }

  return { meta: { spam_checked: true } }
}

PII Scrubbing for Outbox

// lib/middleware.ts

function scrubPII(input) {
  const ctx = input.data  // EgressContext
  const message = ctx.message
  let content = typeof message.payload.content === 'string'
    ? message.payload.content
    : JSON.stringify(message.payload.content)

  // Redact email addresses
  content = content.replace(
    /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
    '[REDACTED_EMAIL]'
  )

  // Redact phone numbers
  content = content.replace(
    /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
    '[REDACTED_PHONE]'
  )

  // Return transformed EgressContext
  return {
    data: {
      ...ctx,
      message: {
        ...message,
        payload: { ...message.payload, content }
      }
    },
    meta: { pii_scrubbed: true }
  }
}

Delivery Re-routing for Outbox

// lib/middleware.ts

function rerouteToProxy(input) {
  const ctx = input.data  // EgressContext

  // Re-route all HTTP deliveries through a proxy
  if (ctx.transport.method === 'http') {
    return {
      data: {
        ...ctx,
        transport: {
          ...ctx.transport,
          address: 'https://proxy.example.com/relay',
          headers: { 'X-Original-Address': ctx.transport.address }
        }
      }
    }
  }

  return {}
}

Auth Header Injection for Fetch

// lib/middleware.ts

async function injectAuthHeaders(input) {
  const fetchData = input.data

  // Read API token from identity store or file
  const token = await adf.fs_read({ path: 'config/api-token.txt' })

  if (token) {
    const headers = { ...(fetchData.headers || {}), 'Authorization': `Bearer ${token.trim()}` }
    return { data: { ...fetchData, headers } }
  }

  return {}
}

URL Allowlist for Fetch

// lib/middleware.ts

function enforceAllowlist(input) {
  const { url } = input.data
  const allowed = [
    'https://api.example.com',
    'https://hooks.slack.com'
  ]

  const isAllowed = allowed.some(prefix => url.startsWith(prefix))
  if (!isAllowed) {
    return { reject: { code: 403, reason: `URL not in allowlist: ${url}` } }
  }

  return {}
}

Logging Middleware (Pass-Through)

// lib/middleware.ts

function logRequest(input) {
  console.log(`[MW] ${input.point}:`, JSON.stringify(input.data).slice(0, 200))
  return { meta: { logged_at: Date.now() } }
}

UI Configuration

Security Section

In Agent Config > Security, a “Custom Middleware” area appears with three lists:

  • Inbox — Lambda references for inbound message middleware
  • Outbox — Lambda references for outbound message middleware
  • Fetch — Lambda references for sys_fetch request middleware

Each list has an + Add button to add entries and an x button to remove them. Enter lambda references in path/file.ts:functionName format.

Route Cards

In Agent Config > Serving > API Routes, each route card has a middleware section with the same + Add / x pattern. Route middleware runs before that specific route’s lambda function. Unlike the owner-only lists above, serving.api[].middleware is agent-writable through the serving.api array operations, HIL-gated (your principal approves) — see Managing Serving via sys_update_config.

Pipeline Integration

Where Middleware Runs in Each Pipeline

Route pipeline:

HTTP request → Fastify preHandlers (resolve agent) → build HttpRequest
  → route middleware chain → route lambda → HTTP response

Inbox pipeline (HTTP delivery):

HTTP POST → validate message → verify signatures
  → inbox middleware chain → flatten to inbox row → fire on_inbox trigger

Inbox pipeline (local delivery):

msg_send → build AlfMessage → egress pipeline (signing)
  → ingress pipeline (verification) → inbox middleware chain
  → flatten to inbox row → fire on_inbox trigger

Outbox pipeline:

msg_send → build AlfMessage → wrap in EgressContext
  → outbox middleware chain (can modify transport.address)
  → egress pipeline (signing) → write outbox row → deliver

Fetch pipeline:

sys_fetch call → fetch middleware chain → HTTP fetch → return result

Middleware Authorization

By default (require_middleware_authorization: true), middleware lambdas must be from authorized files. If a middleware lambda’s source file is not authorized, it is silently skipped and the message passes through unmodified. A warning is logged.

This prevents agents from writing middleware that tampers with messages before the agent sees them. The owner must review and authorize middleware code via the Files panel before it takes effect.

To allow agent-written middleware (an explicit trust decision), set require_middleware_authorization: false in the security config — owner-only: security.require_middleware_authorization is a guard path, hard-denied to agents via sys_update_config (no HIL); ask your principal. Unauthorized middleware will run but cannot call restricted methods.

See Authorized Code Execution for the full authorization model.

Best Practices

  1. Keep middleware fast. Middleware runs on every request/message. Avoid heavy computation or slow I/O.
  2. Use pass-through by default. Return {} when no action is needed. Only return data when you actually transform it.
  3. Order matters. Auth middleware should come before logging middleware. Validation before transformation.
  4. Test rejection paths. Verify that rejected requests return the expected error codes and messages.
  5. Use meta for cross-middleware communication. An early middleware can set meta.authenticated = true and a later one can check it.
  6. Don’t modify message signatures. Outbox middleware runs before signing. Inbox middleware runs after verification. Modifying signatures will break the crypto pipeline.
  7. Outbox middleware operates on EgressContext. Access the message via ctx.message and delivery info via ctx.transport. You can change the delivery address by modifying ctx.transport.address.