Skip to content

Webhook

The webhook channel turns your agent into an HTTP endpoint: POST a JSON body with text, and the agent runs a turn. It is the zero-credential channel mono-agent init enables by default — a loopback smoke test you can curl immediately, and the integration point for automations, scripts, and other services. Coverage: config (webhook section), plus env overrides.

init writes a webhook block bound to loopback on a free port. Start the agent, then POST to the path:

{
"webhook": {
"enabled": true,
"host": "127.0.0.1",
"port": 0,
"path": "/webhook/invoke",
"defaultMode": "sync"
}
}
Terminal window
PORT=3000 # Replace 3000 with the port printed at startup.
curl -s "http://127.0.0.1:${PORT}/webhook/invoke" \
-H 'content-type: application/json' \
-d '{"text": "Summarize today’s standup notes."}'

The actual bound host/port (when port: 0) is printed in the start log. In sync mode the agent’s answer is returned in the response body.

mono-agent init chooses and writes enabled: true for this starter endpoint. That scaffold choice is distinct from the loader default: if enabled is omitted from a hand-written config, the webhook channel remains off.

KeyTypeDefaultNotes
enabledbooleanfalseChannel on/off. mono-agent init explicitly scaffolds true.
hoststring127.0.0.1Bind address. Loopback-only unless allowNonLoopback is true.
portinteger00 picks a free port (printed at startup). 165535 to pin one.
pathstring/webhook/invokePOST path for the default (single) endpoint.
defaultModesync | asyncsyncResponse mode when a request does not override it.
allowNonLoopbackbooleanfalseRequired to bind a non-loopback host. See warning below.
apiKeystringOptional static bearer token on loopback; required for a non-loopback bind. Prefer MONO_AGENT_WEBHOOK_API_KEY over committed JSON. Protects invoke and status routes.
retentionMsinteger300000How long completed/request statuses are retained (min 1, max 86_400_000).
maxStoredRequestsinteger100Maximum status entries kept before oldest-first pruning (min 1, max 10_000).
maxRunMsinteger1200000Adapter-level wall-clock fallback per run (20 min). endpoints[].maxRunMs wins; 0 disables. Min 0, max 86_400_000. See Run watchdog.
endpoints[].maxRunMsintegerinheritedPer-endpoint watchdog override. 0 disables only that endpoint; otherwise min 1, max 86_400_000.
promptstringPre-instructions prepended to the request text (see Prompts).
notifybooleanfalseDeliver the successful final answer via native notification.
notifyConversationIdstringinferred if exactly one destinationDestination conversation id. Use exact web:new to create a new WEBHOOK-marked web conversation; web is never inferred.
modelstringruntime.modelPer-endpoint model override (e.g. anthropic:claude-opus-4-8). A request body model wins. See Per-trigger model & effort.
effortstringruntime.effortPer-endpoint reasoning effort (none/minimal/low/medium/high/xhigh/max/ultra), subject to model support. Reasoning-capable models map ultra to LOW; models without reasoning use OFF. max degrades to xhigh unless the resolved model advertises it. mono-agent doctor warns and names the nearest supported level when the configured value is outside the model’s advertised set. Ranking above max only prevents keyword downgrade. A request body effort wins.
endpointsarrayMultiple named endpoints — see Multiple endpoints.
dirstringwebhookFolder of *.md endpoint files, resolved against the app working directory.

When apiKey is set, callers send Authorization: Bearer <key>. Authentication runs before JSON body parsing, so missing, malformed, and incorrect bearer values all receive the same HTTP 401 without decoding the body; token comparison uses the shared timing-safe contract. The adapter removes authorization, cookie, set-cookie, proxy-authorization, and x-api-key from request metadata before the responder or artifacts can observe them. Leaving the key unset preserves the existing unauthenticated loopback behavior.

The request body is a JSON object. text is required; everything else is optional.

