diff --git a/README.md b/README.md
index 63dfbbf..affcc86 100644
--- a/README.md
+++ b/README.md
@@ -88,8 +88,9 @@ Build these parts:
a merchant and an expiry. Show an approval screen in my app where the user picks
a card and approves. Run OrderIntentVerification when the card rail needs it.
3. Agent Checkouts. Start a run at a product URL with a max cost and stream its
- messages. Answer its form requests. Pay its payment step with an agent card for
- the exact amount. Collect store passwords with CrossmintProtectedInput.
+ messages. Answer each form request with all its fields in one answer. Pay its
+ payment step with an agent card for the exact amount. Collect each protected
+ field, such as a store password, with CrossmintProtectedInput.
4. Buyer details. Save the user's name, contact and shipping address as a buyer
profile, so checkouts do not stop to ask for them.
5. Agent tools. Give my agent tools to list saved cards, request an agent card,
diff --git a/apps/web/components/chat/message.tsx b/apps/web/components/chat/message.tsx
index 2d0f146..d27f8e5 100644
--- a/apps/web/components/chat/message.tsx
+++ b/apps/web/components/chat/message.tsx
@@ -15,7 +15,7 @@ import type {
} from "@/lib/chat/tools";
import type { ChatMessage, ChatMessagePart } from "@/lib/chat/types";
import { AgentCardApproval } from "./agent-card-approval";
-import { PasswordRequest } from "./password-request";
+import { ProtectedRequest } from "./protected-request";
import { AddCard } from "./add-card";
import { BuyerDetailsRequest } from "./buyer-details-request";
import { PaymentChoiceInThread } from "./payment-choice";
@@ -38,7 +38,7 @@ import {
isToolError,
isCheckoutPart,
messageText,
- passwordRequestOf,
+ protectedRequestOf,
productFor,
sentBackFirst,
toolBusy,
@@ -54,7 +54,7 @@ export interface MessageProps {
/** True while this message is still streaming in. */
streaming: boolean;
onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void;
- onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
+ onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void;
onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void;
onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void;
@@ -76,7 +76,7 @@ export function Message({
message,
streaming,
onApprovalOutcome,
- onPasswordOutcome,
+ onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
@@ -106,7 +106,7 @@ export function Message({
part={part}
streaming={streaming}
onApprovalOutcome={onApprovalOutcome}
- onPasswordOutcome={onPasswordOutcome}
+ onProtectedInputOutcome={onProtectedInputOutcome}
onCardSaved={onCardSaved}
onBuyerDetails={onBuyerDetails}
onPaymentChoice={onPaymentChoice}
@@ -198,7 +198,7 @@ function Part({
part,
streaming,
onApprovalOutcome,
- onPasswordOutcome,
+ onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
@@ -212,7 +212,7 @@ function Part({
part: ChatMessagePart;
streaming: boolean;
onApprovalOutcome: MessageProps["onApprovalOutcome"];
- onPasswordOutcome: MessageProps["onPasswordOutcome"];
+ onProtectedInputOutcome: MessageProps["onProtectedInputOutcome"];
onCardSaved: MessageProps["onCardSaved"];
onBuyerDetails: MessageProps["onBuyerDetails"];
onPaymentChoice: MessageProps["onPaymentChoice"];
@@ -329,18 +329,16 @@ function Part({
);
- // The store asks for a password: Crossmint's field in a dialog, never words.
+ // The store asks for secrets: Crossmint's fields in a dialog, never words.
case "tool-await_protected_input": {
- const request = passwordRequestOf(part, watches);
+ const request = protectedRequestOf(part, watches);
if (request && (part.state === "input-available" || part.state === "output-available")) {
return (
-
);
}
diff --git a/apps/web/components/chat/messages.tsx b/apps/web/components/chat/messages.tsx
index 02dd6d9..b5691bf 100644
--- a/apps/web/components/chat/messages.tsx
+++ b/apps/web/components/chat/messages.tsx
@@ -24,7 +24,7 @@ export interface MessagesProps {
messages: ChatMessage[];
status: ChatStatus;
onApprovalOutcome: (toolCallId: string, outcome: ApprovalOutcome) => void;
- onPasswordOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
+ onProtectedInputOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
onCardSaved: (toolCallId: string, outcome: SavedCardOutcome) => void;
onBuyerDetails: (toolCallId: string, outcome: BuyerDetailsOutcome) => void;
onPaymentChoice: (toolCallId: string, outcome: PaymentChoiceOutcome) => void;
@@ -42,7 +42,7 @@ export function Messages({
messages,
status,
onApprovalOutcome,
- onPasswordOutcome,
+ onProtectedInputOutcome,
onCardSaved,
onBuyerDetails,
onPaymentChoice,
@@ -71,7 +71,7 @@ export function Messages({
message={m}
streaming={status === "streaming" && i === messages.length - 1}
onApprovalOutcome={onApprovalOutcome}
- onPasswordOutcome={onPasswordOutcome}
+ onProtectedInputOutcome={onProtectedInputOutcome}
onCardSaved={onCardSaved}
onBuyerDetails={onBuyerDetails}
onPaymentChoice={onPaymentChoice}
diff --git a/apps/web/components/chat/parts.ts b/apps/web/components/chat/parts.ts
index e2cbc39..128e920 100644
--- a/apps/web/components/chat/parts.ts
+++ b/apps/web/components/chat/parts.ts
@@ -136,15 +136,17 @@ export function stopReason(
};
}
const action = view.pendingUserAction;
- // A password: the chat shows Crossmint's protected field, never a question in words.
- if (view.status === "awaiting_input" && action?.protected && !asked.has(action.id)) {
+ // Secrets: the chat shows Crossmint's protected fields, never a question in words.
+ const secure = view.protectedRequest;
+ if (view.status === "awaiting_input" && secure && !asked.has(secure.requestId)) {
return {
checkoutId: view.id,
- status: "awaiting_password",
- password: {
- requestId: action.id,
- question: action.question || "The store asks for your password.",
- ...(action.protected.merchant ? { domain: action.protected.merchant.domain } : {}),
+ status: "awaiting_protected_input",
+ protectedInput: {
+ requestId: secure.requestId,
+ question: secure.question || "The store asks for a secret.",
+ secrets: secure.fields.map((f) => f.label),
+ ...(secure.merchantDomain ? { domain: secure.merchantDomain } : {}),
},
};
}
@@ -158,7 +160,8 @@ export function stopReason(
requestId: action.id,
question: action.question || view.rendered?.title || "The store needs an answer.",
...(action.expiresAt ? { expiresAt: action.expiresAt } : {}),
- responseSchema: action.responseSchema as Record,
+ // The model answers by key; the handling is always standard here.
+ fields: action.fields.map(({ key, label, required, input }) => ({ key, label, required, input })),
...(asksPasswordInForm(action)
? { note: PASSWORD_IN_FORM_NOTE }
: asksCardInForm(action)
@@ -180,9 +183,9 @@ export function watchedHere(message: ChatMessage, part: ChatMessagePart): boolea
return message.parts.some((p) => p.type === "tool-watch_checkout" && p.input?.checkoutId === id);
}
-/** What the agent is told when a store asks for a password in a plain form. */
+/** What the agent is told when a store asks for a password in a plain field. */
const PASSWORD_IN_FORM_NOTE =
- "This asks for the user's password in a plain form. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline).";
+ "This asks for the user's password in a plain field. Never ask the user for it and never send it: it would pass through the chat, and the store's agent does not use a password sent that way. Tell the user in one line that this store wants them to sign in, which cannot be done safely here, and ask whether to check out as a guest instead (answer_checkout with action alternative) or stop (action decline).";
/** What the agent is told when a store asks for card details in a plain form, not a payment request. */
const CARD_IN_FORM_NOTE =
@@ -200,8 +203,8 @@ export interface WatchIndex {
sites: Map;
/** Each checkout's first `watch_checkout`, by tool call id. */
firstWatch: Map;
- /** Password requests a watch handed back, by requestId: the store the password is for, from the checkout itself. */
- passwords: Map;
+ /** Protected requests a watch handed back, by requestId: the store the secrets are for, from the checkout itself. */
+ protectedInputs: Map;
/**
* Watches whose card the next watch takes over: the stretch stopped on a
* question the agent answered itself, with nothing shown in between, so the
@@ -229,7 +232,7 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
paymentRequests: new Set(),
sites: new Map(),
firstWatch: new Map(),
- passwords: new Map(),
+ protectedInputs: new Map(),
absorbed: new Set(),
carried: new Map(),
products: new Map(),
@@ -274,11 +277,13 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
if (!shown) index.shown.set(out.checkoutId, (shown = new Set()));
for (const u of out.updates ?? []) shown.add(u.id);
if (out.question) index.asked.add(out.question.requestId);
- if (out.password) {
- index.asked.add(out.password.requestId);
- index.passwords.set(out.password.requestId, {
+ if (out.protectedInput) {
+ index.asked.add(out.protectedInput.requestId);
+ index.protectedInputs.set(out.protectedInput.requestId, {
checkoutId: out.checkoutId,
- ...(out.password.domain ? { domain: out.password.domain } : {}),
+ question: out.protectedInput.question,
+ secrets: out.protectedInput.secrets,
+ ...(out.protectedInput.domain ? { domain: out.protectedInput.domain } : {}),
});
}
if (out.payment) index.paymentRequests.add(out.payment.requestId);
@@ -287,23 +292,36 @@ export function watchIndex(messages: ChatMessage[]): WatchIndex {
return index;
}
+/** An `await_protected_input` call, as its card shows it. */
+export interface ProtectedRequestSummary {
+ checkoutId: string;
+ requestId: string;
+ /** The store's question: "Sign in to shop.example to continue." */
+ question: string;
+ /** The secrets it asks for, by label: "Password". */
+ secrets: string[];
+ domain: string;
+}
+
/**
* An `await_protected_input` call, with what its card needs: the checkout,
- * the request, and the store the password is for. The store comes from the
+ * the request, what it asks for, and the store. All of it comes from the
* watch that handed the request back, else the checkout's own site, never
- * from the model's arguments: the password is bound to it.
+ * from the model's arguments.
*/
-export function passwordRequestOf(
+export function protectedRequestOf(
part: ChatMessagePart,
watches: WatchIndex,
-): { checkoutId: string; requestId: string; domain: string } | undefined {
+): ProtectedRequestSummary | undefined {
if (part.type !== "tool-await_protected_input" || part.state === "input-streaming")
return undefined;
const requestId = part.input?.requestId;
- const known = requestId ? watches.passwords.get(requestId) : undefined;
+ const known = requestId ? watches.protectedInputs.get(requestId) : undefined;
if (!requestId || !known) return undefined;
const domain = known.domain ?? watches.sites.get(known.checkoutId)?.host;
- return domain ? { checkoutId: known.checkoutId, requestId, domain } : undefined;
+ return domain
+ ? { checkoutId: known.checkoutId, requestId, question: known.question, secrets: known.secrets, domain }
+ : undefined;
}
/**
@@ -317,7 +335,7 @@ export function stoppedForUser(outcome: CheckoutOutcome | undefined): boolean {
return (
outcome?.status === "awaiting_input" ||
outcome?.status === "awaiting_payment" ||
- outcome?.status === "awaiting_password"
+ outcome?.status === "awaiting_protected_input"
);
}
@@ -347,6 +365,7 @@ export function runSteps(opts: {
switch (out.status) {
case "awaiting_input":
case "awaiting_payment":
+ case "awaiting_protected_input":
break;
case "succeeded":
steps.push({
@@ -552,7 +571,7 @@ export function toolTitle(type: string): string {
"tool-get_agent_card": "Checking an agent card",
"tool-request_agent_card": "Requesting an agent card",
"tool-await_agent_card_approval": "Waiting for your approval",
- "tool-await_protected_input": "Waiting for your password",
+ "tool-await_protected_input": "Waiting for your answer",
"tool-await_saved_card": "Waiting for your card",
"tool-await_buyer_details": "Waiting for your details",
"tool-await_payment_choice": "Waiting for how you want to pay",
diff --git a/apps/web/components/chat/password-request.tsx b/apps/web/components/chat/protected-request.tsx
similarity index 52%
rename from apps/web/components/chat/password-request.tsx
rename to apps/web/components/chat/protected-request.tsx
index 67d5035..5e12080 100644
--- a/apps/web/components/chat/password-request.tsx
+++ b/apps/web/components/chat/protected-request.tsx
@@ -2,24 +2,33 @@
import { useCallback, useState } from "react";
import {
- AnswerPasswordRequest,
+ AnswerProtectedRequest,
Badge,
Button,
Dialog,
DialogContent,
DialogTitle,
cn,
- type PasswordRequestOutcome,
+ type ProtectedRequestOutcome,
} from "@agent-commerce/ui";
import { AGENT_COMPANY } from "@/components/brand";
+import type { ProtectedRequestSummary } from "@/components/chat/parts";
import type { ProtectedInputOutcome } from "@/lib/chat/tools";
import { APPROVAL_DONE_LINGER_MS } from "./agent-card-approval";
import { SiteIcon } from "./checkout-site";
import { AgentBubble } from "./text";
-/** What the agent asks when a store wants the password of the user's account there. */
-export function passwordQuestion(domain: string): string {
- return `Can you sign in to ${siteName(domain)}?`;
+/** "Password", "Password and one-time code": what the store asks for, in lower case, for a sentence. */
+export function secretsPhrase(secrets: string[]): string {
+ const words = secrets.map((s) => s.toLowerCase());
+ if (words.length === 0) return "details";
+ if (words.length === 1) return words[0]!;
+ return `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
+}
+
+/** What the agent asks when a store wants secrets, such as the password of the user's account there. */
+export function protectedQuestion(request: Pick): string {
+ return `Can you enter your ${secretsPhrase(request.secrets)} for ${siteName(request.domain)}?`;
}
/** "www.amazon.com" → "amazon.com", for reading only. */
@@ -33,33 +42,35 @@ const OUTCOME_LABEL: Record = {
};
/**
- * A store's password request in the thread, the same in every chat frame,
- * and laid out like an approval: the agent asks in its bubble, and one small
- * card sits under it, the store and what happens to the password, with
- * Enter password, which opens Crossmint's protected field in the frame's own
- * sheet or dialog. Answered, the card says whether it was sent.
+ * A store's question with secrets in the thread, the same in every chat
+ * frame, and laid out like an approval: the agent asks in its bubble, and
+ * one small card sits under it, the store and what happens to the secrets,
+ * with a button that opens the question, with Crossmint's protected fields,
+ * in the frame's own sheet or dialog. Answered, the card says whether it was
+ * sent.
*
- * `domain` comes from the checkout (the watch that handed the request back),
- * never from the model: the password is bound to it.
+ * `request` comes from the watch that handed the request back, never from
+ * the model.
*/
-export function PasswordInThread({
- domain,
+export function ProtectedRequestInThread({
+ request,
output,
onEnter,
bubbleClassName,
buttonSize = "xl",
className,
}: {
- domain: string;
+ request: ProtectedRequestSummary;
output?: ProtectedInputOutcome;
onEnter: () => void;
bubbleClassName?: string;
buttonSize?: "lg" | "xl";
className?: string;
}) {
+ const site = siteName(request.domain);
return (
<>
-
+
-
- Sign in to {siteName(domain)}
+
+ {request.question}
- Encrypted and only used on {siteName(domain)}. {AGENT_COMPANY} never sees it.
+ Encrypted and only used on {site}. {AGENT_COMPANY} never sees it.
{output ? null : (
)}
@@ -89,30 +100,26 @@ export function PasswordInThread({
}
/**
- * The desktop's password request: the card in the thread, and Enter password
- * opens the field in a dialog. The model called
+ * The desktop's protected request: the card in the thread, and its button
+ * opens the question in a dialog. The model called
* `await_protected_input({ checkoutId, requestId })` and the stream stopped;
- * once the user sent the password (or skipped it), `onOutcome` hands back
- * only that, and the chat resubmits itself.
+ * once the user sent it (or skipped it), `onOutcome` hands back only that,
+ * and the chat resubmits itself.
*/
-export function PasswordRequest({
+export function ProtectedRequest({
toolCallId,
- checkoutId,
- requestId,
- domain,
+ request,
output,
onOutcome,
}: {
toolCallId: string;
- checkoutId: string;
- requestId: string;
- domain: string;
+ request: ProtectedRequestSummary;
output?: ProtectedInputOutcome;
onOutcome: (toolCallId: string, outcome: ProtectedInputOutcome) => void;
}) {
const [open, setOpen] = useState(false);
const handleDone = useCallback(
- (status: PasswordRequestOutcome) => {
+ (status: ProtectedRequestOutcome) => {
onOutcome(toolCallId, { status });
setTimeout(() => setOpen(false), APPROVAL_DONE_LINGER_MS);
},
@@ -121,20 +128,20 @@ export function PasswordRequest({
return (
<>
- setOpen(true)}
/>