Approvals
Pause your agent mid-run. Ask the customer a question — choice, free text, or yes/no. Resume with the answer when they respond. One SDK call, hosted page, magic-link auth, audit log included.
When to use approvals
Approvals fit the case where your agent needs a decision from someone outsideyour own app — a customer signing off on an action, a vendor confirming a price, a counterparty reviewing a contract clause, a candidate confirming an interview slot.
If your reviewer logs into your product, build the approval into your existing dashboard. We’re for the case where they don’t — the agency’s client’s CFO, the SaaS customer’s vendor, the recruiter’s candidate. They have no login, no account, no reason to make one. Magic-link auth on a hosted page is the right shape.
Quickstart
Create a track with a bound customerEmail, then call track.confirm() at the moment you need approval. The call blocks until the customer answers, the timeout elapses, or you cancel.
import { createClient } from "@porchso/sdk";
const porch = createClient({ apiKey: process.env.PORCH_API_KEY! });
const track = await porch.createTrack({
title: "Vendor invoice batch",
steps: ["Fetch", "Parse", "Approve", "Schedule"],
customerEmail: "cfo@acme.com", // ← required for ask-enabled tracks
});
await track.startStep(0);
await track.action("Pulled 47 invoices from Bill.com");
await track.completeStep(0);
await track.startStep(1);
await track.action("Parsed line items + matched to POs");
await track.completeStep(1);
// Agent pauses here. The CFO opens the track URL (emailed via
// your existing pipeline), verifies via magic link, and clicks
// Approve or Hold. This call returns when they answer.
const answer = await track.confirm({
prompt: "Acme Office Supplies invoice is 8% above contract. Approve?",
acceptLabel: "Approve the new rate",
rejectLabel: "Hold for negotiation",
timeoutSeconds: 86400, // 24h
});
if (answer.status === "answered" && answer.value === true) {
await track.startStep(2);
await track.action("Approval recorded — scheduling payments");
await track.completeStep(2);
await track.startStep(3);
await track.action("All payments queued for Friday");
await track.completeStep(3);
await track.complete({ summary: "47 payments scheduled." });
} else {
await track.fail({
reason: answer.status === "answered"
? "Customer held the invoice"
: `Approval ${answer.status}`,
});
}SDK methods
Four methods on the track object cover the ask lifecycle. Three are convenience wrappers around the general-purpose track.ask(); pick whichever reads cleanest at the call site.
track.confirm() — yes/no approval
const answer = await track.confirm({
prompt: "Send this email to the customer?",
acceptLabel: "Send", // optional, default "Yes"
rejectLabel: "Hold", // optional, default "No"
timeoutSeconds: 1800, // optional, default 1800 (30 min)
});
// answer.status: "answered" | "timeout" | "cancelled"
// answer.value: boolean (only meaningful if answered)track.askText() — free-text input
const answer = await track.askText({
prompt: "What email should we send the report to?",
validate: "email", // optional: "short" | "long" | "email" | "url"
timeoutSeconds: 1800,
});
// answer.value is a string when answeredtrack.ask() — multiple choice (or anything custom)
const answer = await track.ask({
kind: "choice",
prompt: "Which template should the contract use?",
config: {
options: [
{ value: "standard", label: "Standard MSA" },
{ value: "enterprise", label: "Enterprise MSA" },
{ value: "trial", label: "30-day trial agreement" },
],
},
timeoutSeconds: 1800,
});
// answer.value is the string `value` of the selected optiontrack.cancelInputRequest() — agent-side cancel
Call when the agent decides it no longer needs the answer (e.g., it found the information elsewhere). The customer sees the card switch to a “no longer needed” state; the SDK’s pending ask call returns with status: "cancelled".
Ask kinds
Three kinds today — choice, text, confirm. Pick by the shape of the answer:
- choice— one-of-N selection. Use for templates, classifications, branching decisions. Config takes
options: {value, label}[](2–8 options). The customer sees buttons; the answer is the selectedvalue. - text— free-form string. Use for missing data, custom prompts, anything not enumerable. Optional
validatehint (short / long / email / url) sets the input control + client-side check. - confirm— boolean yes/no. Use for approvals, gates, “is this OK” moments. Customer sees two buttons; answer is
trueorfalse.
Display metadata
Every ask kind can carry optional display fields that light up the customer’s card with context. None are required; the card renders cleanly with just prompt. All round-trip on the input_request.created webhook payload too.
description— one-sentence subtitle under the prompt (≤500 chars). Use to add the “why” behind the question.summary— key/value table rendered in the card body (max 20 entries; each key+value ≤200 chars). Use for structured context like “Previous rate: $2,400 / New rate: $2,592 / Reason: Q3 passthrough”.details— longer text in an expandable “Show details” disclosure (≤8000 chars). Use for full context the customer might want without burying the prompt.risk—"low"/"medium"/"high". Currently accepted but not yet visually surfaced; reserved for an upcoming risk-badge UI in v1.2.
Example: a contract-redline approval
await track.ask({
kind: "choice",
prompt: "Counterparty pushed back on the liability cap. Accept or counter?",
config: {
description: "Last 3 deals settled at $750k avg. Renewal value: $480k ARR.",
summary: {
"Our redline": "$500k cap, mutual",
"Their counter": "$1M cap, mutual",
"Last 3 deals": "$750k avg",
},
details: "Original draft of Section 8.2 follows below...\n\n[full text]",
options: [
{ value: "accept", label: "Accept their $1M counter" },
{ value: "counter", label: "Counter at $750k" },
],
},
});Auth model
Three composable tiers. Every ask-enabled track uses tier 2 by default; tier 1 is optional add-on; tier 3 has no UI (just the unguessable slug).
- Tier 1 — Password (optional). Set
passwordoncreateTrack()to require an unlock page before the customer can view the track at all. Useful when the link will travel through Slack / forwarded email and you want a second factor. Dashboard toggle available to set/change/clear without re-deploying. - Tier 2 — Magic-link OTP (default for ask-enabled tracks). When you set
customerEmailoncreateTrack(), the customer must verify that email via a one-time signed link before they can submit an answer. Watching the page stays open; only the answer requires verification. Cookie binds the browser to that email for 7 days. - Tier 3 — Link-only (default for non-ask tracks). The unguessable slug is the credential. Anyone with the URL can view; nobody can answer (because no ask exists).
Tiers compose: a password-protected track with customerEmail set requires unlock first, then email verification before answer. We tested both gates firing in order; works as expected.
Patterns
Approval gate (most common)
const answer = await track.confirm({
prompt: "Send this invoice for payment?",
});
if (answer.status !== "answered" || answer.value === false) {
await track.fail({ reason: "Not approved", retryable: false });
return;
}
// ... proceed with sendingBranching choice
const answer = await track.ask({
kind: "choice",
prompt: "How should we handle this duplicate contact?",
config: {
options: [
{ value: "merge", label: "Merge — keep newest fields" },
{ value: "keep", label: "Keep both" },
{ value: "delete", label: "Delete the duplicate" },
],
},
});
if (answer.status !== "answered") {
await track.fail({ reason: `No answer (${answer.status})` });
return;
}
switch (answer.value) {
case "merge": await mergeContact(); break;
case "keep": /* no-op */ break;
case "delete": await deleteDuplicate(); break;
}Missing data collection
// Customer didn't include their billing email when signing up;
// the agent collects it before generating the first invoice.
const answer = await track.askText({
prompt: "What email should we send invoices to?",
validate: "email",
});
if (answer.status === "answered") {
await saveBillingEmail(answer.value);
}Caveats + gotchas
- customerEmail is required for asks. Tracks without it cannot call any ask method — the SDK returns
requires_customer_email. Set it atcreateTrack()time. - One open ask per track at a time. Trying to open a second ask while one is still pending returns
already_awaiting. Cancel the first withtrack.cancelInputRequest()if you need to switch contexts. - Timeouts don’t auto-reap yet. Today, when the deadline passes, your SDK call returns with
status: "timeout"and you handle it in your agent code. The DB-side sweeper that flips state automatically (and fires theinput_request.timeoutwebhook) ships in v1.2. If your agent isn’t running when the timeout would fire, the row stays inopenuntil you re-run it. - The customer never signs up for Porch. The magic-link cookie binds them to one track for 7 days. No account, no profile, no dashboard. We are not creating a second product for your customer to learn.
- Plan caps apply. Free: 25 asks/mo. Pro ($99): 1,000 asks/mo with a 24h / 100-ask grace window. Past grace, ask creation returns
monthly_ask_limit_reached. Email us for Enterprise above that.
Webhook events
Every ask lifecycle event fires an outbound webhook (when you have an endpoint registered). See the Webhooks reference for signing, retry, and payload shape details.
input_request.created— agent paused; ask is open. Use to alert the customer (Slack DM, SMS, email) outside the in-app status page.input_request.answered— customer responded. Payload carriesanswerValueandduration_ms. Use to clear “waiting on customer” alerts or log to your audit trail.input_request.cancelled— agent calledcancelInputRequest(). Mirror to your queue so stale “we need your input” notifications stop firing.
See it in action
The landing pageruns scripted demos of asks across four real-world workflows: vendor invoice approval (finance), contract redline review (legal), candidate interview confirmation (recruiting), and cold outreach batch approval (sales). Each demo is the actual status page + ask card with deterministic timing — click any tab to watch the loop end-to-end.