FieldRequiredNotes
textyesThe user message for this turn. Non-empty.
modenosync or async, overriding the endpoint’s mode for this request.
conversationIdnoReuse to continue a thread. Defaults to a per-request id (webhook:<requestId>).
modelnoPer-request model override (sdk:model / sdk:provider:model). Wins over the endpoint’s model. See Per-trigger model & effort.
effortnoPer-request reasoning effort (none/minimal/low/medium/high/xhigh/max/ultra), subject to model support. Reasoning-capable models map ultra to LOW; models without reasoning use OFF. max degrades to xhigh unless the resolved model advertises it. mono-agent doctor warns and names the nearest supported level when the configured value is outside the model’s advertised set. Ranking above max only prevents keyword downgrade. Wins over the endpoint’s effort.
metadatanoArbitrary JSON passed through to the turn.

The request blocks until the turn finishes and returns the answer text in the body. Use it for short, interactive automations where you want the result inline.

The request returns immediately with HTTP 202 and a statusUrl. Poll that URL until status is succeeded (text included), failed, or cancelled. Use it for long-running turns that would otherwise exceed an HTTP timeout.

Terminal window
# kick off
PORT=3000 # Replace 3000 with the port printed at startup.
curl -s "http://127.0.0.1:${PORT}/webhook/invoke" \
-H 'content-type: application/json' \
-H "authorization: Bearer ${MONO_AGENT_WEBHOOK_API_KEY}" \
-d '{"text": "Research and draft the weekly report.", "mode": "async"}'
# → 202 { "status": "accepted", "requestId": "...", "statusUrl": "/webhook/requests/..." }
# poll
REQUEST_ID='replace-with-request-id'
curl -s "http://127.0.0.1:${PORT}/webhook/requests/${REQUEST_ID}" \
-H "authorization: Bearer ${MONO_AGENT_WEBHOOK_API_KEY}"
# → { "status": "succeeded", "text": "..." }

Async statuses are kept in memory subject to retentionMs and maxStoredRequests; a status URL polled after expiry returns not_found. If a turn is already running and the runtime cannot accept another, a request returns HTTP 409 (status: "busy") — that transient state is not stored or replayed via the status URL.

Webhook responses are text/machine contracts. Attachments, MCP Apps, and prior part failures do not change text; successful sync JSON and completed async status JSON instead include optional replyPartOutcomes. Attachments and apps terminate as unsupported_destination. The array is capped at 20 with a counted overflow aggregate and contains only position, broad type, status, code, and a fixed message—never source ids, filenames, paths, URLs, capabilities, integrity ids, producer messages, or payload bytes. Programmatic getStatus() and result callbacks receive separate copies of the same sanitized outcome.

Route outcomeHTTP statusJSON statusStatus-store behavior
Async invocation admitted202acceptedA corresponding running entry is stored.
Stored status lookup200running, succeeded, failed, or cancelledRetained until pruning or process restart.
Sync success200succeededStored.
Sync cancellation499cancelledStored.
Sync failure500failedStored.
Same endpoint + conversation already active409busyNever stored.
Unknown or expired request id404not_foundNo entry exists.
Missing or invalid configured bearer401unauthorizedNever parsed or stored.
Invalid JSON/request shape400failedNever admitted or stored.
Adapter stopping before admission503failedNever admitted or stored.

The status map is adapter-owned, bounded process memory; it is not durable and is cleared by restart. Conversation history has a different owner. The adapter forwards the exact conversationId to its responder, and the config-first agent-app responder applies the host’s durable history/session policy. A custom responder may provide another policy or no persistence at all.

Harness response metadata is an external boundary. metadata.summary may include normal run status, model, usage, and failure information, but never the compiled systemPrompt. The harness removes it on success and early-failure paths, and the webhook adapter sanitizes untrusted custom responders again for sync responses, async storage/status reads, and result callbacks. Private local recorder artifacts may retain the prompt for operator inspection; it is not serialized onto webhook surfaces.

Run watchdog: a wedged run is aborted, not left to starve

Section titled “Run watchdog: a wedged run is aborted, not left to starve”

A hung run (a destination resolver, responder, or provider call that never settles) would otherwise hold its conversation slot forever. This matters most in async mode: with no client connection to disconnect, nothing else bounds the run. To prevent that, each webhook run is raced against a 20-minute watchdog (maxRunMs, default 1200000): a run that does not finish in time has its request signal aborted and its conversation slot reclaimed even if its in-flight work never settles. This brings webhook to parity with cron’s maxRunMs.

