Open the console

Guides

Building an agent

An agent is a Python module exporting two names: a MANIFEST saying what it is, and a build() returning the ADK agent that runs. If you are deciding between an agent and a graph, read agents and workflows first.

elan new agent invoice-triage

That writes both halves and the elan.yaml entry, which is the point of it: the module and the deploy file are checked against each other on every deploy, in both directions, and writing one by hand is how you find that out at deploy time instead of now. The split between the two files is explained in elan.yaml and MANIFEST.

The two halves

agents/invoice_triage/
├── __init__.py
└── agent.py          MANIFEST + build()

The platform finds an agent by importing every agents/<dir>/agent.py and reading the MANIFEST it exports. The slug comes from the manifest, not from the directory: the directory is a Python module name, and elan new only derives it from the slug as a convenience. A directory without an agent.py is skipped, not reported, so a misnamed file looks exactly like an absent one.

A slug is 5–16 characters, lower-case letters and digits, starting with a letter, with single hyphens between them and none at the end. The cap is what keeps every name derived from it — namespaces, databases, roles — under 63 characters once your tenant and project are prefixed; a doubled hyphen is refused because -- is the separator between them.

MANIFEST

from agentkit_core.manifest import AgentManifest, Billing, Limits

MANIFEST = AgentManifest(
    slug="invoice-triage",
    version="0.1.0",
    display_name="Invoice Triage",
    description="Reads an invoice and routes it to the right approver.",
    model="gemini-flash-latest",
    limits=Limits(max_concurrent=4, max_llm_calls=8, timeout_s=120),
    billing=Billing(sku="agent.chat.standard"),
)
FieldMeaning
slugThe address. 5–16 characters: lower-case letters and digits, starting with a letter, single hyphens between them. Unique within a project, shared by agents and workflows alike, and it never changes.
versionA label for this definition. A deploy is refused when a target's own directory changed and its version did not.
display_nameWhat the console shows, and the subject of the “thinking” status a chat shows.
descriptionOne line. The console shows it; when a workflow is offered to another agent as a tool, it is what the calling model reads.
modelDefault gemini-flash-latest. Must be one of llm.models in elan.yaml, or the deploy is refused.
toolsMCP bindings only. A Python function is not one — see Tools below.
limitsWhat one invocation may use. See Limits below.
billingRequired. The SKU, the deposit, and what you charge your own users.
actionsWhat each tool does, keyed by tool name. See Declaring what a tool does.
default_tiert1 by default: a declared action waits for a person. t0 refuses actions, t2 lets them run.
unlisted_toolsaction by default, so t0 refuses a tool actions never names. read lets an unlisted tool through at t0.
pluginsYour own ADK plugins, as dotted paths. See the plugins guide.
compactionSummarize old turns instead of carrying them. Off unless set; see the compaction guide.

Unknown fields are refused, so a misspelled one fails the build rather than being ignored.

Bump version when you change the agent

The version is stamped on every event and charge as slug@version. Without a bump, two different builds are both invoice-triage@0.1.0 and every record under that name becomes ambiguous — including the ones you read while working out which build caused an incident. The rule applies to targets whose own directory changed; editing something they all share does not demand a bump from each.

Charging your users

billing says what you charge the people who use your agent. It is not what the platform charges you — that is pod CPU and memory, priced by a rate card you can read on the usage page and cannot set. The money model is in billing and the ledger; choosing the numbers is in charging your users.

FieldUnitMeaning
sku—What holds and charges are recorded against. Required unless unbilled=True.
deposit_microsmicro-USDWhat to reserve before each run. Required once any price is set; $0.012 is 12_000.
input_token_costUS cents per million tokensPrompt tokens, less any served from cache.
cached_input_token_costUS cents per million tokensPrompt tokens served from the provider's cache.
output_token_costUS cents per million tokensCompletion tokens, reasoning tokens included.
tool_costsmicro-USD per callWhat one call of a tool costs, keyed by tool name.
unbilled—True takes the target out of the ledger entirely: no hold, no balance check, nothing charged.

