Guides
Charging your users
Your agent costs you money to run. This is how you charge your users for it: give each one a balance, price the agent, let their browser watch the reply, and run on their behalf. It is your book, kept by the platform in US dollars, and separate from what the platform bills you — see Billing and the ledger for the two books and how a deposit works.
Nothing here is on by default
An agent with no prices charges nobody, and a run for a user with no wallet spends your tenant balance rather than theirs. You can switch this on for one agent and one user while everything else carries on unchanged.
1. Get a credential
In the console, open your project and go to API access. Create a service user with Can write on: crediting a wallet, minting a ticket and running for one of your users all need it. One that only reads balances can have it off.
You get a client id and a client secret, and the secret is shown once — store it when you see it, and press Rotate if you lose it. Exchange the pair for a short-lived token and send it as Authorization: Bearer …; Authentication has the exchange and what the token carries. The credential is issued for that one project, which matters at the end of this page.
2. Give a user a balance
API=https://api.openelan.ai
AUTH="Authorization: Bearer $TOKEN"
# Create the wallet. Safe to call on every sign-in.
curl -s -X POST "$API/v1/projects/agents/wallets" -H "$AUTH" \
-H 'content-type: application/json' \
-d '{"external_user_id": "ada", "display_label": "Ada Lovelace"}'
# $5.00 of credit. `micros` is micro-USD: 5000000 is five dollars.
curl -s -X POST "$API/v1/projects/agents/wallets/ada/credits" -H "$AUTH" \
-H 'content-type: application/json' \
-d '{"micros": 5000000, "ref": "promo:launch"}'external_user_id is whatever you already call this person — an email, a UUID, your own primary key. It only has to be unique within the project, and it may not be * or contain : or /. Balances are US dollars: USD is the only currency a wallet accepts today.
Creating is idempotent; crediting is not. A repeat create returns the existing wallet with a 200 instead of a 201. A repeat credit issues the credit twice, and ref is the only thing that will tell you so afterwards — send one.
Or from a terminal inside your project, or the project's Wallets page in the console:
elan wallet create ada --label "Ada Lovelace" elan wallet credit ada 5 --ref promo:launch
3. Price your agent
In the agent's MANIFEST, say what you charge, in US cents per million tokens:
from agentkit_core.manifest import AgentManifest, Billing
MANIFEST = AgentManifest(
slug="hotel-concierge",
version="0.9.0", # bump it; a changed agent with the same version is refused
display_name="Hotel Concierge",
description="Answers guests.",
billing=Billing(
sku="agent.chat.standard",
input_token_cost=20, # $0.20 per million
cached_input_token_cost=10, # $0.10 per million
output_token_cost=300, # $3.00 per million
deposit_micros=12_000, # hold $0.012 per run, see below
),
)Then elan deploy. Until that deploy lands the agent is unpriced and charges nobody. Once any price is set, deposit_micros is required and the build refuses the manifest without it.
sku is a label you choose, to group your spend the way you want to read it. The ledger stores it as <tenant>/<project>/<sku>, so another customer using the same word gets a different row from yours, and it is registered on the first run — there is nothing to create first. That stored form is capped at 64 characters, so a long tenant and project name leave less room. A typo is a new label rather than an error: it charges correctly and reports under a name you did not mean.
Billing(unbilled=True) is the other end of the dial: the target skips the ledger entirely — no deposit, no gate, nothing charged — and needs no sku. Pod compute is still billed to you either way.
How a price becomes a charge
charge = input_tokens_not_cached × input_token_cost
+ cached_tokens × cached_input_token_cost
+ output_tokens × output_token_cost
────────────────────────────────────────────────── ÷ 1,000,000 tokensAt the rates above, a turn of 10,000 prompt tokens and 2,000 output, none of it cached:
| Tokens | Rate | Charge | |
|---|---|---|---|
| Input | 10,000 | $0.20 /M | $0.0020 |
| Output | 2,000 | $3.00 /M | $0.0060 |
| $0.0080 |
The same turn once 8,000 of the prompt is served from cache:
| Tokens | Rate | Charge | |
|---|---|---|---|
| Input | 2,000 | $0.20 /M | $0.0004 |
| Cached | 8,000 | $0.10 /M | $0.0008 |
| Output | 2,000 | $3.00 /M | $0.0060 |
| $0.0072 |
Cached tokens are a part of the prompt count, so set the cached rate below the input rate or a cache hit costs more than a miss. Reasoning tokens are billed as output. Charges are whole micro-USD and the total is floored, so rounding favors your user and a very short turn can cost nothing. The rates and the manifest version are stamped on every charge as applied, so moving a price never rewrites what you already charged.
Pricing a tool call
Tokens cannot answer “what did this tool cost me” — a tool that calls a paid API costs the same whether the turn was 50 tokens or 5,000. tool_costs prices a call directly, keyed by tool name:
billing=Billing(
sku="agent.chat.standard",
output_token_cost=300, # cents per MILLION tokens
deposit_micros=12_000,
tool_costs={
"lookup_order": 200, # MICRO-USD per CALL
},
),Two units in one block
200 in tool_costs is $0.0002 for one call. 200 as output_token_cost is $2.00 per million tokens. They are 10,000x apart, and the field name is the only thing that says which is which.
Three calls to lookup_order in one run cost $0.0006. A tool price is also the only way to charge for an agent or workflow called as a tool: a nested run carries none of your plugins, so its tokens reach no charge. Price the tool that wraps it and you recover the cost.
When the price depends on the call
Most tools do not cost a flat fee. A refund costs a percentage, with a floor and a ceiling — so a price may be an expression over the call's own arguments. This is $0.30 + 2.90%, capped at $5:
tool_costs={
"issue_refund": {
"min": [
{"add": [
300_000,
{"divide": [
{"multiply": [{"var": "amount_micros"}, 290]},
10_000,
]},
]},
5_000_000,
]
},
},Evaluated for a $4.50 refund, innermost first:
{"var": "amount_micros"} → 4_500_000 the argument, in micro-USD
{"multiply": [..., 290]} → 1_305_000_000 290 basis points
{"divide": [..., 10_000]} → 130_500 = 2.90%
{"add": [300_000, ...]} → 430_500 + the $0.30 fixed part
{"min": [..., 5_000_000]} → 430_500 under the cap
↑ charged $0.4305At a $200 refund the same price comes to 300_000 + 5_800_000 = 6_100_000, which the min reduces to $5.00.
The operators
| Operator | Takes | Means |
|---|---|---|
var | one string | An argument of the call by dotted path: {"var": "amount_micros"}, or {"var": "order.total"} for a nested one. |
add | two or more | Sum. |
subtract | exactly two | a − b. Not commutative, so a third operand would be ambiguous. |
multiply | two or more | Product. |
divide | exactly two | a ÷ b, floored. |
min | two or more | The smallest. This is how you write a cap; there is no separate field for one. |
max | two or more | The largest, which is a minimum fee. |
if | {"condition": …, "then": …, "else": …} | A tier. All three keys are required, and any other is refused. |
An operand is a whole number or another operator, so they nest anywhere a number would go, including inside a condition.
Conditions
An if condition is one comparison, {"<op>": [a, b]}, spelled the same way autonomy rules spell them:
| Comparison | Means |
|---|---|
> >= < <= | Ordering. |
== != | Equality. |
A tiered price:
"bulk_lookup": {
"if": {
"condition": {">": [{"var": "rows"}, 1_000]},
"then": {"multiply": [{"var": "rows"}, 5]}, # 5 micro-USD a row
"else": 5_000, # flat $0.005 below that
}
},| rows | Charge | |
|---|---|---|
| 500 | $0.005 | the flat branch |
| 1,000 | $0.005 | still flat |
| 1,001 | $0.005005 | 5 micro-USD a row takes over |
| 5,000 | $0.025 |
Check that a tier does not invert. The two branches are independent prices, and nothing stops the upper one being cheaper: with 2 micro-USD a row as the then above, 1,001 rows would cost $0.002 while 1,000 cost $0.005. Price the boundary from both sides before you deploy.
The rules it will hold you to
- Whole numbers only. A fraction anywhere in the expression is refused at build time —
{"add": [1.5, 2]}will not deploy. Scale withmultiplyanddivide, which is what basis points are for. An argument the model passes as a float is floored. - A
varargument is already in micro-USD. The platform cannot know whether youramountcounts dollars or cents, and guessing wrong is 100x in somebody's favor. Name an argument your tool already carries in micro-USD, or add one. - A whole price cannot be negative, though an intermediate value can:
{"add": [{"var": "amount_micros"}, -100]}is a legitimate discount, and-100as the whole price is refused. A fee never credits a wallet. - At most 8 levels deep and 64 parts. A price nobody can check against an invoice is not a price.
multiplybeforedivide.dividefloors, so the two orders are not the same price — and the smaller the amount, the worse the wrong order is:
| Refund | ×290 ÷10000 | ÷10000 ×290 |
|---|---|---|
| $0.005 | 145 | 0 |
| $4.505 | 130,645 | 130,500 |
| $4.500 | 130,500 | 130,500 |
Dividing first throws away everything below the divisor before the rate is applied, so a half-cent refund is charged nothing at all. Every rounding on this platform lands in your user's favor.
When it will not charge, and what says so
| Case | What happens |
|---|---|
A model built-in such as google_search | Refused at build time. The model runs it, so nothing here ever sees the call. |
| A name matching no tool | Charges nothing. elan validate cannot catch it, because an MCP server names its own tools, so the pod logs a warning at startup naming the price and the tools the agent really offers. |
A var naming no argument | Refuses every call to that tool rather than undercharging silently, and says so in the tool's result. |
| A tool your policy refused, or your own plugin short-circuited | Never ran, never charged. |
| A tool waiting on a confirmation | Not charged while it waits; charged once, when it runs. |
Each call's resolved arguments are recorded with its charge, so a disputed one can be replayed against the price that applied. A var resolves only to a number, so free text cannot be drawn into the ledger by pricing it.
4. Choose the numbers
The platform does not know what your tokens cost you. You bring your own provider key, so your provider bills you directly. Take their per-million prices, convert to cents, and add whatever margin covers your compute and your profit; set the cached rate below the input rate by roughly your provider's own discount, so a returning user's saving is passed on.
Choosing deposit_micros
The deposit is in micro-USD, like every balance: $0.012 is 12_000. It is reserved before the run, topped up in steps of the same size as the run spends, and released against what was actually used — a first step, not a ceiling. Take a typical turn, price it with your own rates, and round up:
a turn you expect: 10,000 input tokens × $0.20/M = $0.0020
2,000 output tokens × $3.00/M = $0.0060
───────
$0.0080
deposit_micros = 12_000 → $0.012, about 1.5x a typical turn- Not huge. The deposit gates entry: a user whose remaining credit is below it is refused up front, even for a run that would have cost a fraction of it. A $1.00 deposit turns away anybody under a dollar.
- Not tiny. Every top-up is a round trip to the ledger mid-run; one that covers a typical turn means most runs never make one.
- A refusal lands on the next call. When a top-up has no credit behind it, the next model call is skipped with the out-of-credit message, so a run can exceed a balance by at most one call — the wallet can go slightly negative, and what was used is what is charged.
- A priced tool is refused on the same rule, and it matters more: one call can cost several times the deposit. So cap what you price from its own arguments —
{"min": [<your fee>, 5_000_000]}never charges more than $5.
What the platform bills you — pod CPU and memory, plus each successful wallet API call, priced by a rate card you can read on the usage page — is a separate bill that does not scale with tokens.
5. Let their browser listen
A run made for a user belongs to them, so its events go to their socket, not to your backend's. Their page opens it with a ticket: a five-minute credential for one person, one project and one origin, which your backend mints so your client secret never reaches a browser.
# On your backend, behind your own endpoint.
curl -s -X POST "$API/v1/tickets" -H "$AUTH" \
-H 'content-type: application/json' \
-d '{"end_user_id": "ada", "project": "agents",
"origin": "https://app.acme.example"}'
{"ticket": "eyJ…", "expires_in": 300, "end_user_id": "ada"}// In the page. The ticket goes on the handshake, not in a frame.
const { ticket } = await fetch("/api/agent-ticket").then((r) => r.json());
const socket = new WebSocket(
`wss://api.openelan.ai/ws?ticket=${encodeURIComponent(ticket)}`);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "ready") {
socket.send(JSON.stringify({ type: "subscribe", session_id: "sess-ada-1" }));
}
if (message.type === "subscribed") {
// NOW ask your backend to start the run (step 6).
}
// Events carry the reply; see the invocations API for the envelope.
};Subscribe before the run starts. Events published before a socket is subscribed are discarded, not queued, so the page has to see subscribed before your backend invokes — otherwise a warm agent's whole reply is gone.
A ticket opens the event stream and nothing else: every other route refuses it, so a leaked one cannot spend Ada's balance. Two things bite. The origin match is exact — https://acme.example is not https://app.acme.example. And it expires in minutes while a socket reconnects with backoff, so the page must be able to ask your backend for a fresh one rather than caching one for the session. Minting needs Can write, and the project must be the credential's own.
6. Run it on their behalf
curl -s -X POST "$API/v1/invocations" -H "$AUTH" \
-H 'content-type: application/json' \
-d '{"target_slug": "hotel-concierge", "project": "agents",
"end_user_id": "ada",
"session_id": "sess-ada-1", "text": "hi, checking in"}'end_user_id is what makes the wallet pay. Leave it out and the run belongs to your own credential and spends your tenant balance. It needs Can write (otherwise 403), and the run then belongs to Ada in every respect: she owns the conversation, a tool confirmation goes to her rather than to you, and her own autonomy policy applies.
The wallet pays only when both hold: the agent is priced, and a wallet already exists for that user in that project. Otherwise the run quietly spends your tenant balance and charges her nothing — nothing creates a wallet for you.
From the Python SDK, the same two steps:
from elan_sdk import AsyncElan
async def ticket_for(user: str) -> str:
async with AsyncElan.from_env() as elan:
await elan.credit.open(user) # idempotent: free if it exists
ticket = await elan.tickets.mint(user, origin="https://app.acme.example")
return ticket.ticket
async def start_turn(user: str, session_id: str, text: str) -> str:
async with AsyncElan.from_env() as elan:
accepted = await elan.runtime.invoke(
"hotel-concierge", text, session_id=session_id, end_user_id=user)
return accepted.invocation_idruntime.invoke refuses an end_user_id while the same client is holding a socket open, because those events will never arrive on it — a process that invokes and watches at once would wait out its timeout about a run that worked. See Wallets and usage for the rest of the SDK.
The run reserves the deposit against Ada's wallet before a token is spent and settles the real figure after:
$ elan wallet statement ada 2026-10-07T12:05:00 run -$0.0072 3f9c2a7e5b1d4c8a9e6f0b2d7a4c1e8f 2026-10-07T12:00:00 credit $5.00 promo:launch
Held is not spent. A run in flight has reserved what it expects to need, which is why a balance looks lower than you expect mid-conversation. The hold can grow during a long run as it is topped up, and returns to zero when the run settles.
7. Show them what they have left
curl -s "$API/v1/projects/agents/wallets/ada" -H "$AUTH"
{"external_user_id": "ada", "project_slug": "agents", "status": "active",
"currency": "USD",
"balance_micros": 4992800, "held_micros": 0, "available_micros": 4992800}Everything is micro-USD: divide by 1,000,000 for dollars. Render available_micros, which is the balance less what is held, if a run might be in flight.
A statement is GET /v1/projects/agents/wallets/ada/statement, newest first, one signed row per movement:
{"entries": [
{"transaction_id": "txn_…", "kind": "settle", "ref": "3f9c2a7e5b1d4c8a9e6f0b2d7a4c1e8f",
"amount_micros": -7200, "created_at": "2026-10-07T12:05:00+00:00"},
{"transaction_id": "txn_…", "kind": "grant", "ref": "promo:launch",
"amount_micros": 5000000, "created_at": "2026-10-07T12:00:00+00:00"}
],
"total": 2}kind is grant for a credit, settle for a run — its ref is the invocation id — and adjustment for a correction. Every route and status code is in the wallets API.
When a user cannot run
| What they see | What happened | What fixes it |
|---|---|---|
| “This request could not be started because the account balance is too low. Please top up and try again.” | Not enough credit for the deposit — or, mid-run, for the next call. | Credit the wallet. |
| “This account is not currently able to make requests. Please contact support.” | The wallet is frozen or closed. | elan wallet unfreeze ada |
Both arrive as the agent's reply to an invocation that was accepted, and no model call is made. Two messages because the remedies differ — telling somebody whose account you suspended to top it up sends them to do the one thing that cannot help.
Freezing keeps the balance, and a frozen wallet can still be credited; use it to stop spending without touching the money. Closing needs a zero balance, so adjust what is left away first and there is always a transaction saying where it went:
elan wallet freeze ada elan wallet adjust ada -2.50 --ref "applied in error"
Everything is scoped to a project
A tenant runs several products, one project each, and ada in one is not ada in another — a different person with a different balance. A wallet is keyed on the project and your user id, and a service user is issued for one project: calling anything under another project's /v1/projects/<slug>/ answers 403, and so does invoking an agent or minting a ticket there. A project that is not your tenant's at all answers 404. Your own signed-in token carries no project, so as a person you reach every project you own.