<!-- Source: https://docs.geopera.com/api-reference/sdks/mcp/recipes · Markdown for LLMs -->

# 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)](/api-reference/sdks/mcp). Every tool here is the _same_ governed
operation an SDK or the REST API would call, with the same
[scopes](/api-reference/scopes), [errors](/api-reference/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](/api-reference/sdks/mcp#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](/api-reference/pagination) for the cursor semantics and
[Catalog discovery](/api-reference/guides/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`](/api-reference/idempotency) 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](/api-reference/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](/api-reference/errors) 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](/api-reference/guides/ordering).

## 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 }
	]
}
```

- **`direction`** — `down` 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](/api-reference/guides/provenance) 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](/api-reference/sdks/python/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`](/api-reference/idempotency) at
  confirmation time and reuse it on every retry, including after a `402` 3-D Secure
  challenge.
- **Verify, don't assume.** Read [provenance](/api-reference/guides/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](/api-reference/scopes) don't already permit — and an API key can't place an
  archive order at all.