Token prices and the deposit

    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 before each run
    ),
  • Leave the prices out and your users are charged nothing — but the run still goes through the ledger. It reserves a platform default in tokens against your own balance and can be refused if that will not cover it. unbilled=True is how a target leaves the ledger entirely. Pod CPU and memory are billed to you either way.
  • The deposit is a deposit, not a charge and not a ceiling. It is reserved before the run, topped up in steps of the same size as the run spends, and settled at what was used. But it gates entry: a payer with less than it is refused up front, even for a run that would have cost a fraction. Smaller is kinder. A priced target with no deposit is refused at build time.
  • Cached tokens are a discount, not an extra charge. Providers count cached tokens inside the prompt count, so the ledger bills prompt − cached at the input rate and the cached part at the cached rate. A cached rate above the input rate charges more for a hit than a miss.
  • Reasoning tokens are output. They are billed at output_token_cost.
  • A price is stamped on each charge as applied, with the manifest version that declared it. Changing a price never rewrites what was already charged; a redeploy is how it takes effect.
  • Fractions are fine. input_token_cost=1.5 is $0.015 per million. Charges are floored to whole micro-USD, so a very short turn can legitimately cost nothing.

Charging for a tool call

Tokens are a poor proxy for what a tool costs you: one that calls a paid third-party 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,
        deposit_micros=12_000,
        tool_costs={
            "lookup_order": 200,          # $0.0002 every time it runs
        },
    ),

Micro-USD per call, not cents per million

The same unit as deposit_micros, and 10,000x away from the three token rates. 200 here is two ten-thousandths of a dollar; 200 as input_token_cost would be $2.00 per million tokens. Nothing but the field name tells them apart.

For a workflow called as a tool it is the only way to charge at all. A workflow tool runs without your plugins, so its tokens never reach the ledger — neither your charges nor your user's wallet. Pricing the workflow tool itself recovers it; the tools inside that graph are not separately billable.

A price that depends on the call

A price may be an expression over the call's own arguments instead of a number. The worked case is a refund fee of $0.30 plus 2.9%, capped at $5:

        tool_costs={
            "issue_refund": {
                "min": [
                    {"add": [
                        300_000,
                        {"divide": [
                            {"multiply": [{"var": "amount_micros"}, 290]},
                            10_000,
                        ]},
                    ]},
                    5_000_000,
                ]
            },
        },

For a $4.50 refund that is:

multiply   4500000 * 290  = 1_305_000_000
divide     // 10000       =       130_500     2.90%
add        + 300000       =       430_500     the $0.30
min        vs 5000000     =       430_500     under the cap
                                    ↑ charged $0.4305
OperatorMeaning
varAn argument of the call, by dotted path. It must already be in micro-USD — the platform cannot know whether your amount counts dollars or cents, and the two differ by 100x. Only a number resolves; anything else refuses the call.
addIntegers in, an integer out. Two or more operands.
subtractExactly two operands: the first less the second.
multiplyTwo or more operands.
divideExactly two operands, and it floors, so rounding always favors your user.
minThe smallest operand. A cap is a min; there is no separate field for one.
maxThe largest operand — a floor under the price.
if{"if": {"condition": {">": [a, b]}, "then": …, "else": …}}, for a tiered price. A condition is one comparison: >, >=, <, <=, == or !=.

Multiply before you divide. Amount × 290 ÷ 10,000 keeps the precision; dividing first floors it away. A whole price may not be negative, and an expression is limited to 8 levels deep and 64 parts. Naming an argument records its value with the charge, so a disputed one can be replayed — which is also why only a number can be priced: free text cannot be drawn into the ledger.

What is not a call

CaseCharged?
A tool your policy refusedNo — it never ran.
A tool waiting for approvalNot while it waits; once, when it runs.
A tool your own plugin short-circuitedNo — no work was done.
A model built-in such as google_searchPricing one is refused at build time: the model runs it, so nothing here ever sees the call.
A tool inside a workflow called as a toolNo — price the workflow tool instead.

A price whose name matches no tool charges nothing, for ever. The build cannot catch it — an MCP server names its own tools — so the pod logs a warning at startup naming the price and the tools the agent actually offers. A mistyped var is louder: every call to that tool is refused until the argument name matches, rather than undercharged.

One call can cost more than the deposit. The deposit gates entry and is topped up as the run spends, so an expensive call is not refused for being larger than the hold — but a payer who cannot cover the next priced call has that call refused. Cap with min if the exposure matters.

