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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,20 @@ 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=
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 | [Agent checkouts](docs/crossmint.md) |


See the [repository layout and implementation map](docs/repository.md) to extend Bell.
Expand Down
14 changes: 14 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,17 @@ 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.
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;
}
115 changes: 115 additions & 0 deletions agent/tools/crossmint_checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { defineTool, toolOutput } from "eve/tools";
import { z } from "zod";
import {
amount,
currency,
errorOutput,
FIRST_UPDATE_MS,
MIN_UPDATE_MS,
notConfigured,
requestId,
runId,
UPDATE_MS,
type CheckoutToolOutput,
} from "../lib/crossmint";
import {
answerForm,
cancelCheckout,
createCheckout,
declineRequest,
getCheckout,
waitForCheckout,
type CheckoutSnapshot,
type WaitOptions,
} from "../../server/crossmint/client";
import { assertSessionCheckout, rememberCheckout, type CheckoutRecord } from "../../server/crossmint/runs";

/** Wait for the next step or, while the run works, the next new stage, and remember what was reported. */
async function nextUpdate(record: CheckoutRecord, options: WaitOptions): Promise<CheckoutSnapshot> {
const snapshot = await waitForCheckout(record.runId, { progressSince: record.lastStage ?? null, ...options });
if (snapshot.progress) record.lastStage = snapshot.progress.stage;
return snapshot;
}

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: start returns within about twenty-five seconds, and status waits for the next step or, while the run works, the next progress stage, up to a minute. When the snapshot carries progress, tell the person in one short sentence what Crossmint is doing, then call status again. 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 });
const record = { runId, maxCost: input.maxCost, principalId: ctx.session.auth.current?.principalId ?? null };
rememberCheckout(sessionId, record);
return await nextUpdate(record, { signal, timeoutMs: FIRST_UPDATE_MS });
}
case "status":
return await nextUpdate(assertSessionCheckout(sessionId, input.runId), { signal, timeoutMs: UPDATE_MS, minProgressMs: MIN_UPDATE_MS });
case "answer": {
const record = 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 nextUpdate(record, { signal, timeoutMs: UPDATE_MS, minProgressMs: MIN_UPDATE_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);
},
});
71 changes: 71 additions & 0 deletions agent/tools/crossmint_pay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { defineTool, toolOutput } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { amount, currency, errorOutput, notConfigured, payee, requestId, requesterOnly, runId, WAIT_MS, type CheckoutToolOutput } from "../lib/crossmint";
import { CrossmintSetupError, getCheckout, payWithOrderIntent, waitForCheckout } from "../../server/crossmint/client";
import { assertPaymentMatches, PaymentMismatchError } from "../../server/crossmint/payment";
import { assertSessionCheckout } from "../../server/crossmint/runs";

const inputSchema = z.object({
runId,
requestId: requestId.describe("The open payment request's id."),
amount: amount.describe("The exact total Crossmint is asking for, copied from the payment request."),
currency,
merchant: z.string().min(1).max(255).describe("The merchant URL or host from the payment request."),
merchantName: z.string().min(1).max(120).optional().describe("The merchant's name from the payment request, when it has one."),
});

type Input = z.infer<typeof inputSchema>;

type PayOutput = CheckoutToolOutput | { status: "payment_not_configured"; runId: string; requestId: string; hint: string };

export default defineTool({
description:
"Pay the open payment request of a Crossmint checkout with the person's own card, through the order intent held on the server. Copy amount, currency, and merchant exactly from the payment request, and its merchant name when it has one. Eve stops and asks the person to approve this call. The server then compares those three values with the live request and with the cap the run started with, and refuses on any difference. The card number never reaches this assistant. Needs an authenticated sender. Call once per payment request. Use crossmint_checkout decline if the person says no.",
inputSchema,
label: {
start: (input: Readonly<Input>) => `Paying ${input.amount} ${input.currency} at ${payee(input)} through Crossmint`,
},
approval: {
request: always(),
prompt: ({ input }) => `Pay ${input.amount} ${input.currency} at ${payee(input)} with your saved card?`,
response: requesterOnly,
},
async execute(input, ctx): Promise<PayOutput> {
ctx.abortSignal.throwIfAborted();
const unavailable = notConfigured();
if (unavailable) return unavailable;
const auth = ctx.session.auth.current;
if (!auth) {
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 record = assertSessionCheckout(ctx.session.id, input.runId);
if (record.principalId !== null && record.principalId !== auth.principalId) {
return { status: "error", reason: "principal_mismatch", message: "A different sender started this checkout." };
}
try {
assertPaymentMatches(input, await getCheckout(input.runId, { signal }), record);
await payWithOrderIntent(input.runId, input.requestId, { signal });
} catch (error) {
if (error instanceof PaymentMismatchError) return { status: "error", reason: error.reason, message: error.message };
if (error instanceof CrossmintSetupError && error.setting === "CROSSMINT_ORDER_INTENT_ID") {
return {
status: "payment_not_configured",
runId: input.runId,
requestId: input.requestId,
hint: "No order intent is configured on the server, so Bell cannot pay. Decline the request and tell the person. See docs/crossmint.md.",
};
}
throw error;
}
return await waitForCheckout(input.runId, { signal, timeoutMs: WAIT_MS, skipRequestId: input.requestId });
} catch (error) {
return errorOutput(error);
}
},
toModelOutput(output) {
return toolOutput.json(output);
},
});
15 changes: 15 additions & 0 deletions app/api/crossmint/jwks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { operatorJwks, parseRsaPrivateKey } from "../../../../server/crossmint/operator-jwt";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const headers = { "cache-control": "no-store", "x-content-type-options": "nosniff" };

// Public by design: Crossmint fetches this to check the operator JWTs Bell signs.
export function GET() {
const configured = process.env.CROSSMINT_JWT_PRIVATE_KEY?.trim();
if (!configured) return new Response("Not found.", { status: 404, headers });
const key = parseRsaPrivateKey(configured);
if (!key) return new Response("Check CROSSMINT_JWT_PRIVATE_KEY in the server configuration.", { status: 500, headers });
return Response.json(operatorJwks(key), { headers: { ...headers, "cache-control": "public, max-age=300" } });
}
Loading