BlockRun
Back to Signal
Aug 2026

AG-UI Pauses the Agent. x402 Prices the Call.

An agent walking toward a turnstile checkpoint while a human hand lowers a price tag above it; a receipt with a check mark waits on the other side

BlockRun Engineering · August 2026

Two protocols shipped in the last year that most agent builders will end up using without thinking about them. AG-UI standardized the wire between an agent and the person watching it — text deltas, tool calls, state snapshots, and the pause-and-resume needed to ask the human something. CopilotKit published it, and Google, LangChain, AWS, Microsoft, Mastra and PydanticAI adopted it. x402 standardized the wire between an agent and a paid service — an HTTP 402 that carries a price, a signed USDC authorization in the retry, a receipt in the response.

They meet at one moment the two specs never discuss together: the instant before an agent spends money. AG-UI has a primitive for pausing there. x402 has a primitive for pricing there. This post wires them together, with code you can run.

§ 1 — The moment neither protocol owns

An agent on x402 doesn't need permission to pay. That is the point of the protocol: the agent holds a wallet, the service names a price, the agent signs. No account, no key, no invoice. A budget overrun we wrote about earlier was prevented by that property, because a per-call price is a per-call opportunity to say no.

But "the agent can say no" is not the same as "the person can say no." Every autonomy story eventually meets an action whose cost or consequence is above what the operator delegated. CopilotKit's docs list exactly this case for their interrupt primitive: "A sensitive action (payments, irreversible writes) must be approved." The interesting thing is that they wrote that sentence for a UI framework, with no payment rail in mind. The rail already exists. The two just needed an adapter.

§ 2 — What a 402 actually carries

The reason this works at all is that x402's challenge is a quote, not a bill. Before any money moves, the service answers with a 402 whose payment-required header is a base64 JSON document. Here is a live one from BlockRun, decoded, for a GPT-5.5 call with a 300-token output cap, captured 2026-08-29:

curl -s -D - -o /dev/null https://blockrun.ai/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"openai/gpt-5.5","max_tokens":300,
       "messages":[{"role":"user","content":"Summarize the x402 protocol in three sentences."}]}' \
  | grep -i '^payment-required:' | cut -d' ' -f2 | base64 -d
{
  "accepts": [{ "network": "eip155:8453", "amount": "2095", "asset": "0x833589…" }],
  "resource": {
    "url": "https://blockrun.ai/api/v1/chat/completions",
    "description": "GPT-5.5 API call (~39 input, 300 max output tokens)"
  }
}

amount is in USDC's six decimals: $0.002095, for this request, on this model, quoted before a single token is generated. Nothing was signed, nothing was charged, and the description is human-readable on purpose. That is the string you show a person.

This is the property that makes a confirmation dialog honest. A dialog that says "this may cost something" is a liability waiver. A dialog that says "$0.0021 — GPT-5.5, ~39 input, 300 max output tokens" is a decision.

§ 3 — The loop

Four steps, two of which already exist in each protocol. Steps 1 and 4 are plain x402; steps 2 and 3 are plain AG-UI. The green box is the only new idea in this post.

Sequence diagram: the agent POSTs to BlockRun without a payment header and receives a 402 with a $0.0021 quote; it calls confirmSpend and the run pauses; the human approves, which writes approvals[quoteId] into shared state (client-authored, never a model tool result); the agent POSTs again with a signed payment header and receives the response and a receipt

One design rule carries the whole thing: the approval must not travel through the model. With CopilotKit's useHumanInTheLoop, the LLM decides to call a client-side tool, the UI renders, the user answers, and the answer comes back to the model as a tool result. That is the right shape for "which time slot do you want," and the wrong shape for "may I spend your money," because the model relays it. A model that hallucinates { approved: true } has just approved itself.

So the approval goes in the other channel AG-UI gives you: shared state. The frontend writes approvals[quoteId] = true with agent.setState(...), and that state arrives on the server in the next run's RunAgentInput.state — client-authored, not model-authored. The server-side pay tool checks the state, not the transcript. The model still gets a tool result, so it can narrate what happened, but it never holds the key.

§ 4 — The code

Two files. Server first: a CopilotKit runtime with a BuiltInAgent in factory mode, driven by the Vercel AI SDK. The agent's own reasoning runs on one of BlockRun's free models — no wallet, no key, and it does tool calling — so the only money in the loop is the paid call the human approves.

Install:

