Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,26 @@ LINK_TOKEN_ENCRYPTION_KEY=

# Exact trusted checkout/frame origins per approved merchant (JSON object).
LINK_CHECKOUT_ORIGINS={}

# Optional Crossmint Agent Checkouts. Buy at any store with the person's own card. See docs/crossmint.md.
# Production server key (sk_) with agent-checkouts.* scopes. Leave unset to keep the tools disabled.
CROSSMINT_SERVER_API_KEY=
CROSSMINT_USER_ID=
# Optional per-checkout ceiling ("50.00 EUR") and exact merchant origins.
CROSSMINT_MAX_COST=
CROSSMINT_ALLOWED_MERCHANTS=
# Set up in Bell at /crossmint/setup (operator only): the saved delivery details and the approved budget.
CROSSMINT_BUYER_PROFILE_ID=
# The budget (order intent) Bell pays with. Required only to pay; never shown to the model.
CROSSMINT_ORDER_INTENT_ID=
# For the setup page: client key (ck_) with payment-methods.* and order-intents.* scopes, the project id,
# and a base64 PEM RSA key that signs the operator token Crossmint checks at /api/crossmint/jwks.
CROSSMINT_CLIENT_API_KEY=
CROSSMINT_PROJECT_ID=
CROSSMINT_JWT_PRIVATE_KEY=
# Optional Crossmint Agent Cards: pay in this conversation's browser with a one-time card. Set to 1 to
# turn it on. Needs LINK_SANDBOX=0, DATABASE_URL, the setup keys above, and a budget whose Visa or
# Mastercard rail is verified.
CROSSMINT_CARD_CHECKOUT=
# Exact payment-frame origins per merchant origin (JSON object).
CROSSMINT_CHECKOUT_ORIGINS={}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pnpm dev
| Link by Stripe | Wallet authorization, spend approval, and checkout through Linq | [Sandbox and wallet setup](docs/link.md) |
| 1Password | Operator credentials and OTP; personal Agentic Autofill is disabled by default | [Operator access](docs/browser-runtime.md#credentials-and-otp) · [Personal access status](docs/onepassword.md) |
| Baselayer | Verified access to approved merchants using short-lived credentials | [Merchant access](docs/baselayer.md) |
| Crossmint | Buy at any store with the person's own card, worldwide, under a spending cap the person approves on Bell's setup page; or pay in Bell's browser with a one-time agent card | [Agent checkouts and agent cards](docs/crossmint.md) |


See the [repository layout and implementation map](docs/repository.md) to extend Bell.
Expand Down
23 changes: 23 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,26 @@ navigation needs a fresh proof. After access, call snapshot and screenshot to ve
Do not claim a successful mint or merchant access unless the tool result confirms it.

For private Linq senders, use `onepassword_connect`, `onepassword_status`, `onepassword_login`, and `onepassword_disconnect` for personal 1Password access. If status is disabled, explain that personal 1Password login is not available yet; do not offer a consent link or fall back to the operator vault. Never use the operator vault tools for a messaging sender. Connecting is separate from approving a website login. Use the exact login URL and a distinct expected success path on the same origin. A verified destination is not proof of account identity. The first version supports single-step sign-in only. Approval links are sent through Eve authorization; do not ask for tokens, integration keys, passwords, or OTPs in chat. If a private fill fails, start a new conversation rather than inspecting the blocked browser. Personal browser mode does not support reusable Contexts.

Use crossmint_checkout when Crossmint Agent Checkouts is configured and the user wants a purchase
completed by Crossmint rather than in this conversation's browser. It works at any store, pays with
the user's own card, and the card number never reaches you. Start with the product URL, the request,
and the user's spending cap. The checkout runs in Crossmint's browser, so this conversation's browser
stays idle. While it runs, each result can carry progress: tell the user in one short sentence what
Crossmint is doing, in your own words and without ids, then call crossmint_checkout status again. Do
not repeat a stage you already reported. Answer form questions from what the user said, using option
values for choice fields, and never fill protected fields. At a payment request, call crossmint_pay
with the amount, currency, merchant, and merchant name copied from the request. Eve asks the person to approve, and the
server verifies those values against Crossmint before paying. Call crossmint_checkout decline if the
person says no. Only a succeeded result with purchase.kind receipt_captured is a placed order. Report
any other outcome as what it is. Never start a second checkout for the same request while one is
unresolved, and never ask for or print API keys or order intent ids.

Use crossmint_card_checkout when Crossmint Agent Cards is configured and the user wants to pay in
this conversation's browser with their own card. Confirm the merchant, items, delivery details,
currency, and final total, and fill everything but the card first, billing address included. Then
call it with the exact total, currency, the checkout page's URL or host, exact CSS selectors for the
card number, CVC, and expiry, and the pay button. Eve asks the person to approve. The server checks
the order intent, mints a one-time card, fills it, and presses pay once; card values never reach
you. Its statuses do not prove payment, and the browser stays private afterwards, so check the order
through human handoff. Never retry an uncertain purchase. Report refusal reasons as they are.
57 changes: 57 additions & 0 deletions agent/lib/crossmint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { ApprovalResponseContext, ApprovalResponseDecision } from "eve/tools/approval";
import { z } from "zod";
import {
AMOUNT,
CrossmintPolicyError,
CrossmintRequestError,
CrossmintSetupError,
isCrossmintConfigured,
type CheckoutSnapshot,
} from "../../server/crossmint/client";
import { hostOf } from "../../server/crossmint/payment";

/** How long one tool call waits for Crossmint before returning the latest snapshot. */
export const WAIT_MS = 90_000;
/** After starting a run, return at the first progress so the person hears back within seconds. */
export const FIRST_UPDATE_MS = 25_000;
/** While a run works, return at most this late, and no sooner than MIN_UPDATE_MS unless it settles. */
export const UPDATE_MS = 60_000;
export const MIN_UPDATE_MS = 15_000;

export const amount = z.string().regex(AMOUNT, "Decimal string such as 20.00");
export const currency = z.string().length(3).toUpperCase().describe("ISO 4217 code, such as USD or EUR.");
export const runId = z.string().min(1).max(200);
export const requestId = z.string().min(1).max(200);

export type CheckoutToolOutput =
| { status: "not_configured"; hint: string }
| { status: "error"; reason: string; message: string }
| CheckoutSnapshot;

export function notConfigured(): CheckoutToolOutput | undefined {
if (isCrossmintConfigured()) return undefined;
return { status: "not_configured", hint: "Set CROSSMINT_SERVER_API_KEY and CROSSMINT_USER_ID on the server. See docs/crossmint.md." };
}

/** "Zara (payments.zara.com)" or just the host: the approval names who is paid in plain words. */
export function payee(input: { merchant: string; merchantName?: string }): string {
const host = hostOf(input.merchant) ?? input.merchant;
const name = input.merchantName?.trim();
return name && name.toLowerCase() !== host ? `${name} (${host})` : host;
}

/** Approval response policy for payments: only the authenticated sender who asked can approve. */
export function requesterOnly({ request, response }: Pick<ApprovalResponseContext<unknown>, "request" | "response">): ApprovalResponseDecision {
const requester = request.principal;
if (!requester) return { status: "rejected", reason: "Payments need an authenticated sender." };
const same = response.principal.principalId === requester.principalId && response.principal.authenticator === requester.authenticator;
return same ? { status: "allowed" } : { status: "rejected", reason: "Only the person who requested this payment can approve it." };
}

/** Crossmint errors carry fixed, sanitized messages the model may see. Anything else is rethrown. */
export function errorOutput(error: unknown): CheckoutToolOutput {
if (error instanceof CrossmintSetupError || error instanceof CrossmintRequestError || error instanceof CrossmintPolicyError) {
return { status: "error", reason: error.reason, message: error.message };
}
throw error;
}
120 changes: 120 additions & 0 deletions agent/tools/crossmint_card_checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { defineTool, toolOutput } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { amount, currency, errorOutput, payee, requesterOnly, type CheckoutToolOutput } from "../lib/crossmint";
import { runBrowserOperation, runCancelableBrowserWork } from "../lib/browser-runtime";
import { assertCheckoutPolicy } from "../../server/crossmint/client";
import {
assertCardPayment,
cardConfig,
checkoutAmount,
CrossmintCardError,
getOrderIntent,
idempotencyKey,
isCrossmintCardsConfigured,
merchantOrigin,
mintCard,
paymentOrigins,
} from "../../server/crossmint/cards";
import { hasBrowserPrivacyLock } from "../../server/browser/privacy-lock";
import { checkoutInHostedPage } from "../../server/link/checkout-browser";
import { checkoutInput } from "../../server/link/payment";

const cardTarget = checkoutInput.shape.fields.element.extend({ field: z.enum(["number", "cvc", "exp_month", "exp_year", "expiry"]) });

const inputSchema = z.object({
amount: amount.describe("The exact total on the merchant's checkout, including tax and shipping."),
currency,
merchant: z.string().min(1).max(255).describe("The checkout page's URL or host."),
fields: z
.array(cardTarget)
.min(1)
.max(16)
.describe("Exact CSS selectors for the card inputs: number, cvc, and expiry (MM/YY) or exp_month (MM) and exp_year (YYYY). Add a frame selector for a card iframe."),
submit: checkoutInput.shape.submit.describe("The pay button."),
confirmation: checkoutInput.shape.submit.optional().describe("An element that appears only once the merchant confirms the order."),
});

type Input = z.infer<typeof inputSchema>;

type CardCheckoutOutput =
| CheckoutToolOutput
| { status: "payment_not_configured"; hint: string }
| { status: "submission_attempted" | "merchant_confirmation_observed" | "outcome_unknown"; verify_payment: true }
| { status: "waiting_for_user"; reason: string; action: string };

export default defineTool({
description:
"Pay at the merchant checkout open in this conversation's browser with the person's Crossmint agent card. First complete everything but the card and stay on the payment step. Supply the exact total and currency, the checkout page's URL or host, exact CSS selectors for the card inputs, and the pay button. Eve stops and asks the person to approve the amount and merchant. The server then checks the order intent the person approved in Crossmint (status, merchant, currency, remaining amount, expiry, verified card rail), mints a one-time card on Visa Intelligent Commerce or Mastercard Agent Pay, fills it, and presses pay once. The card number never reaches this assistant, and the browser stays private afterwards. submission_attempted, merchant_confirmation_observed, and outcome_unknown do not prove payment; check the order through human handoff. Call once per purchase and never retry an uncertain one.",
inputSchema,
label: {
start: (input: Readonly<Input>) => `Paying ${input.amount} ${input.currency} at ${payee(input)} with a Crossmint agent card`,
},
approval: {
request: always(),
prompt: ({ input }) => `Pay ${input.amount} ${input.currency} at ${payee(input)} with your Crossmint agent card?`,
response: requesterOnly,
},
async execute(input, ctx): Promise<CardCheckoutOutput> {
ctx.abortSignal.throwIfAborted();
if (!isCrossmintCardsConfigured()) {
return { status: "not_configured", hint: "Set the Crossmint Agent Cards settings on the server. See docs/crossmint.md." };
}
if (!ctx.session.auth.current) {
return { status: "error", reason: "authentication_required", message: "Payments are available to authenticated senders only. The anonymous web demo cannot pay." };
}
const signal = ctx.abortSignal;
let minted = false;
try {
const config = cardConfig();
if (!config.orderIntentId) {
return { status: "payment_not_configured", hint: "No order intent is configured on the server, so Bell cannot pay. Tell the person. See docs/crossmint.md." };
}
const payment = { amount: input.amount, currency: input.currency };
const origin = merchantOrigin(input.merchant);
assertCheckoutPolicy(origin, payment, config);
const { provider } = assertCardPayment(await getOrderIntent({ config, signal }), { ...payment, merchant: origin });
const checkout = checkoutInput.parse({
id: "crossmint_card",
amount: checkoutAmount(input.amount, input.currency),
currency: input.currency.toLowerCase(),
credential: "card",
fields: input.fields,
submit: input.submit,
...(input.confirmation ? { confirmation: input.confirmation } : {}),
});
const key = idempotencyKey(ctx.session.id, ctx.callId);
// A mint spends its amount at once, so it happens only while the browser lock is held, which
// lets the fill quarantine the browser right after.
const mint = async () => {
if (!hasBrowserPrivacyLock()) throw new CrossmintCardError("browser_lock_unavailable");
const card = await mintCard(provider, payment, key, { config, signal });
minted = true;
return card;
};
// The card is minted inside the fill, after every target and origin check, and never leaves it.
const output = await runCancelableBrowserWork(ctx.session.id, signal, () =>
runBrowserOperation(ctx.session.id, (browser) =>
browser.withHostedPage(signal, (page, connection) =>
checkoutInHostedPage(page, connection, checkout, paymentOrigins(config, origin), mint, signal), true)));
const result: { status: string; reason?: string; action?: string } = output.result;
if (result.status === "waiting_for_user") return { status: "waiting_for_user", reason: result.reason ?? "captcha", action: result.action ?? "" };
return { status: result.status as "submission_attempted" | "merchant_confirmation_observed" | "outcome_unknown", verify_payment: true };
} catch (error) {
if (error instanceof CrossmintCardError) return { status: "error", reason: error.reason, message: error.message };
if (!signal.aborted && error instanceof Error && error.message.startsWith("Checkout stopped before submission.")) {
return {
status: "error",
reason: "stopped_before_submission",
message: "The checkout stopped after the card was issued and before it was submitted. The browser is now private. Check the merchant page through human handoff and do not retry.",
};
}
// Any other failure after the mint: the amount is spent, so a retry must not issue a second card.
if (minted) return { status: "error", reason: "card_issued_unfinished", message: new CrossmintCardError("card_issued_unfinished").message };
return errorOutput(error);
}
},
toModelOutput(output) {
return toolOutput.json(output);
},
});
Loading