Open the console

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 tokens

At the rates above, a turn of 10,000 prompt tokens and 2,000 output, none of it cached:

TokensRateCharge
Input10,000$0.20 /M$0.0020
Output2,000$3.00 /M$0.0060
$0.0080

The same turn once 8,000 of the prompt is served from cache:

TokensRateCharge
Input2,000$0.20 /M$0.0004
Cached8,000$0.10 /M$0.0008
Output2,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.4305

At 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

OperatorTakesMeans
varone stringAn argument of the call by dotted path: {"var": "amount_micros"}, or {"var": "order.total"} for a nested one.
addtwo or moreSum.
subtractexactly twoa − b. Not commutative, so a third operand would be ambiguous.
multiplytwo or moreProduct.
divideexactly twoa ÷ b, floored.
mintwo or moreThe smallest. This is how you write a cap; there is no separate field for one.
maxtwo or moreThe 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:

ComparisonMeans
> >= < <=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
    }
},
rowsCharge
500$0.005the flat branch
1,000$0.005still flat
1,001$0.0050055 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 with multiply and divide, which is what basis points are for. An argument the model passes as a float is floored.
  • A var argument is already in micro-USD. The platform cannot know whether your amount counts 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 -100 as 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.
  • multiply before divide. divide floors, 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.0051450
$4.505130,645130,500
$4.500130,500130,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

CaseWhat happens
A model built-in such as google_searchRefused at build time. The model runs it, so nothing here ever sees the call.
A name matching no toolCharges 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 argumentRefuses 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-circuitedNever ran, never charged.
A tool waiting on a confirmationNot 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_id

runtime.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 seeWhat happenedWhat 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.

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.