# @mono-agent/webhook-adapter

Expose one or more authenticated HTTP invocation endpoints from an agent host,
with synchronous responses or process-local asynchronous status tracking.

## Category

<!-- package-metadata:start -->
<!-- Generated by scripts/generate-package-docs.mjs. Do not edit by hand. -->

Category: `communication`
Tier: `core`
Catalog responsibility: Invokes agent responders from HTTP webhook requests with sync and async modes.

<!-- package-metadata:end -->

## Responsibility

HTTP webhook invocation adapter for agent hosts. It starts a small HTTP server, validates JSON invocation requests, maps them into structural `AgentResponder` calls, and returns either a synchronous result or an in-memory async request status. One server can serve **multiple named endpoints**, each with its own path, mode, optional `prompt`, and run-watchdog override.

## Install / Usage

### Config-first host

`@mono-agent/agent-app` already includes this adapter. The loader default is
`enabled: false`; the `mono-agent init` scaffold deliberately writes
`enabled: true` so a new agent has a loopback smoke-test endpoint.

```json
{
  "webhook": {
    "enabled": true,
    "host": "127.0.0.1",
    "port": 4310,
    "path": "/webhook/invoke",
    "defaultMode": "sync"
  }
}
```

```bash
mono-agent validate
mono-agent start --foreground
```

### Port ownership

The adapter binds **its own** HTTP port — it is *not* inherited from a parent mono-agent process (mono-agent is not itself an HTTP server; each HTTP-bearing channel binds its own port). `port: 0` (the default) asks the OS for a random free port, so the resolved URL is unpredictable. **Set an explicit port** (e.g. `4310`) when you need a stable URL that skills or webhook `prompt`s can reference. The resolved invoke URL(s) are reported in the channel `summary` (visible via `mono-agent status` and logs).

### Programmatic use

Install the package directly only when composing a custom host:

```bash
pnpm add @mono-agent/webhook-adapter
```

<!-- doc-test:typescript -->
```ts
import {
  startWebhookAdapter,
  type WebhookAdapterOptions,
} from "@mono-agent/webhook-adapter";

const responder: WebhookAdapterOptions["responder"] = {
  async respond(request) {
    return { text: `Webhook request received: ${request.text}` };
  },
};

const apiKey = process.env.MONO_AGENT_WEBHOOK_API_KEY;
const webhook = await startWebhookAdapter({
  host: "127.0.0.1",
  port: 4310,
  ...(apiKey === undefined ? {} : { apiKey }),
  maxRunMs: 1_200_000,
  responder,
  endpoints: [
    { name: "invoke", path: "/webhook/invoke" },
    {
      name: "report",
      path: "/webhook/report",
      mode: "async",
      maxRunMs: 3_600_000,
      prompt: "Prepare a concise report from the submitted text.",
    },
  ],
});

process.once("SIGINT", () => void webhook.stop());
```

A single legacy endpoint still works (`path`/`defaultMode` are folded into a one-element `endpoints` list):

```ts
await startWebhookAdapter({ host: "127.0.0.1", port: 4310, path: "/webhook/invoke", responder });
```

For programmatic native-notify composition, an endpoint can set an explicit `notifyConversationId`, a pre-resolved `notifyFallbackConversationId`, or the adapter options can provide `resolveNotifyFallbackConversationId`. The resolver runs once per invocation after explicit and deliverable request destinations are considered. Its selected route is attached to the responder-facing request's host-only `replyTo`, retained privately by the run, and reconstructed on a separate completion request for `onResult`; responder mutation therefore cannot redirect, suppress, or inject final delivery. The resolver receives the request's optional `AbortSignal`, and the adapter also races its promise against that signal so disconnect/stop can reclaim the slot even when resolver code does not cooperate.

`NATIVE_NOTIFY_CALLBACK_CHANNEL_IDS` is the exact request-conversation policy: Telegram and Slack are eligible, while WhatsApp remains excluded until its plugin driver exposes a native notify hook.

Send a sync invocation (omit the `authorization` header when `apiKey` is not configured):

