docs blog library
github

Wake an agent from another system with a webhook

Create an authenticated issue-triage webhook, test it with curl, then connect it to GitHub. The webhook uses a fixed prompt and agent, with limits on tools and spend.

How requests run

One URL name bound to one agent and one prompt template, served by jazz daemon:

POST /webhooks/<name>  →  agent <agentId> runs <promptTemplate>, with the body quoted in

The caller chooses nothing except the payload. Not the agent, not the prompt, not the tools. The payload arrives inside the prompt between two fence lines drawn at random for each fire, so it cannot close the fence early and write text that reads as yours. It is data, never spliced in as an instruction, the same treatment web_fetch output and a peer’s reply get.

The run also gets none of your own context: no standing preferences from memory, no AGENTS.md, and no file attached because the payload names a path on your machine.

Use a peer for open-ended requests between agents.

1. Pick the agent

Any agent will do. Create one if you have none:

jazz agent create

Name it triage. Its model and persona are yours to choose; the webhook does not change them.

2. Define the door

Webhooks live in ~/.jazz/config.json under webhooks. Add one:

{
  "webhooks": [
    {
      "name": "issue-triage",
      "agentId": "triage",
      "description": "GitHub issues:opened, first-pass triage",
      "promptTemplate": "A GitHub issue was just opened. From the payload below, reply with exactly three lines: SEVERITY (low/medium/high), AREA (one word), and SUMMARY (one sentence a maintainer can act on). If the payload is not an issue event, reply 'ignored'.\n\n{{payload}}",
      "conversation": "ephemeral",
      "disclosure": "internal"
    }
  ]
}

Required fields and disclosure:

FieldWhat it decides
nameThe URL (/webhooks/issue-triage) and which credential unlocks it
agentIdWhich agent wakes, by id or by name
promptTemplateThe entire instruction. {{payload}} is where the body lands, quoted as data
disclosureThe ceiling on what the run may reveal. Defaults to internal when you leave it out

Four more are optional and bound what one sender can cost you:

FieldWhat it decides
signatureAuthenticate by a signature over the body instead of a bearer token. See step 6
deliveryIdHeaderThe header naming each delivery, so a repeat runs nothing. Defaults to GitHub’s X-GitHub-Delivery
budgetmaxTokens, maxCostUSD and maxDurationMs for each run this door starts, including one resumed after an approval
maxConcurrentRunsHow many runs may be in flight at once. Defaults to 4; past it the door answers 429 with Retry-After

Leave {{payload}} out and the payload is appended at the end instead, with the same quoting and less control over where it sits.

Specify the response format in promptTemplate if the caller needs to parse it.

3. Mint the token

Each door carries its own credential. For a caller you control, like the curl below, that is a bearer token. Jazz generates it, stores it in the OS keyring, and prints it exactly once:

jazz webhook token issue-triage

For the curl example, read the printed token into a shell variable without echoing it. Run this command, paste the token, and press Enter:

read -rs JAZZ_WEBHOOK_TOKEN

If you lose the token, mint a new one; this replaces the old token. On a host with no keyring (a container), set JAZZ_WEBHOOK_TOKEN_ISSUE_TRIAGE in the daemon’s environment instead.

The token never goes in config.json. It authenticates this webhook, not you. See what the caller can reach for why that distinction decides the tool ceiling.

4. Serve it

jazz daemon

/webhooks/ is served on every daemon; no flag turns it on. The default is http://127.0.0.1:4747, loopback-only. A webhook added while the daemon is running works on the next request, because the list is read per call. There is nothing to restart.

5. Fire it

curl -X POST http://127.0.0.1:4747/webhooks/issue-triage \
  -H "Authorization: Bearer $JAZZ_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"opened","issue":{"number":412,"title":"Timeouts on /export since 2.3.0","body":"Every export over ~50MB now 504s. Worked on 2.2.9."}}'

The request is held open until the run finishes, then answers:

{
  "ok": true,
  "answer": "SEVERITY: high\nAREA: export\nSUMMARY: Exports over ~50MB began returning 504 in 2.3.0 and worked in 2.2.9, so a regression in the export path needs bisecting between those releases.",
  "costUSD": 0.0041
}

costUSD is there so a caller can budget on spend rather than request count. When a price is unknown, costIncomplete: true rides alongside it rather than being folded in, so the figure is then a floor rather than a total.

6. Point GitHub at it

The daemon binds loopback by default, which GitHub cannot reach. Give it a public address first, a tunnel for a trial or a reverse proxy with TLS for anything lasting, and read Surface access before you bind anything but 127.0.0.1.

GitHub cannot send a bearer token. It signs every delivery instead: an HMAC-SHA256 of the body under a secret you share, in X-Hub-Signature-256. The secret itself never travels, so anyone who sees a delivery still cannot forge the next one. Tell the webhook to expect it:

{
  "name": "issue-triage",
  "agentId": "triage",
  "promptTemplate": "...",
  "signature": { "format": "hmac-sha256" }
}

Then mint the secret. Like the token, it goes to the OS keyring and is printed once:

jazz webhook secret issue-triage

For the signed curl test below, save the printed secret in your current shell:

read -rs JAZZ_WEBHOOK_SECRET

In a container, set JAZZ_WEBHOOK_SECRET_ISSUE_TRIAGE in the daemon’s environment instead.