build()

from google.adk.agents import BaseAgent, LlmAgent


def build(ctx) -> BaseAgent:
    return LlmAgent(
        name="invoice_triage",
        model=ctx.model,
        description=ctx.manifest.description,
        instruction="...",
        tools=[...],
    )

ctx is deliberately narrow — your manifest and the resolved settings, and nothing global:

On ctxWhat it is
ctx.modelThe manifest's model — the value the deploy checked against llm.models. Use it rather than a literal so the two cannot disagree.
ctx.manifestYour MANIFEST, as validated.
ctx.slugctx.manifest.slug.
ctx.tools()Your manifest's MCP bindings, connected as ADK toolsets.
ctx.workflow_tool(slug)Another workflow in this project, as a tool this agent can call.
ctx.settingsThe runtime's resolved settings. Your own secrets are not among them; a tool reads those from the environment.

build() runs once per pod start, not per invocation, so it is where tools are constructed and clients opened; anything per request belongs in the tool. It is not run by elan validate or by the build, which only import the module to read MANIFEST — so a mistake inside it surfaces when the pod starts.

Tools

Three kinds, and only one of them goes in MANIFEST.tools.

A Python function

The ordinary case, constructed in build() and not declared in the manifest. The docstring is the tool description the model reads.

from google.adk.tools import FunctionTool, ToolContext


def check_balance(account: str, tool_context: ToolContext) -> dict:
    """Look up an account's balance."""
    return {"balance": 42}

# in build():
#     tools=[FunctionTool(check_balance)]

An MCP server

Declared, because the platform has to let it through your network policy, and connected in build() with ctx.tools():

from agentkit_core.manifest import ToolBinding

MANIFEST = AgentManifest(
    ...,
    tools=[ToolBinding(kind="mcp", ref="billing",
                       url="https://mcp.example.com/mcp",
                       tool_filter=["lookup_invoice"])],
)

def build(ctx):
    return LlmAgent(..., tools=[*ctx.tools(), FunctionTool(check_balance)])
  • tool_filter is required at deploy. Without it a binding offers every tool the server has, including whatever it adds after your review.
  • The URL's host must be in that target's egress in elan.yaml, or the deploy is refused. Network access is default-deny and enforced outside your process, so no library in your code can route around it.
  • The URL in the manifest is the default. An MCP_<REF>_URL variable in the pod's environment — MCP_BILLING_URL here — overrides it, and the egress allowlist still has to cover the host actually called.
  • kind="mcp" is the only kind that builds; the schema's other value is refused as not supported yet.

Another agent or workflow

A workflow in the same project becomes a tool with ctx.workflow_tool("guest-check-in"), which offers the model the workflow's declared inputs as typed parameters. ADK's own AgentTool cannot wrap a workflow; the details are in calling a workflow from an agent.

For a sub-agent you build yourself, wrap it as AgentTool(agent=sub, include_plugins=False). ADK's default re-runs your agent's plugins for the nested run, and the platform's are not re-entrant: the ledger opens a second hold that nothing ever settles.

A tool that needs a credential

Read it from os.environ, inside the tool. Nothing hands it to you: ctx carries your manifest and the runtime's settings, not your secrets.

import os


def issue_refund(charge_id: str, amount: int) -> dict:
    """Refund a charge."""
    key = os.environ["STRIPE_API_KEY"]
    ...

The variable exists because the target declares it under secrets in elan.yaml, and its value was set out of band with elan secret set secrets <name> — so it is in neither this file nor git. Read it in the tool rather than at import: a missing secret at import is a pod that crash-loops and has to be diagnosed from logs; in the tool it is an error attached to the call that needed it. The whole path is in secrets.

A tool that reads or writes a file

tool_context carries ADK's own artifact API. There is no platform helper to import; the platform supplies the store, the isolation and the bill.

from google.adk.tools import ToolContext
from google.genai import types


async def save_report(room: str, tool_context: ToolContext) -> dict:
    """File the report so the guest can be sent a link."""
    version = await tool_context.save_artifact(
        "report.md",
        types.Part.from_bytes(data=b"# Report\n", mime_type="text/markdown"),
    )
    return {"filename": "report.md", "version": version}


