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
20 changes: 20 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,23 @@ 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.
# Production server key (sk_) with agent-checkouts.* scopes. See docs/crossmint.md. Leave unset to keep the tools disabled.
CROSSMINT_SERVER_API_KEY=
CROSSMINT_USER_ID=
# The operator's approved order intent id. Required only to pay; never shown to the model.
CROSSMINT_ORDER_INTENT_ID=
# Optional saved buyer profile, per-checkout ceiling ("50.00 EUR"), and exact merchant origins.
CROSSMINT_BUYER_PROFILE_ID=
CROSSMINT_MAX_COST=
CROSSMINT_ALLOWED_MERCHANTS=

# Optional Crossmint Agent Cards: pay in this conversation's browser with a one-time card. Needs DATABASE_URL.
# Client key (ck_) with payment-methods.* and order-intents.* scopes; BELL_PUBLIC_URL must be an allowed origin.
CROSSMINT_CLIENT_API_KEY=
# Project id, and a base64 PEM RSA key that signs the operator JWT served at /api/crossmint/jwks.
CROSSMINT_PROJECT_ID=
CROSSMINT_JWT_PRIVATE_KEY=
# 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; 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
20 changes: 20 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,23 @@ 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. 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, and merchant 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.
44 changes: 44 additions & 0 deletions agent/lib/crossmint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { ApprovalResponseContext, ApprovalResponseDecision } from "eve/tools/approval";
import { z } from "zod";
import {
AMOUNT,
CrossmintPolicyError,
CrossmintRequestError,
CrossmintSetupError,
isCrossmintConfigured,
type CheckoutSnapshot,
} from "../../server/crossmint/client";

/** How long one tool call waits for Crossmint before returning the latest snapshot. */
export const WAIT_MS = 90_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." };
}

/** 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;
}
108 changes: 108 additions & 0 deletions agent/tools/crossmint_card_checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { defineTool, toolOutput } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { amount, currency, errorOutput, 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 { 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 ${input.merchant} with a Crossmint agent card`,
},
approval: {
request: always(),
prompt: ({ input }) => `Pay ${input.amount} ${input.currency} at ${input.merchant} 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;
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);
// 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), () => mintCard(provider, payment, key, { config, signal }), 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.",
};
}
return errorOutput(error);
}
},
toModelOutput(output) {
return toolOutput.json(output);
},
});
88 changes: 88 additions & 0 deletions agent/tools/crossmint_checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { defineTool, toolOutput } from "eve/tools";
import { z } from "zod";
import { amount, currency, errorOutput, notConfigured, requestId, runId, WAIT_MS, type CheckoutToolOutput } from "../lib/crossmint";
import { answerForm, cancelCheckout, createCheckout, declineRequest, getCheckout, waitForCheckout } from "../../server/crossmint/client";
import { assertSessionCheckout, rememberCheckout } from "../../server/crossmint/runs";

const inputSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("start"),
startUrl: z.string().url().max(2048).describe("Product or store page to start from."),
task: z.string().min(1).max(20_000).describe("What to buy, in plain language. Item, options, quantity, delivery."),
maxCost: z.object({ amount: amount.describe("Spending cap including shipping and tax."), currency }),
merchantGuidance: z.string().min(1).max(20_000).optional().describe("Optional notes about this merchant's checkout."),
countryCode: z.string().length(2).toUpperCase().optional().describe("Optional browser location for the checkout, such as ES or US."),
}),
z.object({ action: z.literal("status"), runId }),
z.object({
action: z.literal("answer"),
runId,
requestId,
answers: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]))
.describe("One value per form field key. For a choice field, an option value. Protected fields cannot be answered here."),
}),
z.object({ action: z.literal("decline"), runId, requestId }),
z.object({ action: z.literal("cancel"), runId }),
]);

export default defineTool({
description:
"Buy at any store through Crossmint Agent Checkouts under a hard spending cap. Crossmint picks the fastest path for the store, the merchant API, an agent protocol, or its own checkout browser, so nothing runs in this conversation's browser. The person pays with their own card, worldwide, and the card number never reaches this assistant. Call start with the product URL, a plain-language task, and maxCost, then follow requiredAction. For a form, call answer with one value per field key. For a payment, call crossmint_pay. decline refuses a request and cancel stops the run. Each call returns a status snapshot and waits up to ninety seconds for the next step. A succeeded result with purchase.kind receipt_captured is the only proof of a placed order. Never start a second checkout for the same request while one is unresolved.",
inputSchema,
label: {
start: (input) =>
input.action === "start" ? `Starting a Crossmint checkout at ${new URL(input.startUrl).hostname}` : `Crossmint checkout: ${input.action}`,
},
async execute(input, ctx): Promise<CheckoutToolOutput> {
ctx.abortSignal.throwIfAborted();
const unavailable = notConfigured();
if (unavailable) return unavailable;
const sessionId = ctx.session.id;
const signal = ctx.abortSignal;
try {
switch (input.action) {
case "start": {
const { runId } = await createCheckout(input, { signal });
rememberCheckout(sessionId, { runId, maxCost: input.maxCost, principalId: ctx.session.auth.current?.principalId ?? null });
return await waitForCheckout(runId, { signal, timeoutMs: WAIT_MS });
}
case "status":
assertSessionCheckout(sessionId, input.runId);
return await waitForCheckout(input.runId, { signal, timeoutMs: WAIT_MS });
case "answer": {
assertSessionCheckout(sessionId, input.runId);
const current = await getCheckout(input.runId, { signal });
const action = current.requiredAction;
if (!action || action.requestId !== input.requestId) return current;
if (action.interaction.kind !== "form") {
return { status: "error", reason: "wrong_interaction", message: "This request is not a form. Payment requests go through crossmint_pay." };
}
const protectedKeys = new Set(action.interaction.fields.filter((field) => field.handling === "protected").map((field) => field.key));
if (Object.keys(input.answers).some((key) => protectedKeys.has(key))) {
return {
status: "error",
reason: "protected_field",
message: "Protected fields (codes, passwords, card data) cannot be answered through the model. Decline the request or ask the person to finish in Crossmint's hosted flow.",
};
}
await answerForm(input.runId, input.requestId, input.answers, { signal });
return await waitForCheckout(input.runId, { signal, timeoutMs: WAIT_MS, skipRequestId: input.requestId });
}
case "decline":
assertSessionCheckout(sessionId, input.runId);
await declineRequest(input.runId, input.requestId, { signal });
return await waitForCheckout(input.runId, { signal, timeoutMs: 30_000, skipRequestId: input.requestId });
case "cancel":
assertSessionCheckout(sessionId, input.runId);
await cancelCheckout(input.runId, { signal });
return await waitForCheckout(input.runId, { signal, timeoutMs: 30_000 });
}
} catch (error) {
return errorOutput(error);
}
},
toModelOutput(output) {
return toolOutput.json(output);
},
});
Loading