--- title: "Webhooks" description: "Get an HTTP POST when a call ends and when its analysis settles, and personalize inbound phone calls before the agent answers" icon: "bell" --- Point your agent at one or more endpoints on your server and Fish Audio calls them when things happen: `call.ended` the moment a session reaches a terminal state, `call.analyzed` with the transcript once [post-call analysis](/agents/monitor/post-call-analysis) settles, and, on [outbound phone calls](/agents/telephony/outbound-calls), `phone_call.dial_finished` as soon as the dial attempt resolves. Use them to write results into your CRM, ticketing system, or data warehouse without polling the sessions API. Inbound phone calls also have an [inbound call webhook](#inbound-call-webhook) that runs before the agent answers, so your server can look up the caller and hand back the values for the prompt's `{{placeholders}}`. ## Events | Event | Fires | Payload | | -------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | `call.ended` | When a session reaches a terminal state: every real call, whether or not analysis is configured | Session facts plus the reason the call ended | | `call.analyzed` | After the transcript is complete and post-call analysis settles: completed, skipped, or error | Session facts, the full `analysis` entity, and the transcript `items` | | `phone_call.dial_finished` | When an outbound call's dial attempt resolves: answered, busy, no answer, or failed | Session facts plus `dial_status` and `answered_by` | For a given session, `call.ended` is sent first, and every answered production call produces both events. `call.ended` does not carry the transcript. `call.analyzed` is sent after the transcript is complete, and it carries the full timeline in `items`. It is sent even when no analysis is configured. When analysis has nothing to work with (the caller never spoke, or analysis is disabled) or fails, `call.analyzed` still arrives, with `analysis.status` set to `skipped` or `error` and empty result sections. `phone_call.dial_finished` fires only for outbound calls, and it fires whether or not anyone picks up. An answered outbound call produces all three events, `phone_call.dial_finished` first; an outbound call that is never answered (a `dial_status` of `busy`, `no_answer`, or `failed`) produces `phone_call.dial_finished` and nothing else. Unanswered calls are not billed and not analyzed. Only `call.analyzed` carries the transcript. To read it while the call is still running, or again later, use `GET /v1/agent/sessions/{session_id}`. See [conversation history](/agents/monitor/conversation-history). ## Configure the endpoint Webhooks live in the `webhooks` section of your agent's configuration. `post_call` is a list, so one agent can fan events out to several systems at once (the section's other entry, `conversation_init`, is the [inbound call webhook](#inbound-call-webhook)). Set them in the console on your agent's **Webhooks** page, or via the config API: ```bash API (curl) curl --request PATCH https://api.fish.audio/v1/agent/agents/YOUR_AGENT_ID/config \ --header "Authorization: Bearer $FISH_API_KEY" \ --header "Content-Type: application/json" \ --data '{ "webhooks": { "post_call": [ { "url": "https://example.com/fish-webhooks", "secret": "your-signing-secret" }, { "url": "https://crm.example.com/fish-webhooks", "secret": "another-signing-secret" } ] } }' ``` ```python Python import httpx, os httpx.patch( "https://api.fish.audio/v1/agent/agents/YOUR_AGENT_ID/config", headers={"Authorization": f"Bearer {os.environ['FISH_API_KEY']}"}, json={ "webhooks": { "post_call": [ { "url": "https://example.com/fish-webhooks", "secret": "your-signing-secret", }, { "url": "https://crm.example.com/fish-webhooks", "secret": "another-signing-secret", }, ] } }, ) ``` The list is replaced as a whole on each update, so send every endpoint you want to keep. Set it to `null` or `[]` to stop deliveries. A single `post_call` object is still accepted and is treated as a one-element list. Secrets are write-only, so an entry sent without a `secret` keeps the secret already stored for that `url`; you can read the configuration, edit the list, and send it back without re-entering secrets. | Field | Rules | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `post_call` | Up to 5 entries, and their `url` values must be unique. A longer list or a repeated URL is rejected with `422` | | `url` | Required on every entry. Up to 4000 characters. Must resolve to a public address. Localhost and private-network hosts are rejected with `422`, and the check is repeated at every delivery | | `secret` | Optional signing key, up to 256 characters, set per entry. **Write-only**: reads return `has_secret: true`, never the value. Omit it (or send `null`) to keep the stored secret for the same `url`; send `""` to remove it | Webhook settings are part of the agent configuration, so they follow the draft-and-publish flow: [publish](/agents/deploy/versions-publishing) for changes to apply to production calls. ## Payload Every payload carries the `event` name, a unique `event_id`, and a `session` object with the session facts. Note the field names differ from the [sessions API](/agents/monitor/conversation-history), which uses `session_id` / `started_at` / `ended_at` for the same facts. `call.ended` adds `ended_reason` and `llm_endpoint_errors`. `call.analyzed` adds the `analysis` entity and the transcript `items`. `phone_call.dial_finished` adds top-level `dial_status` and `answered_by`. The `session` object is the same on every event. The `phone_call.dial_finished` example shows it in full, the others are shortened. ```json call.ended { "event": "call.ended", "event_id": "evt_3f9c2a7b5d1e4f60a8b9c0d1e2f3a4b5", "session": { "id": "5f3e…", "agent_id": "a1b2…", "branch_id": "b7d4…", "source": "phone", "status": "completed", "end_reason": "user_hangup", "conversation_started_at": "2026-07-23T12:01:12Z", "conversation_ended_at": "2026-07-23T12:04:16Z", "duration_seconds": 184, "end_user_id": "customer-42", "metadata": { "order_ref": "SO-1042" }, "agent_name": "Support agent", "config_hash": "sha256:9c41…" }, "ended_reason": "user_hangup", "llm_endpoint_errors": [] } ``` ```json call.analyzed { "event": "call.analyzed", "event_id": "evt_8a41d0c97e2b4f13b6c5a9d8e7f60123", "session": { "id": "5f3e…", "agent_id": "a1b2…", "branch_id": "b7d4…", "source": "phone", "status": "completed", "conversation_started_at": "2026-07-23T12:01:12Z", "conversation_ended_at": "2026-07-23T12:04:16Z", "duration_seconds": 184, "end_user_id": "customer-42", "metadata": { "order_ref": "SO-1042" }, "agent_name": "Support agent", "config_hash": "sha256:9c41…" }, "analysis": { "status": "completed", "summary": "Caller asked about a delayed order and accepted a reshipment.", "data": [ { "name": "callback_requested", "type": "boolean", "value": false, "rationale": "…" } ], "criteria_results": [ { "name": "issue_resolved", "result": "success", "rationale": "…" } ] }, "items": [ { "type": "message", "role": "assistant", "content": "Hi, this is Acme support. How can I help?", "turn_id": 1, "created_at": "2026-07-23T12:01:13Z" }, { "type": "message", "role": "user", "content": "My order SO-1042 hasn't arrived yet.", "turn_id": 2, "created_at": "2026-07-23T12:01:18Z" } ] } ``` ```json phone_call.dial_finished { "event": "phone_call.dial_finished", "event_id": "evt_c2e7f9a14b3d4e58a0f1b2c3d4e5f607", "dial_status": "answered", "answered_by": "human", "session": { "id": "5f3e…", "agent_id": "a1b2…", "branch_id": "b7d4…", "source": "phone", "status": "active", "direction": "outbound", "dial_status": "answered", "answered_by": "human", "caller_number": "+15551234567", "dialed_number": "+14155550100", "sip_call_id": "7f3a9c@sbc.example.com", "call_sid": "", "batch_call_id": null, "conversation_started_at": "2026-07-23T12:01:12Z", "conversation_ended_at": null, "duration_seconds": null, "end_user_id": "", "metadata": { "lead_ref": "L-2041" }, "agent_name": "Follow-up agent", "config_hash": "sha256:9c41…" } } ``` `event_id` is `evt_` followed by 32 hex characters. It identifies one event. Every endpoint and every retry of that event gets the same value, and it is also sent in the `X-Fish-Webhook-Id` header. Use it to drop duplicates. See [Idempotency](#idempotency). `items` on `call.analyzed` is the same timeline as `items` in [`GET /v1/agent/sessions/{session_id}`](/agents/monitor/conversation-history#get-a-session): messages, tool calls, tool results, and transfers, in the order they happened. See [Item types](/agents/monitor/conversation-history#item-types) for the fields. `items` is `[]` when the session's content was already deleted by data retention, and `null` when it could not be read. In that case, fetch the session from the sessions API. `ended_reason` is the same value as `session.end_reason` and uses the [sessions API vocabulary](/agents/monitor/conversation-history#status-and-end-reason): `user_hangup`, `agent_hangup`, `escalated`, `conversation_timeout`, `heartbeat_timeout`, `agent_join_timeout`, `internal_error`, or `llm_endpoint_failure`. `call.ended` is only sent for sessions in which a participant connected, so the never-connected reasons (`room_join_timeout`, `cancelled`, `dial_failed`) do not appear here; query the sessions API with `status=failed` for those. A caller who connected and waited for an agent that never joined does produce `call.ended`, with `session.status` set to `failed` and `ended_reason` set to `agent_join_timeout`. In the rare case the reason is not settled yet at delivery time, both fields are `null`. New values may be added; treat unrecognized values as informational rather than rejecting the event. `call.ended` also carries `llm_endpoint_errors`. It lists the failures of your [custom LLM](/agents/build/custom-llm#see-what-your-endpoint-returned) endpoint during the session, in the same shape as the [sessions API](/agents/monitor/conversation-history#llm-endpoint-errors). It is empty on platform models. It is `null` in the rare case the list could not be read at delivery time. `dial_status` is `answered`, `busy`, `no_answer`, or `failed`. `answered_by` is deprecated: answering-machine detection has been removed, so it is `unknown` once the call is answered and `null` before (older events may carry `human` or `voicemail`). Both facts also appear on the session, alongside `direction` (`inbound` or `outbound`) and `batch_call_id` (reserved, always `null` today); these session fields are present in every webhook payload, with the dial fields `null` on inbound sessions. The `phone_call.dial_finished` snapshot is taken when the dial resolves, so on an answered call `conversation_ended_at` and `duration_seconds` are still `null`. The final numbers arrive with `call.ended`. `caller_number`, `dialed_number` and `sip_call_id` carry the same values as on the [sessions API](/agents/monitor/conversation-history): the E.164 numbers of the call (person-centric on outbound calls, so `caller_number` is the party you dialed) and the SIP `Call-ID` of the call's INVITE. All three are empty strings on web sessions, and `sip_call_id` is also empty on an unanswered outbound dial. How to use it against your carrier's records: [Match calls with your SIP records](/agents/telephony/byo-sip#match-calls-with-your-sip-records). `call_sid` is the carrier's id of the call. Today that is Twilio's CallSid on an inbound call through a Twilio number. It is the same value the [inbound call webhook](#inbound-call-webhook) request carries, so you can join that request to the session. It is an empty string on web sessions, outbound calls, and calls through your own SIP trunk. `end_user_id` and `metadata` are echoed exactly as you set them when creating the session. Use them to correlate the event with records in your own system. `branch_id` is an internal configuration-lineage identifier and is safe to ignore. What lands in `summary`, `data`, and `criteria_results` is defined by your [analysis configuration](/agents/monitor/post-call-analysis). The example shows a `completed` analysis. `analysis.status` can also be `skipped` (nothing to analyze: the caller never spoke, or analysis is disabled) or `error` (the run failed, with the cause in `analysis.error`); both arrive with `summary: null` and empty `data` / `criteria_results`. Check the status before reading results. ## Verify the signature When an entry has a `secret`, every request to that endpoint carries a signature header with the send time and an HMAC-SHA256 of `{timestamp}.{raw_body}`, keyed with that entry's own secret: ```http X-Fish-Webhook-Signature: t=1784808000,v1=8693a4b9… ``` Every post-call request also carries the event's `X-Fish-Webhook-Id` header, the same value as `event_id` in the body. The body is what is signed, so trust `event_id` in a verified body over the header. `t` is the Unix time (seconds) the request was sent. Each attempt is signed again with its own timestamp over the same body. `v1` is the hex HMAC. Verify in three steps: 1. Parse `t` and `v1` from the header. 2. Recompute HMAC-SHA256 over the string `{t}.` followed by the **raw request body bytes** (before any JSON parsing or re-serialization) and compare against `v1` in constant time. 3. Reject requests whose `t` is more than 5 minutes from your clock. Because the timestamp is inside the MAC, a replayed capture can't be refreshed. ```javascript Node.js import crypto from "node:crypto"; const TOLERANCE_SECONDS = 300; function verifyWebhook(rawBody, signatureHeader, secret) { const elements = new Map( (signatureHeader ?? "").split(",").map(el => el.split("=", 2)) ); const timestamp = elements.get("t"); const signature = elements.get("v1"); if ( !/^\d+$/.test(timestamp ?? "") || !/^[0-9a-f]{64}$/.test(signature ?? "") ) { return false; } if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) { return false; } const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.`) .update(rawBody) .digest("hex"); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` ```python Python import hashlib import hmac import time TOLERANCE_SECONDS = 300 def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: elements = dict( el.split("=", 1) for el in (signature_header or "").split(",") if "=" in el ) timestamp = elements.get("t", "") signature = elements.get("v1", "") if not timestamp.isdigit(): return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(signature.encode(), expected.encode()) ``` Reject requests with a missing or invalid signature. Without verification, anyone who discovers your endpoint URL can forge call results. Future scheme revisions would ship under a new element (`v2=…`) alongside `v1`. Parse the elements you know and ignore the rest, as the snippets above do. ## Delivery semantics | Property | Behavior | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fan-out | Every configured endpoint receives every event | | Guarantee | At-least-once, per endpoint | | Timeout | 10 seconds per attempt | | Retries | Up to 6 after the first attempt (7 attempts total) per endpoint, after 30 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and 10 hours. That is about 18 hours. Then that delivery is dropped | | Ordering | First attempts go out as `phone_call.dial_finished` (outbound only), then `call.ended`, then `call.analyzed` for the same session. Retries can reorder them | Endpoints are delivered in parallel and independently: each gets its own attempts, its own retry budget, and its own signature keyed with its own secret. An endpoint that is down and exhausts all seven attempts has no effect on the others. Respond with a `2xx` status within the timeout. Acknowledge first and process asynchronously. Slow handlers burn their own retry budget. What happens to an attempt that does not get a `2xx`: | Result of the attempt | Effect | | -------------------------- | ------------------------------------- | | Timeout or network error | Retried on the schedule above | | `408`, `429`, or any `5xx` | Retried on the schedule above | | Any other `4xx` | Not retried. That delivery is dropped | Deliveries can arrive more than once, and a retry can arrive after a later event for the same session. Don't assume `call.analyzed` arrives after `call.ended`. [Preview calls](/agents/test/preview-calls) made from the Builder never trigger webhooks. To test end to end, run a real session against your published agent. ### Idempotency At-least-once delivery means the same event can arrive more than once. Retries of one event carry an identical body, so store each `event_id` you process and drop any event whose `event_id` you have already seen. A skipped or failed analysis can be re-run from the console. The recovered result arrives as a new `call.analyzed` with a new `event_id` and a newer `analysis.finished_at`. It supersedes the earlier one, so keep the `call.analyzed` with the latest `finished_at` for each `session.id`. ### Forward compatibility Payloads change additively. This applies to every webhook on this page, including the [inbound call webhook](#inbound-call-webhook) request. - New fields can appear at any time, at the top level and inside `session`, `analysis` and `call`. They come without a new event name. - Published fields never change name, type or meaning, and are never removed. - Enum fields such as `status` and `ended_reason` can gain new values. Parse payloads without rejecting unknown fields. A receiver that validates against a strict schema breaks when a field is added. Treat an enum value you don't recognize as a generic case instead of failing. ## Inbound call webhook `webhooks.conversation_init` is a single endpoint called while an [inbound phone call](/agents/telephony/inbound-calls) is being set up, before the agent answers. It receives the caller's number and returns `dynamic_variables` that fill the `{{placeholders}}` in the agent's published prompt and first message, so the agent can greet a known customer by name or pick up an open order. In the console it is the **Inbound call webhook** on the agent's Webhooks page. Only inbound phone calls use it. Web sessions and outbound calls carry their [dynamic variables](/agents/build/dynamic-variables) on the request that creates them, and [preview calls](/agents/test/preview-calls) never call it. ```bash Configure curl --request PATCH https://api.fish.audio/v1/agent/agents/YOUR_AGENT_ID/config \ --header "Authorization: Bearer $FISH_API_KEY" \ --header "Content-Type: application/json" \ --data '{ "webhooks": { "conversation_init": { "url": "https://example.com/fish-inbound", "secret": "your-signing-secret", "timeout_seconds": 5 } } }' ``` | Field | Rules | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `url` | Required. Same rules as a `post_call` URL: up to 4000 characters, public address only | | `secret` | Optional signing key, up to 256 characters. Write-only: reads return `has_secret`. When set, requests carry the same [signature header](#verify-the-signature) as post-call events | | `timeout_seconds` | How long the platform waits for your response before connecting the call without variables. `1` to `8`, default `5` | Send `"conversation_init": null` to remove the endpoint. Like the rest of the configuration, the change applies to phone calls after the next [publish](/agents/deploy/versions-publishing). ### Request The platform sends one `POST` with a JSON body: ```json call.inbound { "event": "call.inbound", "call": { "caller_number": "+15551234567", "dialed_number": "+14155550100", "agent_id": "a1b2…", "phone_number_id": "7e9f…", "call_sid": "CA5d3a…" } } ``` `caller_number` and `dialed_number` are E.164. `caller_number` is an empty string when the carrier withholds the caller's number, and `call_sid` is empty when the call did not come through a Twilio number. To match this request with the call, keep `call_sid`. Once the call is connected, `GET /v1/agent/sessions?call_sid=…` returns its session, and post-call webhooks carry the same value. This works only when `call_sid` is not empty, that is for calls through a Twilio number. During the call, webhook tools can send the session id with `{{system.session_id}}`. New fields can be added to this request, see [Forward compatibility](#forward-compatibility). ### Response Respond with a `2xx` status and a JSON object whose `dynamic_variables` follow the same rules as on session creation: names match `[A-Za-z][A-Za-z0-9_]*`, values are strings (up to 100,000 characters), numbers, or booleans, and at most 500 entries totaling 1 MiB are read. ```json Response { "dynamic_variables": { "customer_name": "Ada", "plan": "Pro", "open_orders": 2 } } ``` An entry that breaks a rule is dropped on its own; the call still connects with the rest. Other fields in the response are ignored, so the contract can grow without breaking deployed endpoints. A placeholder that receives no value takes the agent's configured [default value](/agents/build/dynamic-variables#default-values), and follows the agent's `on_missing` setting when there is none. ```text System prompt You are the support line for Acme. The caller is {{customer_name}} on the {{plan}} plan with {{open_orders}} open orders. Greet them by name. ``` ### Failure behavior The endpoint can personalize a call but never block one. The caller is waiting while the request runs, so keep the handler fast and the timeout short. Whenever the call connects without your variables, every placeholder falls back to its configured [default value](/agents/build/dynamic-variables#default-values), so give each one a default that reads well on its own. | Outcome | Effect | | -------------------------------------- | -------------------------------------------------------------------------------- | | Response within the timeout | Variables are rendered into the prompt, first message, and webhook tool requests | | Timeout | The call connects on the defaults; the request is not retried | | Connection error or non-`2xx` response | One immediate retry, then the call connects on the defaults | | Body is not a JSON object | The call connects on the defaults | ## Auto-ticket unresolved calls `call.analyzed` closes the loop on conversations the agent couldn't: judge every call with a success criterion, and open a ticket in your helpdesk whenever the verdict isn't `success`. First give the agent's [analysis configuration](/agents/monitor/post-call-analysis) a criterion that captures resolution: ```json analysis.criteria [ { "name": "issue_resolved", "description": "The agent fully resolved the caller's issue, or set clear next steps before the call ended." } ] ``` Then add your endpoint to `webhooks.post_call` (see [Configure the endpoint](#configure-the-endpoint)) and act on the verdict: ```javascript Node.js (Express) import express from "express"; const app = express(); const seen = new Set(); // swap for your database app.post( "/fish-webhooks", express.raw({ type: "application/json" }), (req, res) => { const signature = req.get("X-Fish-Webhook-Signature"); if (!verifyWebhook(req.body, signature, process.env.FISH_WEBHOOK_SECRET)) { return res.status(401).end(); } res.status(200).end(); // acknowledge first, then process const payload = JSON.parse(req.body); if (payload.event !== "call.analyzed") return; if (seen.has(payload.event_id)) return; // at-least-once delivery: dedupe seen.add(payload.event_id); if (payload.analysis.status !== "completed") { // skipped (the caller never spoke) or error: no verdict to trust. openTicket({ subject: `Unjudged call ${payload.session.id}`, body: `Analysis ${payload.analysis.status}` + (payload.analysis.error ? `: ${payload.analysis.error}` : ""), customer: payload.session.end_user_id, }); return; } const verdict = payload.analysis.criteria_results.find( c => c.name === "issue_resolved" ); if (!verdict || verdict.result === "success") return; openTicket({ subject: `Unresolved call ${payload.session.id}`, body: `${payload.analysis.summary}\n\n` + `issue_resolved: ${verdict.result}. ${verdict.rationale}`, customer: payload.session.end_user_id, }); } ); ``` ```python Python (FastAPI) import json import os from fastapi import FastAPI, Header, Request, Response app = FastAPI() seen: set[str] = set() # swap for your database @app.post("/fish-webhooks") async def fish_webhooks( request: Request, x_fish_webhook_signature: str | None = Header(None), ): raw = await request.body() secret = os.environ["FISH_WEBHOOK_SECRET"] if not verify_webhook(raw, x_fish_webhook_signature, secret): return Response(status_code=401) payload = json.loads(raw) if payload["event"] != "call.analyzed": return {} if payload["event_id"] in seen: # at-least-once delivery: dedupe return {} seen.add(payload["event_id"]) analysis = payload["analysis"] if analysis["status"] != "completed": # skipped (the caller never spoke) or error: no verdict to trust. detail = f": {analysis['error']}" if analysis.get("error") else "" open_ticket( subject=f"Unjudged call {payload['session']['id']}", body=f"Analysis {analysis['status']}{detail}", customer=payload["session"]["end_user_id"], ) return {} results = analysis["criteria_results"] verdict = next((c for c in results if c["name"] == "issue_resolved"), None) if verdict and verdict["result"] != "success": open_ticket( subject=f"Unresolved call {payload['session']['id']}", body=f"{analysis['summary']}\n\n" f"issue_resolved: {verdict['result']}. {verdict['rationale']}", customer=payload["session"]["end_user_id"], ) return {} ``` `verifyWebhook` is the function from [Verify the signature](#verify-the-signature); `openTicket` stands in for your helpdesk's API. Escalating on anything but `success` includes `unknown` verdicts: the model couldn't judge the call, which usually deserves human eyes too. Tighten the check to `failure` only if unknowns prove noisy. Edges worth handling: - Calls with nothing to analyze arrive with `analysis.status: "skipped"`. The handler above tickets them as unjudged, so every call reaches the helpdesk without also watching `call.ended`. Drop that branch if silent calls don't belong in your queue. - To include the transcript in the ticket, read it from `payload.items`. When `items` is `null`, fetch `GET /v1/agent/sessions/{session_id}` from your handler instead. See [conversation history](/agents/monitor/conversation-history). - Set `end_user_id` and `metadata` when creating sessions so tickets attach to the right customer record without a lookup. Post-call ticketing is silent: the caller has already hung up. When the caller should leave the call holding a ticket number, have the agent open it mid-call with a [webhook tool](/agents/build/webhook-tools#escalate-to-a-ticket-mid-call), and keep this recipe as the safety net behind it. ## Going further Define the summary, data fields, and criteria that `call.analyzed` delivers. Fetch the transcript, tool timeline, and recordings by `session_id`. How configuration changes (including webhooks) go live. The full config schema behind `PATCH /v1/agent/agents/{agent_id}/config`.