Set webhook.maxRunMs to override the adapter default (min 0, max 86_400_000). Set webhook.endpoints[].maxRunMs to give one endpoint its own bound. Precedence is endpoint maxRunMs > adapter maxRunMs > 20-minute app default; because the lookup is nullish, an endpoint value of 0 explicitly disables its watchdog even when the adapter fallback is positive. Endpoints without an override continue to use the adapter fallback. A run whose responder resolves after the abort is classified cancelled rather than succeeded — see Run artifacts & traces.

Programmatic destination resolvers receive the request’s AbortSignal, and their promise is raced against it independently of the watchdog. A sync client disconnect or adapter stop therefore reclaims a slot that is still resolving even when maxRunMs is disabled and resolver code ignores the signal; later settlement cannot start a responder or emit another result.

For a webhook endpoint that produces a user-facing result, set notify: true and optionally notifyConversationId. The agent’s successful, non-empty final answer is delivered verbatim to Telegram, Slack, or the web console — no second LLM turn — and recorded into that destination’s history, so a user’s reply resumes with it in context. This works for both sync and async endpoints: sync mode still returns the answer in the HTTP response, and notify: true additionally delivers it to the destination (async, via the post-run hook). Delivery is best-effort and does not change the sync HTTP response or the async stored status if it is skipped or fails.

The operator just writes the endpoint prompt; on a notify turn the harness auto-injects guidance telling the agent its final reply is delivered as-is. To send nothing, the agent produces an empty final answer or replies with the reserved sentinel NOTHING_TO_REPORT (matched trimmed and case-insensitively, either as the whole answer or as its final line — never as a substring).

Destination resolution. If notifyConversationId is set, it is used (telegram:42, slack:C123, slack:C123:1718.99 for a Slack thread, or the exact value web:new). web:new creates a separate assistant-only conversation for every distinct successful invocation, marked WEBHOOK in the sidebar and header. It is explicit-only and never participates in destination inference. If notifyConversationId is omitted, the app infers the destination only when exactly one Telegram/Slack notify-capable candidate exists (from seen conversations plus the adapter allowlist); with 0 or 2+ candidates it skips delivery with a warning rather than guessing. Artifact-derived candidates are cached for 30 seconds after each scan completes. An artifact committed under a Telegram/Slack conversation id invalidates the cache immediately; runs using the default synthetic cron:/webhook: ids do not. Other artifact changes are picked up after cache expiry and the next scan completes. The allowlist is the destination boundary for Telegram/Slack. Web delivery makes one attempt against the running local console and has no retry queue or outbox.

Notifying multiple or other conversations from one endpoint is not a built-in: compose it from a skill or from multiple endpoints, each with its own notifyConversationId.

An endpoint can run on a different model or reasoning effort than the agent’s default, set per-endpoint in config and/or per-request in the body. This powers a delegate pattern: the host “deploys” a sub-agent by POSTing to a webhook that runs a heavier task (e.g. deep research) on a more powerful model.

Terminal window
curl -X POST "$URL/delegate" -H 'content-type: application/json' \
-d '{"text": "Deep-research X and write a brief.", "model": "anthropic:claude-opus-4-8", "effort": "high"}'

Precedence is request body > endpoint config > agent default. The override becomes that turn’s primary model; configured canonical runtime.fallbacks (or legacy backups) remain. With no explicit effort, the configured primary keeps runtime.effort, a selected configured fallback uses its own pinned effort or provider default, and another advertised model inherits runtime.effort only when its ladder admits the grade; unknown cloud metadata stays permissive. Static invalid values fail mono-agent validate; dynamic invalid values are warned and ignored, so the request still runs on the safe default model. Explicit effort must be one of none/minimal/low/medium/high/xhigh/max/ultra and supported by the selected model. Reasoning-capable models map ultra to LOW; models without reasoning use OFF. max degrades to xhigh unless the resolved model advertises it. mono-agent doctor warns and names the nearest supported level when the configured value is outside the model’s advertised set. Ranking above max only prevents keyword downgrade.

The request body may always request an override, but the host applies it only when it preserves the configured runtime/sandbox boundary; an incompatible value is warned and ignored. Its remaining model-selection authority rests on the webhook’s loopback-only default (allowNonLoopback: false). If you expose the endpoint beyond loopback, configure apiKey (required) and put the service behind TLS plus the reverse-proxy controls appropriate for the integration.

