Shadway

Core concepts / Approvals

Approvals

Sometimes a mandate reaches an action that needs a person to sign off before it can go ahead. That's an approval. The mandate waits until someone approves or rejects it.

Shadway captures the exact action it's about to take, a person reviews it, and the decision goes into the mandate's record either way.

Where approvals come from

You set up some approvals yourself. Others happen automatically:

  • authority.approval.requiredFor: actions that always need sign-off in this mandate, no matter the amount.
  • authority.spend.approvalThreshold: any single action above this amount needs sign-off, even if it's under the total spending limit.
  • Automatic: Shadway pauses an action on its own when it's risky and hard to undo, or when a costly action is based on outside data it hasn't confirmed.

Treat every pending approval as real, even ones you didn't set up. An approval you didn't ask for means Shadway paused the action on purpose, not that something went wrong.

What a person reviews

Approval · pendingPlace this order with Atlas Supply?
Proposed actionprocurement.create_po

Commits $7,900 with a supplier for 200 units of part #48-221, delivered Friday.

Arguments are shown redacted. Secret values never appear here.
Expires in 24 hours. A late decision is refused, never executed

The approval includes the question, the exact action it's about to take, and when the request expires. The action's arguments are shown with secrets hidden. Secret values never show up in an approval, in events, or in logs.

Present an approval and record the decision

Wait for an approval on a mandate you're running, or list all the pending approvals in your workspace to build a review inbox.

present-approval.ts
import { ShadwayTimeoutError } from "shadway";

const work = shadway.mandate(mandateId);

try {
  const approval = await work.waitForApproval({ timeout: "30m" });

  if (approval) {
    // Render these fields in your application's approval screen.
    console.log({
      id: approval.id,
      question: approval.question,
      action: approval.action,
      expiresAt: approval.expiresAt,
    });
  } else {
    console.log("The mandate ended without asking for an approval.");
  }
} catch (error) {
  if (!(error instanceof ShadwayTimeoutError)) throw error;
  console.log("No approval yet. The mandate continues on the server.");
}

Only call the deciding function after a person with the right access has reviewed the action and made a decision. Sign that person in and check that they're allowed to act on this mandate before you call it.

decide-approval.ts
async function decide(
  approvalId: string,
  mandateId: string,
  decision: "approve" | "reject",
  reason: string,
) {
  const approval = await shadway.approvals.get(approvalId);
  if (approval.mandate !== mandateId || approval.status !== "pending") {
    throw new Error("This approval is not pending for this mandate");
  }

  if (decision === "approve") {
    return shadway.approvals.approve(approval.id, { reason });
  }
  return shadway.approvals.reject(approval.id, { reason });
}

Decisions are final, and approvals expire

The first decision wins. Sending the same decision again returns the original result, so a retry after a dropped connection is safe. Sending the opposite decision is rejected, so it can't overwrite the first one.

Every approval expires, at most seven days after it's requested. A decision that lands after it expires marks the approval expired instead of running the action. A late approval never runs. Rejecting refuses one action, not the whole job. Cancel the mandate if you want everything to stop. Canceling a mandate also clears its pending approvals.

Pass sensitive values with a decision

Some approved actions need a value only a person has, like a one-time code or a password for an old system. Pass it in inputs when you approve:

approve-with-inputs.ts
await shadway.approvals.approve(approvalId, {
  reason: "Verified with the account holder.",
  inputs: { otp: "483921" },
});

These inputs are only for running this one action. Shadway keeps them briefly as encrypted data, never shows them to the model, the event stream, logs, or API responses, and deletes them once the action is done. You can't pass inputs when you reject.

Input requests

When the agent needs something from a person, like a code, a link, or an account detail, it comes through as an approval too. The action.capability is input.provide and the question is in action.consequence. One approval screen handles both.

Continue building

On this page