async def read_policy(tool_context: ToolContext) -> dict:
    """Read the standing policy every agent in this project shares."""
    part = await tool_context.load_artifact("project:policy.md")
    if part is None or part.inline_data is None:
        # Absent is not an error: ADK returns None for a file nobody filed.
        return {"status": "none_filed"}
    return {"policy": part.inline_data.data.decode()}

These methods are awaitable, and FunctionTool takes an async def without being told. A filename prefix chooses the scope — the four scopes are described in files and multi-modal:

PrefixScopeFrom a tool
nonethis conversationread and write
user:this end user, across their conversationsread and write
agent:this agent, all its usersread only — a tenant admin writes it
project:every agent in the projectread only — a tenant admin writes it
  • Writing a shared scope raises with a sentence saying so. IAM refuses it too; the check is the readable half of a real boundary.
  • Saving a filename again versions it — 0, then 1 — and load_artifact with no version takes the newest. list_artifacts() returns every scope with its prefix intact, so a name from it goes straight back in.
  • A tool cannot hand out a download link. Signing one needs a credential the pod deliberately does not hold. Return the filename and version; a client turns that into a URL.
  • A shared pod shares its files. Agents grouped into one pod run as one identity, so each can read and write the others' prefixes. Only an isolated runtime separates them.
  • Locally, the store is ADK's in-memory one, which does not know the agent: and project: scopes and refuses no write. Check shared-scope behavior on a deployed agent.

A file the user uploaded reaches the model as a file

You do not need a tool for this. When a user's message names a file they uploaded, the platform loads it and puts the real bytes in the model request — so a vision model reads the receipt, rather than a sentence a tool wrote about it. Nothing to enable.

user: What's the total on this?

      (attached file: receipt.jpg)
  • It works because the message names the file, as the name: receipt.jpg matches, “the receipt I just sent” does not. Upload it, then put the filename in the text.
  • Only the user's own files are attached — the conversation's and the end user's. Shared agent: and project: documents are not, because end users write the text that names a file and a tenant's documents should not be reachable by guessing a name in a chat box. Read those with load_artifact in a tool.
  • The bytes go up with every model call that sees the name — each step of a tool loop, and each later turn while the name is still in the conversation — charged as input tokens. At most 16 MiB of attachments go in one request; a file that does not fit is skipped and logged, never truncated.

Use a tool instead when the file is bigger than that, when it is a shared document, when the agent should read it unprompted, or when you want it read once rather than re-sent. The two paths coexist.

Declaring what a tool does

actions is per tool, and it is one statement that files an approval, tells t0 what to refuse, and caps how far a tenant may ever raise the tool. How tiers, rules and stored policies combine is in autonomy and action policy.

from agentkit_core.manifest import ActionPolicy, Rule

MANIFEST = AgentManifest(
    ...,
    actions={
        "issue_refund": ActionPolicy(
            labels=["finance", "money-out"],
            tier="t2",          # small refunds run unattended
            ceiling="t2",       # no stored policy may grant more than this
            rules=[
                # but a large one waits for a person
                Rule(when={"field": "amount", "op": ">", "value": 1000},
                     outcome="ask"),
            ],
        ),
    },
)
ActionPolicyMeaning
labelsCategories stamped on every approval this tool raises, so the console can filter by them. Lower-case [a-z0-9-], up to 32 characters; a typo fails the build.
tierThis tool's own default, overriding default_tier.
ceilingThe most autonomy any stored policy may grant this tool. Read from the manifest on every call, so nobody raises it by editing a policy.
rulesConditions on the call's own arguments, first match wins, tried before the tier. Each is a Rule with a when condition and an outcome of allow, ask or refuse.

A condition is exactly field, op and value. The field is a dotted path into the arguments the tool is about to receive, and op is one of >, >=, <, <=, ==, !=, in and contains — anything else fails when the manifest is built. A condition that cannot be judged, such as a missing field, falls back to asking.

Declare every tool that acts

A tool actions never names is ungoverned at t1 and t2: it runs without anybody being asked, MCP tools included. At t0 it is refused only because unlisted_tools defaults to action. A tool that books, charges or sends belongs in actions even if it never pauses.

Limits

