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:
| Field | What it decides |
|---|---|
name | The URL (/webhooks/issue-triage) and which credential unlocks it |
agentId | Which agent wakes, by id or by name |
promptTemplate | The entire instruction. {{payload}} is where the body lands, quoted as data |
disclosure | The 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:
| Field | What it decides |
|---|---|
signature | Authenticate by a signature over the body instead of a bearer token. See step 6 |
deliveryIdHeader | The header naming each delivery, so a repeat runs nothing. Defaults to GitHub’s X-GitHub-Delivery |
budget | maxTokens, maxCostUSD and maxDurationMs for each run this door starts, including one resumed after an approval |
maxConcurrentRuns | How 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 secretprinted - 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:
-
disclosureis 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.publicis less,privateis the most an external caller can ever hold, andnonereaches nothing. -
allowis 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
| Response | Meaning |
|---|---|
401 unauthorized | No 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 received | This delivery id or signature already ran. Send a new event |
400 ... is not threaded | A thread key was sent to an ephemeral door |
413 | Body over 1 MiB |
429 | maxConcurrentRuns runs are already in flight. Retry after Retry-After seconds |
500 the run failed | The run itself failed. The cause is in the daemon’s log, never in the answer |
202 input-required | The run needs an approval. See the allow list above |
Next
- Webhooks: the concept, and when to prefer a peer
- Surface access: before you expose the daemon
- Unattended runs: approvals when nobody is watching
jazz webhook: the token and secret commands in full