npm install @copilotkit/react-core @copilotkit/runtime @blockrun/llm \
            ai @ai-sdk/openai-compatible zod hono
import {
  CopilotRuntime, createCopilotHonoHandler, InMemoryAgentRunner, BuiltInAgent,
  convertMessagesToVercelAISDKMessages, convertToolsToVercelAITools,
} from "@copilotkit/runtime/v2";
import { handle } from "hono/vercel";
import { streamText, tool, stepCountIs } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { LLMClient, parsePaymentRequired, extractPaymentDetails } from "@blockrun/llm";
import { z } from "zod";

const API = "https://blockrun.ai/api/v1";
const AUTO_APPROVE_BELOW_USD = 0.01;

// The agent's brain: a free BlockRun model. No wallet, no API key.
const free = createOpenAICompatible({ name: "blockrun", baseURL: API });
const brain = free("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning");

// The agent's wallet: signs x402 payments from BASE_CHAIN_WALLET_KEY. Never sent anywhere.
const wallet = new LLMClient();

// Quotes live server-side, keyed by an id the model can reference but not fabricate.
type Quote = { model: string; prompt: string; maxTokens: number; usd: number; description: string };
const quotes = new Map<string, Quote>();

// x402 step 1: ask without paying. The 402 IS the quote.
async function quote(model: string, prompt: string, maxTokens: number): Promise<Quote> {
  const r = await fetch(`${API}/chat/completions`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }),
  });
  if (r.status !== 402) throw new Error(`expected 402, got ${r.status}`);
  const details = extractPaymentDetails(parsePaymentRequired(r.headers.get("payment-required")!));
  return {
    model, prompt, maxTokens,
    usd: Number(details.amount) / 1e6,                       // USDC has 6 decimals
    description: details.resource?.description ?? model,
  };
}

const agent = new BuiltInAgent({
  type: "aisdk",
  factory: ({ input, abortSignal }) => {
    // Client-authored state. The model cannot write here; only the UI can.
    const approvals = ((input.state as any)?.approvals ?? {}) as Record<string, true>;

    const tools = {
      // Frontend tools (confirmSpend lives in the browser) are merged in here.
      ...convertToolsToVercelAITools(input.tools),

      quoteBlockRun: tool({
        description: "Get the exact USD price of a paid model call before running it.",
        inputSchema: z.object({ model: z.string(), prompt: z.string(), maxTokens: z.number().default(300) }),
        execute: async ({ model, prompt, maxTokens }) => {
          const q = await quote(model, prompt, maxTokens);
          const quoteId = crypto.randomUUID();
          quotes.set(quoteId, q);
          return { quoteId, usd: q.usd, description: q.description,
                   needsApproval: q.usd >= AUTO_APPROVE_BELOW_USD };
        },
      }),

      runBlockRun: tool({
        description: "Pay for and run a previously quoted call. Refuses unapproved spend over the threshold.",
        inputSchema: z.object({ quoteId: z.string() }),
        execute: async ({ quoteId }) => {
          const q = quotes.get(quoteId);
          if (!q) return { error: "unknown quote" };
          // Enforced here, in code — not in the prompt, not in the model's memory.
          if (q.usd >= AUTO_APPROVE_BELOW_USD && !approvals[quoteId]) {
            return { error: `$${q.usd.toFixed(4)} needs human approval; call confirmSpend first` };
          }
          // x402 step 2: the SDK re-sends with a signed payment header and returns the body.
          const res = await wallet.chatCompletion(q.model, [{ role: "user", content: q.prompt }], { maxTokens: q.maxTokens });
          quotes.delete(quoteId);
          return {
            receipt: { quoteId, model: res.model, usd: q.usd, sessionUsd: wallet.getSpending().totalUsd },
            answer: res.choices[0].message.content,
          };
        },
      }),
    };

    return streamText({
      model: brain,
      system: `You can run paid models on the user's behalf. Always call quoteBlockRun first.
If needsApproval is true, call confirmSpend with the quoteId, usd and description, and wait.
Only call runBlockRun after a quote is approved (or was below the threshold).`,
      messages: convertMessagesToVercelAISDKMessages(input.messages),
      tools,
      stopWhen: stepCountIs(6),
      abortSignal,
    });
  },
});

const runtime = new CopilotRuntime({ agents: { default: agent }, runner: new InMemoryAgentRunner() });
const app = createCopilotHonoHandler({ runtime, basePath: "/api/copilotkit" });
export const GET = handle(app);
export const POST = handle(app);