LimitDefaultRangeWhat it bounds
max_concurrent41–1000Invocations in flight in one pod. One more waits two seconds for a slot, then is handed back to the queue and redelivered. The autoscaler's per-pod target is derived from it.
max_llm_calls20at least 1Model calls in one invocation. A caller's run_config may lower it for one run, never raise it.
timeout_s1201–3600How long one invocation is expected to take. The pod holds a delivered invocation for timeout_s plus 60 seconds before handing it back for redelivery; nothing else stops a run at timeout_s.
tool_deadline_s8640060–2592000How long a long-running tool call may go unanswered. Past it the call is abandoned and the run resumes with an error result, so the agent can say the system never answered.
approval_deadline_s60480060–2592000The same, for an approval somebody outside the conversation owes.

max_llm_calls is the one that bites: an agent looping over tool results hits it and stops mid-thought, and a generous value costs nothing until it is used. How many pods you get is not here — that is scaling in the elan.yaml reference. What bounds a long conversation rather than one invocation is context compaction.

Testing without model access

Register a fake model in ADK's LLMRegistry and select it with MODEL_OVERRIDE, so the agent resolves its model exactly as it would in a pod and nothing about the build is bypassed. MODEL_OVERRIDE is refused unless AGENTKIT_ENV=local, because outside local development it would run a model the deploy never checked.

# tests/conftest.py
import os

os.environ.setdefault("AGENTKIT_ENV", "local")
os.environ.setdefault("MODEL_OVERRIDE", "fake-model")

from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.adk.models.registry import LLMRegistry
from google.genai import types


class FakeLlm(BaseLlm):
    """Answers at once, with no network and no key."""

    model: str = "fake-model"

    @staticmethod
    def supported_models() -> list[str]:
        return [r"fake-.*"]

    async def generate_content_async(self, llm_request, stream=False):
        yield LlmResponse(content=types.Content(
            role="model", parts=[types.Part(text="Routed to accounts payable.")]))


LLMRegistry.register(FakeLlm)
# tests/test_invoice_triage.py
import asyncio
from pathlib import Path

from agentkit_runtime.builder import build_app
from agentkit_runtime.config import Settings
from agentkit_runtime.registry import resolve
from google.adk.runners import InMemoryRunner
from google.genai import types

ROOT = Path(__file__).resolve().parents[1]


def test_it_answers():
    settings = Settings(targets_root=ROOT)
    app = build_app(resolve("invoice-triage", settings), settings)
    runner = InMemoryRunner(app=app)

    async def turn() -> str:
        await runner.session_service.create_session(
            app_name=app.name, user_id="u1", session_id="s1")
        said = []
        async for event in runner.run_async(
            user_id="u1", session_id="s1",
            new_message=types.Content(role="user", parts=[types.Part(text="hi")]),
        ):
            if event.content and event.content.parts:
                said += [p.text for p in event.content.parts if p.text]
        return "".join(said)

    assert asyncio.run(turn()) == "Routed to accounts payable."

Patching the model on a built agent does not work. ADK wraps agents, so the object you patched is not the one that runs. This test runs your agent without the platform's plugins; to drive it with them — the autonomy gate, the typing status — use elan adk web, which builds the same stack a pod does.

Things that are true and surprising

  • ADK's default confirmation hint is written for a developer. require_confirmation=True produces “Please approve or reject the tool call…” and the approval card shows it verbatim. A tool a person will approve should call tool_context.request_confirmation(hint=...) itself — which also lets it validate first, so nothing impossible is ever put up for approval.
  • A tier decides an outcome; your tool carries out an ask. Setting t1 does not make a Python tool pause — the tool asks, by checking tool_context.tool_confirmation and requesting one. An MCP tool has no body here to ask from, so the platform asks on its behalf.
  • The body of a tool that asks runs twice — once to raise the confirmation, once to act on the answer. Anything that changes state before the tool_confirmation check happens whether or not anybody said yes.
  • The next message resolves every confirmation a turn raised. Answer one of two and the other is abandoned while the run reports finished; a plain message abandons them all. That is why a turn's answers travel as one batch.
  • ADK keeps no idempotency of its own. The platform refuses a second decision on one confirmation, but resumption is at-least-once, so a tool that books or charges should key on something of its own — the invocation id is stable across a resume.

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