Guides / Handle an approval
Handle an approval
When a mandate reaches an action that needs a person, it waits. This guide builds the other side of that wait: finding pending approvals, presenting one for review, and recording the decision safely.
An approval flow has three jobs: notice that a decision is needed, show the person exactly what would run, and submit their decision exactly once. Shadway makes the third job safe to retry.
Find pending approvals
For an inbox-style screen, list pending approvals across the workspace. Filter by mandate when the screen is scoped to one piece of work.
import { Shadway } from "shadway";
const shadway = new Shadway();
for await (const approval of shadway.approvals.list({ status: ["pending"] })) {
console.log(approval.id, approval.mandate, approval.question);
}client, err := shadway.New()
if err != nil {
log.Fatal(err)
}
for approval, err := range client.Approvals.List(ctx, &shadway.ApprovalListParams{
Status: []shadway.ApprovalStatus{shadway.ApprovalStatusPending},
}) {
if err != nil {
log.Fatal(err)
}
fmt.Println(approval.ID, approval.Mandate, approval.Question)
}To be notified instead of polling, subscribe a
webhook to mandate.approval_requested and
approval.resolved. The first tells you a decision is needed. The second tells
you it no longer is, because it was decided elsewhere, expired, or the mandate
was canceled.
Present the action
Show the person what the approval carries: the question, the exact action, and the deadline. Don't paraphrase the action. The screen exists so that a person sees exactly what would run.
const approval = await shadway.approvals.get(approvalId);
render({
question: approval.question,
capability: approval.action.capability,
consequence: approval.action.consequence,
arguments: approval.action.arguments, // Already redacted; safe to display.
expiresAt: approval.expiresAt,
});approval, err := client.Approvals.Get(ctx, approvalID)
if err != nil {
log.Fatal(err)
}
render(View{
Question: approval.Question,
Capability: approval.Action.Capability,
Consequence: approval.Action.Consequence,
Arguments: approval.Action.Arguments, // Already redacted; safe to display.
ExpiresAt: approval.ExpiresAt,
})An approval whose action.capability is input.provide is the agent asking a
person for information rather than permission. Render its fields as a small
form and pass the answers as inputs on approve. One screen handles both.
Record the decision
Authenticate the person and check their access to this mandate in your application first. Shadway records the decision your application submits.
import { ShadwayConflictError } from "shadway";
async function submitDecision(
approvalId: string,
decision: "approve" | "reject",
reason: string,
) {
try {
if (decision === "approve") {
return await shadway.approvals.approve(approvalId, { reason });
}
return await shadway.approvals.reject(approvalId, { reason });
} catch (error) {
if (error instanceof ShadwayConflictError) {
// Already decided the other way, expired, or otherwise not pending.
return shadway.approvals.get(approvalId);
}
throw error;
}
}func submitDecision(ctx context.Context, client *shadway.Client, approvalID, decision, reason string) (*shadway.Approval, error) {
var approval *shadway.Approval
var err error
if decision == "approve" {
approval, err = client.Approvals.Approve(ctx, approvalID, &shadway.ApprovalApproveParams{Reason: &reason})
} else {
approval, err = client.Approvals.Reject(ctx, approvalID, &shadway.ApprovalRejectParams{Reason: &reason})
}
if shadway.IsConflict(err) {
// Already decided the other way, expired, or otherwise not pending.
return client.Approvals.Get(ctx, approvalID)
}
return approval, err
}The conflict handling is what makes double-clicks and retries safe. Repeating
the same decision returns the original result. Only a contradicting decision
conflicts, and then the read shows what actually stands. A decision that lands
after expiry marks the approval expired instead of executing. Show that
outcome to the person rather than pretending it went through.
Wait inline instead
When your application drives one mandate and wants to pause at its decision point, skip the inbox and wait directly:
const approval = await shadway.mandate(mandateId).waitForApproval({ timeout: "10m" });
if (approval) {
// Present it, collect the decision, submit as above.
} else {
// The mandate reached a terminal status without asking.
}approval, err := client.Mandate(mandateID).WaitForApproval(ctx, shadway.WaitOptions{
Timeout: 10 * time.Minute,
})
if err != nil {
log.Fatal(err) // A timeout means no approval yet; check shadway.IsTimeout.
}
if approval != nil {
// Present it, collect the decision, submit as above.
} else {
// The mandate reached a terminal status without asking.
}Remember what a decision means: approving authorizes the one reviewed action, inside the mandate's unchanged limits, and rejecting refuses that action, not the mandate. If the whole task should stop, cancel the mandate.