Now the client. useHumanInTheLoop registers confirmSpend as a tool the model can call; when it does, the run pauses on the Executing state until respond fires. The one line that matters is the setState before respond — that is the approval leaving through the state channel.

"use client";
import { CopilotKit, CopilotChat, useAgent, useHumanInTheLoop, useRenderToolCall, ToolCallStatus }
  from "@copilotkit/react-core/v2";
import { z } from "zod";

function SpendGate() {
  const { agent } = useAgent();

  useHumanInTheLoop({
    name: "confirmSpend",
    description: "Ask the user to approve a paid call over the auto-approve threshold.",
    parameters: z.object({ quoteId: z.string(), usd: z.number(), description: z.string() }),
    render: ({ args, status, respond, result }) => {
      if (status === ToolCallStatus.Executing && respond) {
        const approve = () => {
          const approvals = { ...(agent.state?.approvals ?? {}), [args.quoteId]: true };
          agent.setState({ ...agent.state, approvals });   // client-authored, server-checked
          respond({ approved: true });
        };
        return (
          <div className="card">
            <p><b>${args.usd.toFixed(4)}</b> — {args.description}</p>
            <button onClick={approve}>Approve</button>
            <button onClick={() => respond({ approved: false })}>Decline</button>
          </div>
        );
      }
      if (status === ToolCallStatus.Complete) return <p>{result}</p>;
      return <p>Pricing…</p>;
    },
  });

  // Render the receipt from the server tool's result.
  useRenderToolCall({
    name: "runBlockRun",
    render: ({ status, result }) =>
      status === ToolCallStatus.Complete && result
        ? <pre>{JSON.stringify(JSON.parse(result).receipt, null, 2)}</pre>
        : null,
  });

  return null;
}

export default function Page() {
  return (
    <CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}>
      <SpendGate />
      <CopilotChat />
    </CopilotKit>
  );
}

Ask it "Have GPT-5.5 summarize the x402 protocol in three sentences." The agent quotes ($0.0021 — under the threshold, so it runs straight through). Ask for a 4,000-token essay from a flagship model and the card appears with the real number, the run holds, and nothing is signed until you click. Decline, and the model gets { approved: false } as its tool result and explains that it didn't spend; try to skip the gate, and runBlockRun refuses because approvals in state is empty.

A few things worth knowing about what is and isn't in that code:

  • The quote costs a round trip, not money. Two requests per paid call. If that bothers you, the threshold does most of the work — under it, you can skip straight to runBlockRun and let the SDK handle the 402 in one hop.
  • The approval is per-quote, not per-session. A quoteId is minted server-side and deleted after use. Approving one call does not approve the next.
  • Shared state is the audit trail. approvals is already in agent.state; the receipt from runBlockRun can go in the same object with one more setState, and both survive the run because AG-UI snapshots state, not just messages.
  • InMemoryAgentRunner and a Map are demo-grade. Swap both for your store before production; the pattern doesn't change.

§ 5 — Complementary, on purpose

It would be easy to read this as BlockRun competing for the agent-UI layer. It isn't. CopilotKit's claim is to be "the horizontal layer between your agents and your users." BlockRun's is narrower and underneath it: the layer between agents and the things they pay for — models, search, image and video, RPC, prediction markets — priced per call on one USDC balance. The AG-UI event stream is the right place for a payment to become visible to a person. x402 is the right place for that person's "yes" to become a signed authorization. A confirm card that shows a real price is where the two protocols were always going to meet.

If you build on LangGraph, Mastra, PydanticAI or any other AG-UI backend, nothing above is React-specific except the hooks. The state channel and the 402 quote work the same from Python: copilotkit_emit_state on one side, a bare POST and a base64 decode on the other.

Try it without a wallet

The agent brain in § 4 ran on a free model. As of 2026-08-29 BlockRun serves five of them, keyless, on the same endpoint as the paid catalog — the /v1/models response marks them with a price of 0:

curl -s https://blockrun.ai/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"nvidia/step-3.7-flash","messages":[{"role":"user","content":"Say hi in three words"}]}'

For an agent that should know how to use BlockRun on its own, give it the MCP server (Claude Code shown; the same package works in Cursor and Codex):

claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest

Any other agent can read blockrun.ai/skill.md — a one-time installer that carries the same wiring. Fund the wallet it creates, and the first 402 it meets will be the one it pays.

All articles →