Approval via Slack — async, non-blocking¶
The point of ApprovalGate is not "block the workflow until a human clicks". When the gate fires, the run persists a checkpoint and exits. Your code is free to notify whatever channel you want; the agent is parked, not waiting on a thread.
This recipe wires ApprovalGate to a Slack message with an "Approve" link. Total surface: one @RestController, one notifier, ~30 lines of code.
How it flows¶
ticket comes in → graph.invoke() → gate fires on "refund.process"
→ checkpoint saved
→ AgentResult.interrupted(ApprovalRequest)
→ Slack message: "Approve refund? <link>"
process exits, no thread held
human clicks link → GET /approve/{runId}/{node}
→ graph.resumeWithApproval(runId, node)
→ refund.process runs from the checkpoint
→ customer gets the refund
The whole thing¶
@SpringBootApplication
@RestController
public class SupportApp {
private final AgentGraph graph;
private final SlackNotifier slack;
SupportApp(AgentGraph graph, SlackNotifier slack) {
this.graph = graph;
this.slack = slack;
}
@PostMapping("/tickets")
String submit(@RequestBody String body) {
String runId = UUID.randomUUID().toString();
AgentResult result = graph.invoke(AgentContext.of(body), runId);
if (result.isInterrupted()) {
ApprovalRequest req = (ApprovalRequest) result.interrupt().payload();
slack.notify(runId, req.nodeName(), req.reason());
return "pending approval: " + runId;
}
return result.text();
}
@GetMapping("/approve/{runId}/{node}")
String approve(@PathVariable String runId, @PathVariable String node) {
AgentResult result = graph.resumeWithApproval(runId, node);
return result.completed() ? "done: " + result.text() : "still running";
}
@Bean
AgentGraph graph(InMemoryCheckpointStore store) {
return AgentGraph.builder()
.addNode("triage", triageAgent)
.addNode("refund.process", refundAgent)
.addEdge("triage", "refund.process")
.approvalGate(ApprovalGate.requireFor("refund.process"))
.checkpointStore(store)
.build();
}
}
@Component
class SlackNotifier {
private final String webhook = System.getenv("SLACK_WEBHOOK_URL");
private final RestClient rest = RestClient.create();
void notify(String runId, String node, String reason) {
String url = "https://your-app.example/approve/" + runId + "/" + node;
String msg = "*Approval needed* — `" + node + "`\n_" + reason + "_\n<" + url + "|Approve>";
rest.post().uri(webhook)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("text", msg))
.retrieve()
.toBodilessEntity();
}
}
That is the entire integration. No background queue, no separate worker, no UI. The "approval" is one HTTP request from Slack to your app.
Wiring it up¶
- Create a Slack incoming webhook on your workspace, copy the URL.
export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'- Run the app. POST
/ticketswith a refund request — Slack pings you. Click the link, the agent finishes.
Going further¶
The recipe stops at "it works". Three things to add when you put this in front of real customers:
- Authenticate the approve link. Wrap the URL with a signed token (JWT, HMAC) and check it on
/approve. Otherwise anyone with the link can approve. - Idempotency. If the human double-clicks, the second
resumeWithApprovalwill run the node again. Either short-circuit whencp.interrupt() == null(already resumed) or make the downstream node idempotent. - Reject path. Add
GET /reject/{runId}/{node}that callsstore.delete(runId)or transitions the workflow elsewhere. Right now "not approving" just means the checkpoint sits there.
For richer Slack UX (block kit buttons, interactivity payloads), the same pattern applies — the only thing that changes is SlackNotifier. The graph side stays identical.
Why this design¶
This is the difference the early Reddit critique flagged: "synchronous approval kills enthusiasm". ApprovalGate was deliberately built so that:
- the agent's thread is never blocked waiting for a human
- the operator's latency is whatever your async channel costs (Slack, email, push), not idle compute
- the gate is opt-in per node — flows that don't need it pay nothing
See ApprovalGate docs for the full SPI.