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.
This guide takes the 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 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:
- The LLM gets a prompt and a list of tools, one of which is “pay for something.”
- The LLM decides to call the payment tool with structured arguments.
- Your code receives the tool call, asks Tally to execute the payment, and returns the result to the LLM.
- 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.
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.
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.
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:
- 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.
- 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
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.
// 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 for the full recovery playbook.