Skip to content

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

  1. Create a Slack incoming webhook on your workspace, copy the URL.
  2. export SLACK_WEBHOOK_URL='https://hooks.slack.com/services/...'
  3. Run the app. POST /tickets with 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 resumeWithApproval will run the node again. Either short-circuit when cp.interrupt() == null (already resumed) or make the downstream node idempotent.
  • Reject path. Add GET /reject/{runId}/{node} that calls store.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.