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


See the [repository layout and implementation map](docs/repository.md) to extend Bell.
Expand Down
11 changes: 11 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ 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.
35 changes: 35 additions & 0 deletions agent/lib/crossmint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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." };
}

/** 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;
}
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);
},
});
75 changes: 75 additions & 0 deletions agent/tools/crossmint_pay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { defineTool, toolOutput } from "eve/tools";
import { always } from "eve/tools/approval";
import { z } from "zod";
import { amount, currency, errorOutput, notConfigured, requestId, 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."),
});

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. 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 ${input.merchant} through Crossmint`,
},
approval: {
request: always(),
prompt: ({ input }) => `Pay ${input.amount} ${input.currency} to ${input.merchant} with your Crossmint order intent?`,
response: ({ request, response }) => {
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." };
},
},
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);
},
});
Loading