Approval requests¶
An agent about to do something a person should own (delete data, issue a refund, publish a draft, email a thousand customers) can ask first. Cue finds the people, tells them on their own channels with a one-time link, collects the decision and reports it back.
A held message is about something Cue itself sends. An approval request is about an action that happens outside Cue.
POST /v1/approvals
{
"title": "Delete 1,204 inactive accounts?",
"body": "Accounts with no login since 2024. This cannot be undone.",
"reviewers": ["lead", "deputy"],
"importance": "high",
"expires_in_seconds": 3600,
"metadata": {"workflow_id": "wf-42"}
}
| Field | Default | |
|---|---|---|
title |
— | What needs a decision (up to 200 characters). |
body |
"" |
Details the reviewer sees (up to 4,000 characters). |
reviewers |
— | External ids of the people who may decide (1–10). |
importance |
high |
Capped by an agent's max_importance. |
expires_in_seconds |
3600 |
1 minute to 7 days. |
metadata |
{} |
Returned with the decision (up to 16 KB), e.g. a flow or workflow id. |
Any key with messages:write can ask. An agent key may only name the people in its
agent's reviewers list, so it cannot use approval notifications to reach anyone else.
The response is the request, with status: "pending".
How reviewers hear about it¶
For each reviewer, Cue reports a cue.approval_requested event (source cue), the same
event used for held messages, with data.kind set to
"action". One rule and template serve both:
"en": {"title": "{{ data.agent_name }} needs your OK",
"body": "{{ data.title }}",
"url": "{{ data.review_url }}"}
data holds kind, approval_id, agent, agent_name, title, body (the first 500
characters), importance, expires_at and review_url. The review link has the same
guarantees as for messages:
- Personal: each reviewer gets their own link.
- One-time: the first decision closes all of them.
- Safe to open: opening the link changes nothing; only the buttons act.
- Not stored: Cue keeps only a SHA-256 hash of the token.
A reviewer can add a comment; it is returned with the decision.
Getting the decision¶
status becomes approved, rejected or expired. A request nobody decided on before
expires_at is expired: treat it as a no.
Callback (recommended). Configure where decisions go:
[approvals]
callback_url = "https://app.example.com/internal/cue/decisions"
# secret via CUE_APPROVALS__CALLBACK_SECRET
Cue POSTs each decision there, signed with the Cue-Signature header like its
webhooks, so the SDKs' verify_webhook / verifyWebhook checks it.
Failed callbacks are retried with backoff.
{"type": "approval.decided", "id": "…", "status": "approved", "title": "…",
"agent": "ops-bot", "decided_by": "link:lead", "comment": "go ahead",
"decided_at": "2026-10-11T08:00:00Z", "metadata": {"workflow_id": "wf-42"}}
The URL comes from your configuration, never from the request, so an agent cannot point Cue at an internal address.
Polling. You can also call GET /v1/approvals/{id} until status is not pending.
API decisions. A person can also decide through the API, with any key not bound to an agent:
POST /v1/approvals/{id}/approve {"reason": "go ahead"}
POST /v1/approvals/{id}/reject {"reason": "not this week"}
Agent keys can never decide, and they see only their own requests.
From agents¶
MCP. The request_approval and get_approval tools let Claude, Cursor or your own
agent ask before acting. Its instructions tell it to treat anything but approved as no.
SDKs.
asked = cue.request_approval(
"Refund order 9?", ["lead"], body="Never arrived.", metadata={"order": "9"}
)
cue.get_approval(asked["id"])["status"]
const asked = await cue.requestApproval({ title: "Refund order 9?", reviewers: ["lead"] });
(await cue.getApproval(asked.id as string)).status;
CrewAI Flows¶
@human_feedback waits for console input by default. CueFeedbackProvider asks the
reviewers through Cue instead and pauses the flow; CrewAI persists it. When the decision
arrives at your callback, resume the flow:
import json
from crewai.flow import Flow, human_feedback, listen, start
from cue_client import Cue, verify_webhook
from cue_client.crewai import CueFeedbackProvider, resume_args
provider = CueFeedbackProvider(Cue(CUE_KEY, CUE_URL), reviewers=["lead"])
class PublishFlow(Flow):
@start()
@human_feedback(
message="Publish this post?",
emit=["approved", "rejected"],
llm="gpt-4o-mini",
provider=provider,
)
def draft(self):
return "Cue 0.2 is out…"
@listen("approved")
def publish(self, result): ...
# The approvals.callback_url handler:
async def on_decision(body: bytes, signature: str):
verify_webhook(body, signature, CALLBACK_SECRET)
flow_id, feedback = resume_args(json.loads(body))
await PublishFlow.from_pending(flow_id).resume_async(feedback)
The feedback reads "Approved." or "Rejected. <comment>", which CrewAI's emit turns
into an outcome. Tested with CrewAI 1.15.
Dapr Agents¶
A DurableAgent hook returning RequireApproval publishes an ApprovalRequiredEvent
and waits for an ApprovalResponseEvent. A small service subscribed to the request topic
hands it to Cue, and publishes Cue's decision back:
from cue_client import AsyncCue, verify_webhook
from cue_client.dapr import approval_request, approval_response
cue = AsyncCue(CUE_KEY, CUE_URL)
@app.post("/approval-requests") # Dapr subscription, request topic
async def on_request(cloud_event: dict):
await cue.request_approval(**approval_request(cloud_event["data"], ["oncall"]))
@app.post("/cue/decisions") # Cue's approvals.callback_url
async def on_decision(request: Request):
body = await request.body()
verify_webhook(body, request.headers.get("Cue-Signature"), CALLBACK_SECRET)
await dapr.publish_event(
"pubsub", "agent-approval-responses", json.dumps(approval_response(json.loads(body)))
)
The request expires when Dapr's own timeout_seconds runs out, so nobody approves a
step the workflow has already denied. Checked against dapr-agents 1.0.9's event schemas.
LangGraph¶
A node calls interrupt(...) and the graph pauses on its checkpointer. Hand the pending
interrupt to Cue and resume the thread with the decision:
from langgraph.types import interrupt
from cue_client.langgraph import approval_request, resume
def delete_accounts(state):
decision = interrupt({"title": "Delete 1,204 inactive accounts?", "body": "…"})
if not decision["approved"]:
return {"result": f"kept: {decision['comment']}"}
...
result = graph.invoke(inputs, config)
for pending in result.get("__interrupt__", []):
cue.request_approval(**approval_request(pending, thread_id, ["lead"]))
# The approvals.callback_url handler, after verify_webhook(...):
thread_id, command = resume(json.loads(body))
graph.invoke(command, {"configurable": {"thread_id": thread_id}})
The node receives {"approved", "status", "comment", "decided_by"}. Tested with
LangGraph 1.2.
Pydantic AI¶
Tools registered with requires_approval=True end a run with DeferredToolRequests. Ask
through Cue, keep the messages, and continue with the decisions:
from pydantic_ai import Agent, DeferredToolRequests
from cue_client.pydantic_ai import approval_requests, deferred_results
agent = Agent(model, output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def refund(order_id: str) -> str: ...
result = agent.run_sync("Refund order 9")
if isinstance(result.output, DeferredToolRequests):
save(conversation_id, result.all_messages())
for request in approval_requests(result.output, conversation_id, ["lead"]):
cue.request_approval(**request)
# When every pending call is decided:
agent.run_sync(
message_history=load(conversation_id),
deferred_tool_results=deferred_results(decisions),
)
A rejected call reaches the model as a denial carrying the reviewer's comment. Tested with
Pydantic AI 2.55. Pydantic AI can also use Cue's MCP server directly
(MCPServerStdio("uvx", ["cue-notify", "mcp"])), which gives the agent the
request_approval tool.