Agent recipes
A complete agent loop for buying archive imagery: discover candidate captures, estimate the price, pause for a human to confirm, place the order idempotently, then verify the result by reading its lineage. Each step is a real MCP tool whose name is its operation_id.
The shape of the loop
This recipe assumes a running gateway and an authenticated MCP client. For how the gateway projects operations into tools and forwards your token, see AI agents (MCP). Every tool here is the same governed operation an SDK or the REST API would call, with the same scopes, errors, and provenance — the agent path is not a lesser-governed shortcut.
A well-behaved agent never jumps straight from a natural-language request to a money-moving call. It walks five phases, and only one of them spends:
| Phase | Tool | Side-effect | Scope | Reversible? |
|---|---|---|---|---|
| Discover | catalog.search / catalog.federated_search | read | catalog:read | n/a |
| Estimate | orders.archive.estimate | read | orders:read | n/a |
| Confirm | (no tool — human gate) | — | — | — |
| Act | orders.archive.place | spend | orders:write | cancel only while cancellable |
| Verify | provenance.get | read | provenance:read | n/a |
The two read phases (discover, estimate) are safe to run autonomously — the gateway
annotates them with readOnlyHint, so an MCP client knows nothing is persisted and no
money moves. The act phase is an external_spend operation: it carries an idempotentHint, and a careful agent surfaces it for human confirmation before it
calls. See why this is safe by design.
1. Discover
Start with a read. catalog.search queries a single host’s catalog; catalog.federated_search fans the same query across every registered data source that
covers the area of interest and returns one merged, type-tagged FeatureCollection. Use
the federated tool when you don’t yet know which source has data.
The agent calls the tool by its operation_id. The arguments are the operation’s JSON
body — the gateway proxies them straight to POST /v1/op/catalog.federated_search:
{
"bbox": [151.1, -33.92, 151.3, -33.78],
"datetime": "2024-01-01T00:00:00Z/2024-06-30T23:59:59Z",
"query": { "cloudCoverage": { "LTE": 20 } },
"limit": 25
}The result is a FeatureCollection. Each feature is a capture you can order; note its id and geometry, and inspect sources to see which catalogs answered (a slow or
erroring upstream shows up there with an error instead of sinking the whole search):
{
"type": "FeatureCollection",
"numberReturned": 12,
"features": [
{ "type": "Feature", "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [ ... ] }, "properties": { "datetime": "2024-03-04T00:21:00Z", "eo:cloud_cover": 8 } }
],
"sources": [
{ "source_id": "the-host", "title": "The Host", "count": 12 }
]
}For a single named host, catalog.search takes the same filters plus host_name and
returns commercial results enriched with price. Both are reads, so an agent can iterate
on the query — tightening cloud cover, narrowing the date range — without any
side-effect. Catalog search caps the page size and paginates with a next token; see Pagination for the cursor semantics and Catalog discovery for the full filter set.
2. Estimate
orders.archive.estimate is a read that returns the exact, server-authoritative
price for a cart of captures. The client never decides the price — the backend computes
it over the area of interest. Nothing is persisted and no money moves, so the agent runs
it freely to show the human a number before asking to spend.
Build the cart from the captures the agent selected in step 1, passing each capture’s id and geometry from the search result:
{
"captures": [
{ "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [ ... ] } }
],
"splitByDate": false
}{
"groups": [{ "captures": ["scene-abc"], "aoi_km2": 12.4, "credits": 4200, "aud": 42.0 }],
"errors": [],
"total_aud": 42.0,
"total_credits": 4200
}total_credits is an integer credit count (100 credits = A$1) and total_aud is the
dollar equivalent. Check errors before going further: any capture that couldn’t be
priced (for example an unsupported product) lands there, and the agent should drop it from
the cart — or surface the problem — rather than place a partial order it didn’t intend.
3. Confirm — the human gate
There is no tool for this phase, and that is the point. The estimate is the moment the
agent hands control back to a person. A good agent presents the cart, the total_aud/total_credits, and the captures, and waits for an explicit yes.
Why a hard stop here:
- Estimate is reversible; place is not free.
orders.archive.placereserves credits or authorizes a card. The side-effect tier (external_spend) is surfaced to the MCP client precisely so it can require confirmation before a money-moving call. - The price the human approves is the price they pay. Because pricing is server-authoritative and the estimate and place run the same pricing, the number in the estimate is the number on the order — there is no drift between what the human approved and what the agent commits.
- Scope is a second guard, not the only one. Even with
orders:write, the confirmation gate keeps an autonomous loop from spending without a human in it.
Generate the Idempotency-Key for the next step now, at
confirmation time, and reuse it for every retry of this one order. Minting it before the
call — not inside a retry loop — is what makes a dropped connection safe.
4. Act — place the order
Once the human approves, place the order. orders.archive.place is an external_spend operation, so two things are true:
- it requires the
orders:writescope, and an API key cannot place archive orders — this is a deliberate guard on the money path, so the agent must be acting as a user or delegated principal (see Authentication); and - it honors an
Idempotency-Key, so retrying after a dropped connection never places two orders.
The gateway forwards request headers the caller supplies, so the agent sets the idempotency key alongside the JSON body. The body mirrors the estimate’s cart plus the owning project and license:
{
"projectId": "your-project-id",
"captures": [
{ "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [ ... ] } }
],
"licenseType": "standard",
"splitByDate": false
}The response echoes the final, server-computed price and the new order’s id and status:
{
"id": "f2b1c0de-...",
"status": "processing",
"orderType": "archive",
"billingMode": "credit",
"currency": "AUD",
"totalAud": 42.0,
"totalCredits": 4200,
"groups": [{ "...": "the priced groups, as ordered" }]
}If the order can’t be paid, the call comes back as 402 Payment Required — insufficient
credits, no/declined card, or a 3-D Secure challenge carrying a client_secret. These
are typed problem responses the agent can reason about. For 3-D
Secure, complete the bank challenge and retry with the same Idempotency-Key. The
full ordering reference, billing modes, tracking, and cancellation live in Ordering archive imagery.
5. Verify — read the lineage
After placing the order, close the loop with a read. When the order delivers, its imagery
lands in your project as STAC items, each carrying a provenance edge back to the order. provenance.get walks that graph so the agent can confirm what it actually produced
rather than assuming the place call “worked”:
{
"entity_type": "order",
"entity_id": "f2b1c0de-...",
"direction": "down",
"max_depth": 20
}{
"root": { "entity_type": "order", "entity_id": "f2b1c0de-..." },
"direction": "down",
"edges": [
{
"depth": 1,
"src_type": "order",
"src_id": "f2b1c0de-...",
"dst_type": "item",
"dst_id": "it_3a9c...",
"relation": "produced",
"invocation_id": "..."
}
],
"nodes": [
{ "entity_type": "order", "entity_id": "f2b1c0de-...", "depth": 0 },
{ "entity_type": "item", "entity_id": "it_3a9c...", "depth": 1 }
]
}direction—downlists descendants (“what came from this order?”);uplists ancestors;bothdoes both.max_depthbounds the walk (1–50).- Supported roots:
item,order,processing_job,collection,project. - Lineage is org-scoped — a root your organization doesn’t own returns
404, with no existence leak, exactly like every other read.
Because provenance is emitted by the kernel atomically with the work, the graph reflects what the operation actually did and can’t be forged by a client. That makes it the agent’s source of truth for verification. See Provenance & lineage for the full model.
End-to-end with the Python SDK
If you’d rather script the loop than drive it through an MCP client, the same five steps
run identically through the geopera package. Each operation is its own module under geopera.api.operations, called as sync_detailed(client=..., body=...). The human gate
is an explicit input(); the idempotency key is minted once, before the place call, and
reused on retry.
import uuid
from geopera import AuthenticatedClient
from geopera.api.operations import (
catalog_federated_search,
orders_archive_estimate,
orders_archive_place,
provenance_get,
)
from geopera.models import (
ArchiveEstimateInput,
FederatedSearchInput,
LineageGetInput,
OrdersArchivePlaceBodyType0,
)
# A user session token, not a gpra_ API key — placing an archive order is on the money path
# and requires a user or delegated principal (see step 4).
client = AuthenticatedClient(base_url="https://api.geopera.com", token="<your-jwt>")
# 1. Discover (read)
found = catalog_federated_search.sync(
client=client,
body=FederatedSearchInput.from_dict({
"bbox": [151.10, -33.92, 151.30, -33.78],
"datetime": "2024-01-01T00:00:00Z/2024-06-30T23:59:59Z",
"query": {"cloudCoverage": {"LTE": 20}},
"limit": 25,
}),
)
captures = [
{"id": f["id"], "geometry": f["geometry"]}
for f in found.features[:1] # the agent's selection
]
# 2. Estimate (read — no money moves)
estimate = orders_archive_estimate.sync(
client=client,
body=ArchiveEstimateInput.from_dict({
"captures": captures,
"splitByDate": False,
}),
)
if estimate.errors:
raise SystemExit(f"unpriceable captures: {estimate.errors}")
# 3. Confirm — the human gate
print(f"Order total: A${estimate.total_aud} ({estimate.total_credits} credits)")
if input("Place this order? [y/N] ").strip().lower() != "y":
raise SystemExit("cancelled by operator")
# Mint the idempotency key ONCE, here — reuse it verbatim on every retry of this order.
idem_key = str(uuid.uuid4())
# 4. Act — the only step that spends
order = orders_archive_place.sync(
client=client,
body=OrdersArchivePlaceBodyType0.from_dict({
"projectId": "your-project-id",
"captures": captures,
"licenseType": "standard",
"splitByDate": False,
}),
idempotency_key=idem_key,
)
print(f"Placed order {order.id} — status {order.status}")
# 5. Verify — read the lineage produced by the order
lineage = provenance_get.sync(
client=client,
body=LineageGetInput.from_dict({
"entity_type": "order",
"entity_id": order.id,
"direction": "down",
"max_depth": 20,
}),
)
print(f"Order produced {len(lineage.nodes) - 1} item(s)")The discover and verify reads are safe to run autonomously; the estimate is the data you show the human; and the place call sits behind the explicit gate. The Python SDK recipes cover idempotent retries, pagination, and error handling in more depth.
Recipe checklist
- Read before you spend. Discover and estimate are
readOnlyHint— iterate on them freely; reserve confirmation for the oneexternal_spendcall. - Trust the server price. Estimate and place run the same server-authoritative pricing, so the approved number is the committed number.
- One key per order. Mint the
Idempotency-Keyat confirmation time and reuse it on every retry, including after a4023-D Secure challenge. - Verify, don’t assume. Read provenance to confirm what the order produced instead of trusting that the call “worked”.
- The agent inherits the principal. It can do nothing the token’s scopes don’t already permit — and an API key can’t place an archive order at all.