```bash
curl -X POST "$WEBHOOK_URL/webhook/invoke" \
  -H 'content-type: application/json' \
  -H "authorization: Bearer $MONO_AGENT_WEBHOOK_API_KEY" \
  -d '{"text":"Run the agent","conversationId":"demo","mode":"sync"}'
```

Async mode returns `202` with `requestId` and `statusUrl`; status is process-local memory and is not durable across restarts.

### Audio input (Apple Shortcuts)

An invoke route also accepts a recorded audio file (e.g. Apple's Record Audio
`m4a`) as an ordinary `document` `AgentAttachment`, so an agent with a
transcribe tool (an MCP request-context server such as the transcription
agent's `transcribe`) can work on the file — the same path Telegram voice
notes take. The adapter never transcribes: it only ingests bytes. Your agent
needs such a tool to understand the audio.

Two inbound formats, in addition to JSON (`text` stays required there):

- `multipart/form-data`: exactly one file part under the `audio` or `file`
  field, plus the usual text fields (`text`, `conversationId`, `mode`,
  `model`, `effort`; `metadata` may be a JSON string field and must parse).
  `text` is optional when the file is present.
- Raw `audio/*` body: the whole body is the file; optional params ride the
  query string (`text`, `conversationId`, `mode`, `model`, `effort`) and the
  `X-File-Name` header (or `name` query param) gives the filename.

```bash
# Raw body (Shortcuts "Get Contents of URL" with Request Body: File).
curl -X POST "$WEBHOOK_URL/webhook/invoke?mode=sync" \
  -H 'content-type: audio/x-m4a' \
  -H "authorization: Bearer $MONO_AGENT_WEBHOOK_API_KEY" \
  --data-binary @note.m4a

# Multipart (Shortcuts "Get Contents of URL" with Request Body: Form).
curl -X POST "$WEBHOOK_URL/webhook/invoke" \
  -H "authorization: Bearer $MONO_AGENT_WEBHOOK_API_KEY" \
  -F 'audio=@note.m4a;type=audio/x-m4a' -F 'mode=sync'
```

Apple encoder aliases are normalized (`audio/x-m4a` and `audio/m4a` →
`audio/mp4`, `audio/x-wav` → `audio/wav`, `audio/mp3` → `audio/mpeg`);
anything outside the audio allowlist is rejected with HTTP `415`, empty files
with `400`, and uploads past `webhook.maxAttachmentBytes` (default 20 MiB via
`MONO_AGENT_WEBHOOK_MAX_ATTACHMENT_BYTES`) with `413`. Without `text` the
user message is the endpoint `prompt`, or `Voice message attached.` when the
endpoint has none. Sync and async responses are unchanged, and the audio bytes
never appear in status JSON or metadata — only `hasAttachments` /
`attachmentCount`.

Shortcuts recipe: **Record Audio** → **Get Contents of URL** (`POST` the
invoke URL, header `Authorization: Bearer <key>`, Request Body: File for the
raw format or Form with an `audio` file field plus `mode=sync` for
multipart) → **Get Dictionary Value** `text` → **Show Result** (or Speak
Text).

### HTTP status contract

| Route outcome | HTTP status | JSON `status` | Stored for status lookup? |
| --- | ---: | --- | --- |
| Async invocation admitted | `202` | `accepted` | The corresponding `running` entry is stored. |
| Stored async request read | `200` | `running`, `succeeded`, `failed`, or `cancelled` | Yes, until retention/size pruning or restart. |
| Sync invocation succeeded | `200` | `succeeded` | Yes. |
| Sync invocation cancelled | `499` | `cancelled` | Yes. |
| Sync invocation failed | `500` | `failed` | Yes. |
| Same endpoint + conversation already active | `409` | `busy` | No. |
| Unknown or expired request id | `404` | `not_found` | No. |
| Missing or invalid configured bearer | `401` | `unauthorized` | No. |
| Invalid JSON/request shape | `400` | `failed` | No. |
| Empty audio upload, malformed multipart, or unparsable multipart `metadata` | `400` | `failed` | No. |
| Audio upload past `webhook.maxAttachmentBytes` | `413` | `failed` | No. |
| Non-audio upload (multipart file or `audio/*` outside the allowlist) | `415` | `failed` | No. |
| Adapter stopping before admission | `503` | `failed` | No. |

`apiKey` is optional for loopback-only use. When configured, every invocation and async status lookup requires `Authorization: Bearer <key>`; authentication runs before invocation-body parsing, so malformed, missing, and incorrect credentials receive the same `401` response without decoding the JSON or audio body. Any non-loopback bind requires both `allowNonLoopback: true` and a non-empty key. Host config reads the key from `webhook.apiKey` / `MONO_AGENT_WEBHOOK_API_KEY`, redacts it from config views, and should normally keep it in the environment rather than committed JSON.

Webhook response metadata contains channel-safe run diagnostics such as the run id and status. Compiled system prompts are retained only in local run artifacts and are never returned by this external HTTP API. As defense in depth, the adapter removes `metadata.summary.systemPrompt` even when a custom responder supplies it; sibling summary fields and unrelated metadata are preserved.

Webhook is a machine/text destination. Rich reply parts never alter the answer
`text`: successful sync JSON, stored async status, `getStatus()`, and result
callbacks instead carry optional `replyPartOutcomes`. Each attachment or MCP App
has a terminal `unsupported_destination` failure. The list is capped at 20,
uses an explicit aggregate if an off-contract responder exceeds that ceiling,
and cannot contain part ids, filenames, paths, URLs, integrity values, producer
messages, or payload bytes. Every external copy receives its own outcome array,
so callback mutation cannot rewrite the stored or returned status.

### Per-webhook prompt

Each endpoint may carry a `prompt` (pre-instructions, same role as a cron job's prompt). When set, the adapter forms the agent's user message as `prompt` + `\n\n` + the posted `text`. The webhook imposes no correlation scheme of its own: it forwards the request's `conversationId` and arbitrary `metadata` through unchanged, so a `prompt` plus filesystem/skill conventions can drive any workflow (e.g. matching incoming results to request files on disk).

> A webhook is request/response: the turn's output is returned on the POST (sync)
> or status endpoint (async). This adapter forwards the exact `conversationId`
> but does not persist conversation history. The config-first agent-app responder
> owns its durable history/session policy; a custom host may provide a different
> policy or none. The async request-status table always remains adapter-owned,
> process-local memory and is cleared on restart.

### Configuring multiple endpoints (host config)

`loadWebhookAdapterConfig` reads endpoints from, in precedence order: `MONO_AGENT_WEBHOOK_ENDPOINTS_JSON`, the `webhook.endpoints` array, then the legacy single `webhook.path`/`webhook.prompt` fields. Endpoints can also be authored as `*.md` files in the `webhook` folder (override via `webhook.dir` / `MONO_AGENT_WEBHOOK_DIR`), mirroring cron jobs — frontmatter is routing, the body is the `prompt`:

```markdown
---
path: /webhook/deep-research
mode: async
maxRunMs: 3600000
---
Check deep-research/requests/*.md, match the incoming payload to an existing
request, address it, then move that file to deep-research/researched/.
```

Folder endpoints are merged with config endpoints; a duplicate `name` or `path` is a hard error. An endpoint `maxRunMs` overrides the adapter-level fallback. Set it to `0` to disable the watchdog for only that endpoint; positive values bound that endpoint independently (config range `0`–`86400000`).

## Architecture

### Data flow

The request lifecycle is:

1. `config.ts` layers JSON and env values, applies safe-bind/auth defaults, and
   merges inline endpoints with `webhook/*.md` definitions.
2. `server.ts` creates one Express/HTTP server and registers each normalized POST
   route plus its request-status route.
3. An invocation is authenticated, parsed, admitted per endpoint + conversation,
   normalized into `WebhookInvocationRequest`, and passed to the host responder.
4. Sync mode waits and writes the terminal status. Async mode returns `accepted`
   immediately while the in-memory status advances through `running` to a
   terminal value. Completion hooks and native-notify resolution are best effort.
5. `stop()` rejects new work, aborts active requests, and closes the server; the
   status map and any host-owned history have separate lifecycles.

### Package structure

| Source module | Responsibility |
| --- | --- |
| [`config.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/webhook-adapter/src/config.ts) | Config/env loading, safe-bind validation, endpoint merge, and redaction. |
| [`endpoints-dir.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/webhook-adapter/src/endpoints-dir.ts) | Markdown endpoint parsing and deterministic directory loading. |
| [`server.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/webhook-adapter/src/server.ts) | HTTP/auth lifecycle, request normalization, admission, status storage, watchdogs, and shutdown. |
| [`index.ts`](https://github.com/robertsreberski/mono-agent/blob/main/packages/webhook-adapter/src/index.ts) | Supported public package surface. |

## Public API

### Start here

| API | Use it for |
| --- | --- |
| `loadWebhookAdapterConfig` | Load and validate config, env, and optional endpoint files. |
| `startWebhookAdapter` | Start all configured routes and obtain URLs, status lookup, and `stop()`. |
| `WebhookAdapterOptions` / `WebhookEndpointOption` | Compose a standalone HTTP adapter. |
| `WebhookInvocationRequest` | Implement the responder-facing request contract. |
| `WebhookInvocationStatus` / `WebhookBusyResponse` | Handle stored lifecycle values separately from transient `busy`. |
| `loadWebhookEndpointsFromDirectory` / `parseWebhookEndpointMarkdown` | Build custom Markdown endpoint workflows. |
| `normalizePath` | Apply the server's route normalization in host tooling. |

<!-- public-api-inventory:start -->
<!-- Generated by scripts/generate-public-api-docs.mjs. Do not edit by hand. -->

Every symbol exported by each public code entrypoint is listed below.

**`@mono-agent/webhook-adapter`**

```text
LoadWebhookAdapterConfigInput
NATIVE_NOTIFY_CALLBACK_CHANNEL_IDS
RedactedWebhookAdapterConfig
WEBHOOK_CONFIG_FIELDS
WebhookAdapterConfig
WebhookAdapterError
WebhookAdapterErrorCode
WebhookAdapterErrorDetails
WebhookAdapterLogger
WebhookAdapterOptions
WebhookAdapterStartResult
WebhookBusyResponse
WebhookEndpointConfig
WebhookEndpointOption
WebhookEndpointSummary
WebhookInvocationMode
WebhookInvocationRequest
WebhookInvocationStatus
WebhookRequestMetadata
loadWebhookAdapterConfig
loadWebhookEndpointsFromDirectory
normalizePath
parseWebhookEndpointMarkdown
redactWebhookAdapterConfig
startWebhookAdapter
```

<!-- public-api-inventory:end -->

## Dependency Boundary

This adapter depends on Express plus shared `@mono-agent/agent-contracts` primitives. It must not depend on the agent harness, runtime adapter, operator surfaces, memory, observability, other communication adapters, or host composition code. Hosts compose it with a structural responder.

## What This Package Does Not Own

It does not build prompts, run models, persist conversation history, durably
persist async status, verify provider-specific webhook signatures, manage TLS,
expose an operator UI, or own core agent settings. The adapter binds to loopback
by default and provides optional static bearer authentication; TLS, key rotation,
rate limiting, and reverse-proxy policy remain host responsibilities.

## Related Documentation

- [Webhook channel guide](https://mono-agent-docs.vercel.app/channels/webhook/)
- [Webhook sync/async playbook](https://mono-agent-docs.vercel.app/playbooks/webhook-automation-sync-async/)
- [Delivery and send tools](https://mono-agent-docs.vercel.app/channels/delivery-and-send-tools/)
- [Custom channel adapters](https://mono-agent-docs.vercel.app/programmatic/custom-channels/)

## Verification

Run the package-local build, typecheck, and behavior tests:

```bash
pnpm --filter @mono-agent/webhook-adapter run build
pnpm --filter @mono-agent/webhook-adapter run typecheck
pnpm --filter @mono-agent/webhook-adapter run test
```
