> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tallyforagents.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a paying agent

> Wire Tally into an LLM agent loop so it can spend with policy-bounded safety.

This guide takes the [Quickstart](/quickstart) further: you'll wire Tally into an actual LLM agent so it can decide when and where to spend, with the policy you approved acting as the safety net.

We'll use OpenAI's function calling because it's the most concrete and least framework-bound. The same pattern works in Anthropic's tool use, LangChain, LangGraph, or anything else with structured tool definitions.

## What you'll build

A research agent that pays for API credits on demand. The LLM sees a `buy_credits` tool, decides when to call it, and your code uses Tally to settle the payment. The policy you approved in the dashboard caps how much it can spend.

## Prerequisites

* [Quickstart](/quickstart) complete: an agent registered, a permission granted on your test wallet, Sepolia USDC in the wallet, a working `tally.payments.create()` call.
* An OpenAI API key (or any LLM with tool/function calling).

## The shape

Every paying-agent loop looks like this:

1. The LLM gets a prompt and a list of tools, one of which is "pay for something."
2. The LLM decides to call the payment tool with structured arguments.
3. **Your code** receives the tool call, asks Tally to execute the payment, and returns the result to the LLM.
4. The LLM uses the result (success, failure, balance, etc.) to continue.

Step 3 is where Tally's policy enforcement runs. The LLM never holds keys, never sees raw amounts in cents, never crosses your wallet directly — it asks your code to spend, and your code lets Tally and Privy decide whether to actually move funds.

## 1. Define the tool

```ts theme={null}
const tools = [
  {
    type: "function",
    function: {
      name: "buy_credits",
      description: "Buy USDC-priced credits from a vendor. Returns the on-chain tx hash.",
      parameters: {
        type: "object",
        properties: {
          vendor: {
            type: "string",
            description: "The vendor key, e.g. 'arxiv', 'openalex', 'serpapi'.",
          },
          amount_usd: {
            type: "string",
            description: "Decimal USD amount, e.g. '5.00'.",
          },
        },
        required: ["vendor", "amount_usd"],
      },
    },
  },
] as const;
```

The LLM sees this schema and decides when to call it. Notice the tool doesn't expose the wallet address, the agent id, or anything sensitive — that's up to your code.

## 2. Map vendor → recipient address

You'll need to translate a friendly vendor name into an EVM address Tally can pay. Keep this table somewhere your code controls — never let the LLM pick the destination.

```ts theme={null}
const VENDOR_ADDRESSES: Record<string, string> = {
  arxiv: "0xC0fee04a1b2C3D4e5F6a7B8c9D0e1F2a3B4c5D6e",
  openalex: "0xD1e7C0fee2A3b4C5D6e7F8a9B0c1D2e3F4a5B6c",
  serpapi: "0xA1B2c3D4e5F6a7B8c9D0e1F2a3B4c5D6e7F8a9B0",
};
```

This pattern — LLM picks a label, your code maps the label to an address — is the safety hinge. The LLM can't make up an address; even if it tried, Tally's `recipient_allowlist` policy bound would block it.

## 3. Implement the tool handler

```ts theme={null}
import { Tally, AuthenticationError, ValidationError } from "@tallyforagents/sdk";

const tally = new Tally({
  apiKey: process.env.TALLY_API_KEY!,
  baseUrl: process.env.TALLY_BASE_URL,
});

async function handleBuyCredits(args: {
  vendor: string;
  amount_usd: string;
}): Promise<string> {
  const to = VENDOR_ADDRESSES[args.vendor];
  if (!to) {
    return `Unknown vendor: ${args.vendor}. Valid options: ${Object.keys(VENDOR_ADDRESSES).join(", ")}.`;
  }

  try {
    const payment = await tally.payments.create({
      agent_id: "research-bot",
      wallet: process.env.TALLY_WALLET_ADDRESS!,
      to,
      amount_usdc: args.amount_usd,
      memo: `${args.vendor} credits`,
      idempotency_key: `${args.vendor}-${Date.now()}`,
    });

    return `Paid ${args.amount_usd} USDC to ${args.vendor}. Tx: ${payment.tx_hash}`;
  } catch (err) {
    if (err instanceof AuthenticationError && err.code === "amount_too_large") {
      return `Cannot spend ${args.amount_usd}: exceeds the per-transaction limit on the permission.`;
    }
    if (err instanceof AuthenticationError && err.code === "daily_cap_exceeded") {
      return `Daily spend cap reached. Try again tomorrow, or extend the permission.`;
    }
    if (err instanceof ValidationError) {
      return `Invalid request: ${err.message}`;
    }
    throw err;
  }
}
```

Two things to notice:

1. **Errors are returned to the LLM as natural language**, not thrown. The LLM can then decide what to do — try a smaller amount, ask the user for guidance, abandon the task. Throwing breaks the agent loop.
2. **The idempotency key** is deterministic-ish within a session. For a production agent, use something more stable than `Date.now()` — a request id, a session id, a workflow step id — so retries within the same session are safe.

## 4. The agent loop

```ts theme={null}
import OpenAI from "openai";
const openai = new OpenAI();

async function runAgent(prompt: string) {
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    { role: "system", content: "You are a research assistant. You have a small USDC budget for API credits." },
    { role: "user", content: prompt },
  ];

  while (true) {
    const res = await openai.chat.completions.create({
      model: "gpt-4o",
      messages,
      tools,
    });
    const choice = res.choices[0];
    messages.push(choice.message);

    if (!choice.message.tool_calls) {
      return choice.message.content;
    }

    for (const call of choice.message.tool_calls) {
      const args = JSON.parse(call.function.arguments);
      let result: string;
      switch (call.function.name) {
        case "buy_credits":
          result = await handleBuyCredits(args);
          break;
        default:
          result = `Unknown tool: ${call.function.name}`;
      }
      messages.push({
        role: "tool",
        tool_call_id: call.id,
        content: result,
      });
    }
  }
}
```

Now `runAgent("Find recent papers on RLHF.")` runs an LLM loop where the model can buy credits from approved vendors as needed. Every payment is bounded by the policy you approved for `research-bot`; the LLM has no way to bypass it.

## Per-agent attribution

If you run several agents in parallel — `research-bot`, `summarizer`, `publisher` — give each its own Tally agent id and a separate permission with its own policy. The dashboard will show per-agent spend; webhooks will carry the originating `agent_id` so your downstream system can attribute too.

```ts theme={null}
// Different agents have different budgets
await tally.agents.upsert({ id: "research-bot" });   // $50/day
await tally.agents.upsert({ id: "publisher" });      // $5/day, recipient-allowlisted

// Each gets its own permission, granted from the dashboard.
```

## Patterns

* **Return errors as content, not exceptions.** The LLM can recover from a structured error message ("daily cap reached") in a way it can't recover from an exception.
* **Keep recipient addresses out of LLM-visible state.** Map labels server-side. Tally's `recipient_allowlist` is a second line of defense, but server-side mapping prevents the LLM from even trying.
* **Use idempotency keys derived from a deterministic source.** A workflow step id, a tool call id, an invoice id — anything that survives a retry. Avoid `Date.now()` in production.
* **Subscribe to `payment.confirmed` webhooks** for downstream reconciliation. The LLM gets a `pending` payment back from the tool call; if your business logic cares about confirmation, react in the webhook, not in the agent loop.

See [Handle failures and retries](/guides/handling-failures) for the full recovery playbook.
