Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Dispatch LangChain 1.0 chain/agent events to external systems — webhooks, Kafka, Redis Streams, SNS — via async fire-and-forget callbacks, subgraph-aware wiring, and HMAC-signed delivery with idempotency keys. Use when firing webhooks on tool calls, pushing telemetry to Kafka / Redis Streams, or fanning progress to multiple subscribers without blocking the chain. Trigger with "langchain webhook", "langchain event dispatch", "langchain callback kafka", "langchain pubsub", "langchain per-tool webh
.claude/skills/jeremylongshore-langchain-webhooks-events/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 46% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 12% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 144% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 92% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 136% | 0% |
A team wires per-tool webhook dispatch from their LangChain agent via FastAPI BackgroundTasks — analytics is always N seconds late because BackgroundTasks fire after the HTTP response closes, not during the stream (P60). Worse: the BaseCallbackHandler they attached via .with_config(callbacks=[h]) fires on the outer agent but is dark on the subagent's tool calls — custom callbacks are not inherited by LangGraph subgraphs (P28), they must be passed via config["callbacks"] at invoke time.
Pain-catalog anchors handled here:
with_config don't propagate to subgraphslangchain-langgraph-streaming)astream_events(v2) emits thousands of events; never forward rawinvoke() inside async endpoint blocks the event loopBackgroundTasks fire post-response; wrong for per-event dispatchThis skill walks through an async AsyncCallbackHandler with fire-and-forget dispatch, per-target sinks for HTTP / Kafka / Redis Streams / SNS, HMAC-signed delivery with 1s/5s/30s retry and DLQ, idempotency keys = run_id + event_type
, andconfig"callbacks"] wiring that makes subagent calls visible.Typical webhook latency budget: <500ms per event. Pin: langchain-core 1.0.x, langgraph 1.0.x. Scope: server-to-server dispatch only — UI streaming is in langchain-langgraph-streaming.
langchain-core >= 1.0, < 2.0, langgraph >= 1.0, < 2.0httpx >= 0.27 for async HTTP (or aiohttp)aiokafka, redis[hiredis] >= 5, aioboto3 (per target)Sync dispatch from a callback blocks the chain — a slow HTTP POST during on_tool_end serializes all downstream tokens behind it (P48). Use asyncio.create_task(...) so the dispatch runs alongside the chain:
pythonimport asyncio import uuid from typing import Any from langchain_core.callbacks import AsyncCallbackHandler class EventDispatchHandler(AsyncCallbackHandler): """Fire-and-forget dispatch to external sinks. IMPORTANT: subclass AsyncCallbackHandler (not BaseCallbackHandler) so on_* methods are awaited. Mixing sync and async handlers is a silent footgun — sync on_* blocks the event loop (P48). """ def __init__(self, sink, *, run_id: str | None = None): self.sink = sink # dispatch target — Step 3 self.run_id = run_id or str(uuid.uuid4()) self._tasks: set[asyncio.Task] = set() def _dispatch(self, event_type: str, payload: dict, step_index: int) -> None: # Fire-and-forget. Keep a strong reference so the task isn't GC'd # mid-flight (asyncio quirk — orphan tasks get garbage-collected). task = asyncio.create_task( self.sink.send( idempotency_key=f"{self.run_id}:{event_type}:{step_index}", event_type=event_type, payload=payload, ) ) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def on_tool_end(self, output: Any, *, run_id, parent_run_id=None, **kwargs): # 4000 char cap — keep payload under typical webhook body limits # while preserving enough context for downstream analytics. MAX_OUTPUT_CHARS = 4000 self._dispatch( "tool_end", {"output": str(output)[:MAX_OUTPUT_CHARS], "run_id": str(run_id)}, step_index=kwargs.get("tags", []).__len__() or 0, ) async def on_chain_end(self, outputs: dict, *, run_id, **kwargs): # Only named chains — skip the unnamed LCEL inner nodes (P47) name = kwargs.get("name") if not name or name.startswith("RunnableLambda"): return self._dispatch("chain_end", {"name": name, "run_id": str(run_id)}, step_index=0) async def drain(self, timeout: float = 5.0) -> None: """Call before process exit so in-flight dispatches complete.""" if self._tasks: await asyncio.wait(self._tasks, timeout=timeout)
See Async Callback Handler for the full handler — on_llm_end, filtering, sync-vs-async decision.
config so subgraphs inherit themP28: Runnable.with_config(callbacks=[h]) binds at definition and is not inherited by LangGraph subgraphs. Pass callbacks via config at invocation:
python# WRONG — subagent tool calls never fire the handler agent_with_handler = agent.with_config({"callbacks": [handler]}) await agent_with_handler.ainvoke({"messages": [...]}) # RIGHT — callbacks in config propagate into subgraphs await agent.ainvoke( {"messages": [...]}, config={"callbacks": [handler], "configurable": {"thread_id": "t1"}}, )
Validate propagation with a probe that counts events by kwargs["name"] and asserts the subagent's name appears. See Subgraph Propagation.
Match the event to the transport:
| Target | Delivery | Typical latency | Use when | Failure mode | |---|---|---|---|---| | HTTP webhook | At-least-once (with retry) | 50-500ms | Partner integrations, Zapier/Make, customer-owned endpoints | Endpoint 5xx → retry 1s/5s/30s → DLQ | | Kafka (aiokafka) | At-least-once (idempotent producer) | 5-20ms intra-region | High-volume telemetry, analytics fan-in | Broker unavailable → retry + local buffer | | Redis Streams (XADD) | At-least-once (consumer groups) | 1-5ms | Near-realtime worker queues, progress fan-out | Redis down → retry or spill to disk | | SNS | At-most-once (best-effort) | 10-100ms | Fan-out to multiple SQS/Lambda subscribers | Best-effort only; accept loss or front with SQS FIFO |
Handler stays provider-agnostic; only the sink changes. A minimal HTTP sink:
pythonimport hashlib, hmac, json, os import httpx WEBHOOK_URL = os.environ["WEBHOOK_URL"] SIGNING_SECRET = os.environ["WEBHOOK_SIGNING_SECRET"].encode() class WebhookSink: # 256-bit HMAC — industry-standard signature strength (GitHub, Stripe use same) SIG_ALG = hashlib.sha256 # Retry schedule: 1s absorbs transient blips, 5s absorbs brief 503s, # 30s absorbs autoscaler / cold-start incidents. Beyond 30s = stale event. RETRY_DELAYS = (1, 5, 30) REQUEST_TIMEOUT_S = 5.0 def __init__(self, client: httpx.AsyncClient): self.client = client async def send(self, *, idempotency_key: str, event_type: str, payload: dict) -> None: body = json.dumps({"event": event_type, "data": payload}, sort_keys=True).encode() sig = hmac.new(SIGNING_SECRET, body, self.SIG_ALG).hexdigest() headers = { "Content-Type": "application/json", "Idempotency-Key": idempotency_key, "X-Signature-256": f"sha256={sig}", } for delay in self.RETRY_DELAYS: try: resp = await self.client.post(WEBHOOK_URL, content=body, headers=headers, timeout=self.REQUEST_TIMEOUT_S) if 200 <= resp.status_code < 300: return if resp.status_code < 500 and resp.status_code != 429: return # 4xx (except 429) is not retryable except (httpx.TimeoutException, httpx.TransportError): pass await asyncio.sleep(delay) await self._dead_letter(idempotency_key, event_type, payload)
See Dispatch Targets for Kafka / Redis Streams / SNS sinks and per-target DLQ patterns.
astream_events(version="v2") emits thousands of events per invocation (P47). Never forward raw — dispatch only what the downstream consumes:
| Callback method | Typical decision | Why | |---|---|---| | on_llm_start | Skip | Prompt content often contains PII; low value without masking | | on_llm_new_token | Skip for dispatch (UI only) | 1 event per token; N/A to analytics | | on_llm_end | Dispatch for named chains only | Token usage, final response — high value, low volume | | on_chain_start | Skip (P47 noise) | LCEL emits one per inner runnable | | on_chain_end | Dispatch for named subgraphs only | Stage completion — what analytics cares about | | on_tool_start | Optional (dispatch for audit log) | Matters for compliance / tool-use audit | | on_tool_end | Dispatch always | The key analytics signal in agent flows | | on_agent_action | Dispatch | Cleaner signal than on_tool_start in agent graphs | | on_agent_finish | Dispatch | Terminal event for the run |
Rule of thumb: dispatch on_tool_end + named on_chain_end + on_llm_end. Everything else is noise.
At-least-once transports mean duplicates. Build the key deterministically:
python# run_id — unique per chain invocation (propagates into subgraphs) # event_type — on_tool_end / on_chain_end / on_llm_end # step_index — monotonic per-run counter you maintain in the handler idempotency_key = f"{run_id}:{event_type}:{step_index}"
Retry budget: 1s → 5s → 30s (~36s total) then DLQ. Retry on 5xx / 429 / network only — 4xx (except 429) goes straight to DLQ. DLQ is a Redis Stream or S3 prefix keyed by YYYY/MM/DD/run_id/idempotency_key.json; alarm on depth growth. See Idempotency and Retry for HMAC verify, at-least-once vs at-most-once, and 24h de-dup window sizing.
BackgroundTasks (P60)FastAPI BackgroundTasks run after the response closes — exactly wrong for per-event dispatch. Events must go out during the chain:
python# WRONG — events fire all at once after the stream ends @app.post("/chat") async def chat(req: Request, bg: BackgroundTasks): bg.add_task(agent.ainvoke, {"messages": [...]}) # late + no streaming return {"status": "accepted"} # RIGHT — handler fires during the chain, each on_tool_end dispatches immediately @app.post("/chat") async def chat(req: ChatReq): handler = EventDispatchHandler(sink=webhook_sink, run_id=req.run_id) try: result = await agent.ainvoke( {"messages": req.messages}, config={"callbacks": [handler], "configurable": {"thread_id": req.thread_id}}, ) finally: await handler.drain(timeout=5.0) # flush in-flight dispatches return {"result": result}
drain() awaits in-flight asyncio.create_task() dispatches up to 5s so events aren't lost when the pod scales down mid-request.
BaseCallbackHandler subclass with asyncio.create_task() fire-and-forgetconfig["callbacks"] at invoke time (subgraph-safe)run_id + event_type + step_indexon_tool_end + named on_chain_end + on_llm_enddrain() on shutdown to flush in-flight dispatches| Error | Cause | Fix | |-------|-------|-----| | Handler fires on outer agent but not subagent | Bound via with_config at definition (P28) | Pass via config["callbacks"] at invoke(...) time | | Webhook analytics lags generation duration | BackgroundTasks fire post-response (P60) | Dispatch from the callback handler, never from BackgroundTasks | | Browser / Kafka saturates on long generations | Forwarded astream_events(v2) raw (P47) | Filter events server-side; dispatch only on_tool_end/on_chain_end/on_llm_end | | SSE stream hangs, no end event | Proxy buffering (P46) | Set X-Accel-Buffering: no — see langchain-langgraph-streaming | | Event loop freezes on slow webhook | Sync POST in callback (P48) | Subclass AsyncCallbackHandler; use asyncio.create_task() | | Duplicate events downstream | At-least-once dispatch + retry | Receiver dedupes on Idempotency-Key header with 24h cache | | Orphan asyncio.create_task never runs | GC collected the task | Hold a strong reference in self._tasks and discard on completion | | Events lost on pod shutdown | In-flight tasks cancelled | Call await handler.drain(timeout=5.0) in endpoint finally block | | 4xx webhook errors retrying 3x | Retry logic retrying everything | Retry only on 5xx / 429 / network; 4xx goes straight to DLQ | | Signature verification fails on receiver | Body re-serialized with different key order | Sign the exact bytes you send; use sort_keys=True in json.dumps |
Planner subagent runs search_docs → summarize; outer agent needs a webhook on summarize completion. Full wiring in Subgraph Propagation.
CompositeSink dispatches to multiple child sinks via asyncio.gather(..., return_exceptions=True) — one sink's failure doesn't block others. See Dispatch Targets.
Verify X-Signature-256, SETNX Idempotency-Key against Redis with 24h TTL, 200 on both replay and first-seen. See Idempotency and Retry.
BaseCallbackHandler / AsyncCallbackHandler APIastream_events v2 events referenceXADD / consumer groupslangchain-langgraph-streaming (UI streaming), langchain-debug-bundle (callback-propagation debugging)docs/pain-catalog.md (entries P28, P46, P47, P48, P60)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 45,498 | 39,918 | -12% | 1 | 1 | 0% | 7,973 | 11,612 | +46% | 0 | 0 | — |
case-02 | fail→fail | 44,579 | 29,185 | -35% | 1 | 1 | 0% | 8,290 | 9,275 | +12% | 0 | 0 | — |
case-03 | fail→pass | 56,720 | 29,256 | -48% | 1 | 1 | 0% | 8,301 | 9,311 | +12% | 0 | 0 | — |
case-04 | pass→pass | 23,452 | 16,407 | -30% | 1 | 1 | 0% | 3,356 | 6,315 | +88% | 0 | 0 | — |
case-05 | fail→pass | 22,260 | 18,593 | -16% | 1 | 1 | 0% | 2,842 | 6,932 | +144% | 0 | 0 | — |
case-06 | fail→pass | 43,377 | 17,179 | -60% | 1 | 1 | 0% | 3,337 | 6,406 | +92% | 0 | 0 | — |
case-07 | fail→pass | 24,274 | 21,581 | -11% | 1 | 1 | 0% | 2,827 | 6,669 | +136% | 0 | 0 | — |
case-08 | fail→pass | 12,375 | 10,315 | -17% | 1 | 1 | 0% | 2,038 | 5,719 | +181% | 0 | 0 | — |
case-09 | pass→pass | 17,856 | 19,233 | +8% | 1 | 1 | 0% | 2,655 | 6,711 | +153% | 0 | 0 | — |
case-10 | pass→pass | 16,628 | 10,602 | -36% | 1 | 1 | 0% | 2,132 | 5,805 | +172% | 0 | 0 | — |
case-11 | fail→pass | 14,883 | 9,031 | -39% | 1 | 1 | 0% | 2,466 | 5,859 | +138% | 0 | 0 | — |
case-12 | pass→pass | 24,984 | 12,165 | -51% | 1 | 1 | 0% | 3,329 | 6,319 | +90% | 0 | 0 | — |
case-13 | pass→pass | 17,382 | 8,105 | -53% | 1 | 1 | 0% | 1,749 | 4,925 | +182% | 0 | 0 | — |
case-14 | pass→pass | 17,669 | 8,762 | -50% | 1 | 1 | 0% | 1,958 | 5,843 | +198% | 0 | 0 | — |
case-15 | pass→pass | 14,003 | 9,265 | -34% | 1 | 1 | 0% | 1,666 | 6,021 | +261% | 0 | 0 | — |
case-16 | pass→pass | 13,180 | 9,899 | -25% | 1 | 1 | 0% | 1,041 | 5,094 | +389% | 0 | 0 | — |
case-17 | pass→pass | 17,817 | 12,061 | -32% | 1 | 1 | 0% | 2,150 | 5,228 | +143% | 0 | 0 | — |
case-18 | pass→pass | 17,035 | 7,029 | -59% | 1 | 1 | 0% | 2,511 | 5,630 | +124% | 0 | 0 | — |
case-19 | pass→pass | 15,332 | 5,857 | -62% | 1 | 1 | 0% | 1,457 | 5,207 | +257% | 0 | 0 | — |
case-20 | fail→pass | 22,909 | 21,054 | -8% | 1 | 1 | 0% | 2,674 | 6,665 | +149% | 0 | 0 | — |
case-21 | pass→pass | 20,616 | 16,824 | -18% | 1 | 1 | 0% | 2,654 | 6,985 | +163% | 0 | 0 | — |
case-22 | pass→pass | 11,361 | 7,705 | -32% | 1 | 1 | 0% | 1,082 | 5,496 | +408% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +36 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.