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:

PhaseToolSide-effectScopeReversible?
Discovercatalog.search / catalog.federated_searchreadcatalog:readn/a
Estimateorders.archive.estimatereadorders:readn/a
Confirm(no tool — human gate)
Actorders.archive.placespendorders:writecancel only while cancellable
Verifyprovenance.getreadprovenance:readn/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:

json
{
	"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):

json
{
  "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:

json
{
  "captures": [
    { "id": "scene-abc", "geometry": { "type": "Polygon", "coordinates": [ ... ] } }
  ],
  "splitByDate": false
}
json
{
	"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.place reserves 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:write scope, 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:

json
{
  "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:

json
{
	"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”:

json
{
	"entity_type": "order",
	"entity_id": "f2b1c0de-...",
	"direction": "down",
	"max_depth": 20
}
json
{
	"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 }
	]
}
  • directiondown lists descendants (“what came from this order?”); up lists ancestors; both does both. max_depth bounds 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.

python
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 one external_spend call.
  • 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-Key at confirmation time and reuse it on every retry, including after a 402 3-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.