Build an agent with the Vercel AI SDK
Tools, the tool-approval policy, and how a UI collects the Ledger signature.
npm i @yashjain99/mandate-ai @yashjain99/mandate-sdk ai zod viemMinimal server loop
import { streamText, stepCountIs } from "ai";
import { createMandateFromEnv } from "@yashjain99/mandate-sdk";
import { mandateTools, mandateToolApproval, MANDATE_SYSTEM_PROMPT } from "@yashjain99/mandate-ai";
const client = createMandateFromEnv();
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model,
system: MANDATE_SYSTEM_PROMPT,
messages,
tools: mandateTools(client),
toolApproval: mandateToolApproval(client), // guardian steps pause for a human
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
stopWhen: stepCountIs(8),
});
return result.toUIMessageStreamResponse();
}mandateTools(client) returns nine tools: get_positions, get_markets, draft_plan, simulate_plan, execute_step, check_repayments, draft_repayment_plan, get_plan, get_audit. mandateToolApproval(client) returns "user-approval" for an execute_step call whose step requires the guardian and is not yet done, so the model pauses and your UI gets an approval request. See AI tools reference.
Collecting the signature in your UI
When the stream pauses with a tool approval request for execute_step:
GET /api/approval?planId=…&step=…on your server callsclient.approvals.request(plan, step)and returns{ text, guardian, chainId, deadline, nonce }. Build it server-side, cache it per plan, step and nonce, and never accept text from the browser.- Show the text. Have the Ledger sign it:
ledgerWebGuardian().signMessage(text)from@yashjain99/mandate-sdk/ledger-web. POST /api/approvalwith{ planId, step, signature }; the server callsclient.approvals.submit(plan, step, signature), which verifies the signer against the on-chain guardian and stores it.- Approve the tool call in the chat stream.
execute_stepnow finds the stored approval and runsexecuteWithGuardian.
The console in apps/web is the reference implementation: app/api/chat/route.ts, app/api/approval/route.ts and components/ApprovalSheet.tsx.
Without a UI (auto-sign for tests)
import { localGuardian } from "@yashjain99/mandate-sdk";
const client = createMandateFromEnv({ guardian: localGuardian(process.env.DEV_GUARDIAN_KEY as `0x${string}`) });With a GuardianSigner configured, execute signs guardian steps inline and no pause happens. Use this only for tests; the working example is examples/vercel-ai-agent:
pnpm --filter example-vercel-ai-agent start "Show my positions and the cheapest USDC borrow rate for 3 USDC."Any other framework
The tools are plain ai ToolSet objects with zod input schemas, so they work with generateText, ToolLoopAgent, or can be unwrapped: tools.get_positions.execute({}, ctx). For MCP-based frameworks use registerMandateMcpTools(server, client) on any McpServer. For no framework at all, use the SDK directly: Node script.
Production notes
- Keep
stopWhensmall on serverless hosts.execute_stepcan wait up to four minutes on a CCTP attestation; eight steps fits a 300-second function window. - Add a kill switch: wrap
execute_stepso an env flag returns{ ok: false, error: "disabled by the operator" }. The console does this inapps/web/lib/agent.ts. - Rate limit the chat route. The agent key is funded; the mandate bounds on-chain spend but not model spend. See Operate the console.