In the repository’s Settings → Webhooks → Add webhook:

  • Payload URL: https://<your-host>/webhooks/issue-triage
  • Content type: application/json
  • Secret: the value jazz webhook secret printed
  • Events: Let me select individual events → Issues

A webhook with a signature ignores any Authorization header, and a body whose signature does not match, or that arrives with none, gets 401. Do not have the proxy add a bearer token: a proxy that attaches the credential to every request forwards anyone’s request as authenticated.

Once the webhook has a signature, the bearer token from step 3 no longer opens it. To test it with curl, sign the body the way GitHub does:

body='{"action":"opened","issue":{"number":412}}'
signature=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$JAZZ_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST http://127.0.0.1:4747/webhooks/issue-triage \
  -H "X-Hub-Signature-256: sha256=$signature" \
  -H "Content-Type: application/json" \
  -d "$body"

Other senders sign the same way under different names. signature.header and signature.prefix change where Jazz looks, for example { "format": "hmac-sha256", "header": "x-signature", "prefix": "" } for a bare hex digest.

Each delivery runs once. Jazz remembers the last deliveries per webhook on disk, by X-GitHub-Delivery (or your deliveryIdHeader) and by signature. GitHub does not sign its delivery id, so the signature is what stops a captured request from being replayed under a new one. A repeat answers 409 and runs nothing. That includes GitHub’s Redeliver button: to run an event again, trigger a new one.

Threaded doors, for an ongoing exchange

Use ephemeral (the default) for independent events. For a support thread or chat relay, use threaded and identify the conversation with X-Jazz-Thread. Create an agent named support with jazz agent create, then add this separate webhook to your configuration:

{
  "name": "support-relay",
  "agentId": "support",
  "promptTemplate": "Reply to the customer message below. {{payload}}",
  "conversation": "threaded"
}
jazz webhook token support-relay
read -rs SUPPORT_RELAY_TOKEN

curl -X POST http://127.0.0.1:4747/webhooks/support-relay \
  -H "Authorization: Bearer $SUPPORT_RELAY_TOKEN" \
  -H "X-Jazz-Thread: ticket-8812" \
  -d 'The refund still has not arrived.'

Same X-Jazz-Thread value, same conversation, so the agent remembers what was already said. Sending a thread key to an ephemeral door returns 400.

The key is at most 200 characters. Fires with no key share one conversation rather than getting a fresh one each time, so a threaded door never silently behaves like an ephemeral one.

Watching a long run

A webhook is one held-open request, so a turn that reads a calendar and searches the web is minutes of silence. A caller with somewhere to listen can say so:

curl -X POST http://127.0.0.1:4747/webhooks/issue-triage \
  -H "Authorization: Bearer $JAZZ_WEBHOOK_TOKEN" \
  -H "X-Jazz-Progress-Url: http://127.0.0.1:9099/progress" \
  -H "X-Jazz-Progress-Events: tool-started,tool-finished" \
  -d '{"action":"opened","issue":{"number":412}}'

Jazz POSTs each event as JSON to that URL while the run goes, then answers the original request as usual. The kinds are tool-started, tool-finished, and approval-required; omit the header to get all three. An invalid event kind returns 400.

The progress URL must be loopback.

What the caller can and cannot do

Webhook credentials grant access only to that endpoint. Set these two controls for each webhook:

  • disclosure is a ceiling on what an answer may reveal. internal (the default) is read-only tools that describe the shape of the machine (what exists, what is installed, what the web says) but not the contents of your files or your memory. public is less, private is the most an external caller can ever hold, and none reaches nothing.

  • allow is the separate question of damage. Disclosure says nothing about acting, so a tool that can act is admitted only by being named here, at any tier:

    {
      "name": "deploy-notify",
      "agentId": "release",
      "promptTemplate": "A deploy finished. Post a one-line summary to the team. {{payload}}",
      "disclosure": "internal",
      "allow": ["send_slack_message"]
    }

The send_slack_message example assumes you have configured a tool with that name through MCP or custom tools. Jazz does not ship a built-in Slack sender. Tools outside the effective list are unavailable to the run.

If the run does reach something needing approval, the fire returns 202 with a run id rather than hanging:

{ "ok": false, "state": "input-required", "runId": "run_01H...", "pending": "..." }

Approve it yourself later with jazz runs approve <runId>, or leave those tools out. The run resumes inside the same boundary it parked in: the same tool list, no operator context, and only the budget it had left.

What one sender can cost you. Without a budget, a webhook’s runs fall back to your app-wide maxTokens, maxCostUSD and maxDurationMs, which are unset by default. Set a separate limit for each webhook:

{
  "name": "issue-triage",
  "budget": { "maxCostUSD": 0.05, "maxDurationMs": 120000 },
  "maxConcurrentRuns": 2
}

When it does not work

ResponseMeaning
401 unauthorizedNo webhook by that name, or a missing, wrong, or un-minted credential. Jazz does not say which, so nobody can list your webhooks by guessing. Check the name, then re-mint the token or secret
409 already receivedThis delivery id or signature already ran. Send a new event
400 ... is not threadedA thread key was sent to an ephemeral door
413Body over 1 MiB
429maxConcurrentRuns runs are already in flight. Retry after Retry-After seconds
500 the run failedThe run itself failed. The cause is in the daemon’s log, never in the answer
202 input-requiredThe run needs an approval. See the allow list above

Next

machine-readable: /docs/guides/webhook-endpoint.md · /llms.txt · /llms-full.txt