A pinned model can stay warm across requests with the same explicit conversationId when continuous sessions are enabled. The unique per-request default conversationId still starts separate conversations. Changing the requested model retires the old provider epoch and cold-seeds a new one from canonical history; repeated requests on that model can resume its durable transcript when piSessionsRoot is configured. Overrides to configured local providers are supported: mono-agent recomputes the target provider’s endpoint and capabilities. An unconfigured or invalid local target clears the inherited endpoint block and is rejected rather than accidentally using the host provider. An effort-only request keeps the same model chain and the same provider-session binding.

You can serve several named endpoints on the one shared host/port, each with its own path, mode, and optional prompt. Define them inline under webhook.endpoints[]:

{
"webhook": {
"enabled": true,
"port": 8787,
"defaultMode": "sync",
"maxRunMs": 1200000,
"endpoints": [
{
"name": "triage",
"path": "/hooks/triage",
"mode": "async",
"maxRunMs": 3600000,
"prompt": "You are triaging an inbound support ticket. Classify and summarize.",
"notify": true,
"notifyConversationId": "slack:C012345"
},
{
"name": "echo",
"path": "/hooks/echo",
"enabled": true,
"maxRunMs": 0
}
]
}
}

Each endpoint needs a unique name and a unique path; a duplicate of either (across inline config and folder files) is a hard configuration error. mode defaults to defaultMode, enabled defaults to true, and an omitted maxRunMs inherits the adapter-level value. Endpoint maxRunMs: 0 disables the watchdog for only that endpoint.

Instead of (or alongside) inline config, author one *.md file per endpoint in webhook.dir (default webhook/). The YAML frontmatter holds routing metadata and the markdown body becomes the endpoint’s prompt:

---
name: triage
path: /hooks/triage
mode: async
enabled: true
maxRunMs: 3600000
notify: true
notifyConversationId: slack:C012345
---
You are triaging an inbound support ticket. Classify it and summarize the next action.

path is required in frontmatter; name defaults to the filename stem, mode to defaultMode, enabled to true, and notify to false. Optional maxRunMs follows the same 086400000 endpoint semantics as inline config. Unlike cron jobs, the body may be empty (an endpoint with no prompt). Files are loaded in sorted filename order. This mirrors how cron jobs can be authored as cron/*.md files.

A per-endpoint prompt (inline, or the body of an *.md file) is prepended to the incoming request text before the turn runs — the same role a cron job’s prompt plays. This lets one HTTP caller send only data while the endpoint supplies the standing instructions. Callers cannot see or override the prompt.

Every key has a MONO_AGENT_WEBHOOK_* override, which takes precedence over the JSON config:

Env varMaps to
MONO_AGENT_WEBHOOK_ENABLEDwebhook.enabled
MONO_AGENT_WEBHOOK_HOSTwebhook.host
MONO_AGENT_WEBHOOK_PORTwebhook.port
MONO_AGENT_WEBHOOK_PATHwebhook.path
MONO_AGENT_WEBHOOK_DEFAULT_MODEwebhook.defaultMode
MONO_AGENT_WEBHOOK_ALLOW_NON_LOOPBACKwebhook.allowNonLoopback
MONO_AGENT_WEBHOOK_API_KEYwebhook.apiKey
MONO_AGENT_WEBHOOK_RETENTION_MSwebhook.retentionMs
MONO_AGENT_WEBHOOK_MAX_STORED_REQUESTSwebhook.maxStoredRequests
MONO_AGENT_WEBHOOK_MAX_RUN_MSwebhook.maxRunMs
MONO_AGENT_WEBHOOK_PROMPTwebhook.prompt
MONO_AGENT_WEBHOOK_NOTIFYwebhook.notify
MONO_AGENT_WEBHOOK_NOTIFY_CONVERSATION_IDwebhook.notifyConversationId
MONO_AGENT_WEBHOOK_MODELwebhook.model
MONO_AGENT_WEBHOOK_EFFORTwebhook.effort
MONO_AGENT_WEBHOOK_DIRwebhook.dir
MONO_AGENT_WEBHOOK_ENDPOINTS_JSONwebhook.endpoints (JSON array string)

See Environment variables for precedence rules.