> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apostra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# v3 Concepts

> Accounts, tool discovery, nouns, verbs, Pages, and safety in the v3 agent contract.

## One connection, account-resolved tools

v2 chooses a persona through the URL. v3 connects to one URL and resolves its
catalog from the account kinds you can reach. A person who can administer an
organisation, sell from a Seller Account, and buy through multiple Buyer
accounts can move between them without registering multiple MCP servers, by
naming the account on each call.

A call that names its account is scoped to that call and neither reads nor
changes the connection's stored selection, which stays only as a fallback for
calls that omit `accountId` until removal. Begin with `get_status` with no
`accountId`: it lists the accounts you can reach. Then name the account you work
in with `accountId` on every call. See
[Name the account on every call](https://docs.apostra.com/v3/name-the-account.md).

## Name the account on every call

Every v3 tool takes `accountId`, the account the call runs in, and your tool
list marks it required (except on `save_account` and
`save_directed_campaign_subscription`, where it stays optional until the old
shape is removed). Send it on every call, reads included, with the
`accountId` from `get_status`. `get_status` is the one tool where it is
optional: with it, `get_status` reports that account; without it, it lists the
accounts you can reach. The call runs in that account
for that call only: nothing about the connection changes, so another
conversation on the same connection is never moved. This is the way to work in
more than one account at once.

Five inputs used `accountId` for something else before: member reads in `get`
(now `memberAccountId`), `save_account` (now `id`),
`save_directed_campaign_subscription` (now `providerAccountId`), the
`map_account` action of `buyer_sellers_page` (now `providerAccountId`) and
the connection account on the Media Partners Page (now `connectionAccountId`
on `open_media_partners_page`). On the first four,
`accountId` names the call's account only when you also send the new input.
Without it, `accountId` keeps its earlier meaning for now, except on a
`save_account` call that creates an account (it carries `accountType`), where
`accountId` always names the account the call runs in.

`open_media_partners_page` has no such fallback: its `accountId` always names
the account the call runs in. It takes the connection account to open as
`connectionAccountId`, from the connection's `accounts[].id`, and the seller to
open as `sellerAccountId`, the seller's Account id. A call that still sends
`sellerId` is refused with an error that names `sellerAccountId`. V3 no longer
has `open_connections_page`, the tool's earlier name: a call to it is refused as
an unknown tool. Use `open_media_partners_page`.

The server checks that you can work in the account you name: a membership in
it, or an administrator role on its parent organization. Naming an account never
grants access you do not already have. A Scope3 staff member who names an
account they are not a member of gets the platform support access that applies
to it and nothing more.

For now, a call that names no account still runs in the account the connection
last switched to, or in your credential's home account, and its result ends with
a line asking you to send `accountId`. `switch_account` is retired: it is in no
tool list, and it still runs from tool lists that hosts cached earlier, with a
result that says so. `expectedAccountId` is no longer listed either, and the
server still accepts it. The removal date will be announced in advance. Send
`accountId` instead.

Credentials fixed to one account (WorkOS user and organization API keys, machine
credentials, buyer agents) can name only that account. The v3 HTTP API names
its account with `accountId` in the request too; its `X-SCOPE3-CUSTOMER-ID`
header is deprecated, and if both are sent they must name the same account.

| Error | What it means | What to do |
| - | - | - |
| `ACCOUNT_NOT_REACHABLE` | You cannot work in the account `accountId` names. Nothing was read or written. | Call `get_status` to list the accounts you can reach, and ask the person which one they mean. |
| `ACCOUNT_MISMATCH` | `accountId` and `expectedAccountId` name different accounts, or on the v3 HTTP API `accountId` differs from the request's account. Nothing was read or written. | Send one account. On MCP, use `accountId` and leave `expectedAccountId` out. On the HTTP API, send `accountId` alone, without the header. |

## Writes that omit the account

An account switch used to move every conversation on a connection, not just the
one that asked. That is why a write has to say which account it is for. A write
names it with `accountId`, which never grants access and never changes any
selection.

While the transition runs, a write that omits `accountId` is still checked. If
your connection can be switched to another account, the write fails with
`ACCOUNT_CONFIRMATION_REQUIRED`. That covers people signed in to a connector and
personal (legacy) Scope3 API keys. WorkOS user and organization API keys and
machine credentials are fixed to one account, and so is the v3 HTTP API, so no
switch can move them and an omitted `accountId` runs in their account. The
server enforces this whatever tool list your client has cached.

`expectedAccountId` is no longer listed. The server still accepts it, and it
must agree with `accountId` when both are sent.

| Error | What it means | What to do |
| - | - | - |
| `ACCOUNT_CONFIRMATION_REQUIRED` | The write left out `accountId`. Nothing was written. | Name the account the conversation already established, from the person or from your own earlier `get_status`, with `accountId`. If it has none, call `get_status` with no `accountId`, ask the person which account they mean, then resend with that `accountId`. |
| `ACCOUNT_MISMATCH` | `accountId` and `expectedAccountId` name different accounts, or the connection is in a different account from the one a write that used only `expectedAccountId` named. Nothing was written. | Send `accountId` alone. If the person did not choose the account, ask them before you resend. |

`ACCOUNT_CONFIRMATION_REQUIRED` names the company of the account the connection
is in, and gives no `accountId`, so take it from what the conversation
established or from `get_status`, and confirm it with the person before you
resend.

## Orientation is always available

`get_status` returns:

* the account you named and its account kind, or, when you name none, the
  accounts you can reach;
* readiness and transaction state when available;
* blockers and typed next actions;
* ready buyer destinations where applicable; and
* a bounded list of reachable accounts that says when it is truncated.

`get_status` remains available in an unclassified account. This is a
navigation contract, not access to buyer or seller account data.

A Developer account (account kind `developer`) is an account that owns agents.
It has no buyer or seller nouns in v3, so it lists only `get_status`, and
`get_status` describes it with the unclassified
orientation state while reporting `kind: developer`. Developer accounts are in
a closed preview.

## Nouns and verbs

v3 uses a small verb vocabulary over account-owned nouns:

| Verb | Contract |
| - | - |
| `search` | List or search one supported `kind`, with kind-specific filters and pagination |
| `get` | Read one object by ID, or a singleton kind without an ID, with optional `include` detail |
| `save_<noun>` | Create or update one noun through a noun-specific schema |
| Domain action | Perform work that is not a state declaration, such as `request_proposals` |
| `open_page` | Hand interactive, sensitive, or human-authorized work to an MCP App Page |

Reads can share a safe envelope. Writes cannot: each `save_<noun>` has its own
schema so a model does not choose an operation name and then guess the body.

## Tool discovery is part of the contract

The catalog may differ by:

* account kind;
* permissions;
* feature rollout; and
* MCP App support in the host.

Treat `tools/list` as the source of truth for names and JSON Schemas for the
account kinds you can reach. If your principal can reach both a Buyer Account and a Seller
Account, it lists the deduplicated union of both catalogs at connection time.
Those tools are still authorized only in the account a call names: `get_status`
lists the accounts you can reach and the kinds represented in the listed
catalog.
The [tool catalog](https://docs.apostra.com/v3/tool-reference.md) explains the stable families
and intended workflows; it does not replace runtime discovery.

A call from a cached tool list that still runs `switch_account` makes v3 emit
`notifications/tools/list_changed`. Hosts can cache their initial tool list, so
they must not rely on that notification: a dual-access principal already sees
both account kinds, and a wrong-account call returns a typed error that names
the account the call ran in, the required account kind, and the accounts you can
reach that can run the tool. It tells you to name one of them with `accountId`.
Confirm with the person which account they mean.

## Tasks, Pages, and model visibility

Some work should not be represented as model-authored fields. OAuth grants,
credentials, payout destinations, legal acceptance, and certain review actions
belong in a human-facing Page.

* A model-facing tool may return a Page as the next action.
* `open_page` opens a named Page with bounded focus parameters.
* Resource-bound MCP App tools may be present on the wire but hidden from model
  context by their visibility metadata.
* A Page stays in the account it opened in. Its calls name that account with
  `accountId`, so they keep running there whatever another conversation does. In the Apostra web app, a Page whose browser tab has since
  moved to another account is refused with `ACCOUNT_MISMATCH`, and a Page in
  an account you can no longer reach is refused with `ACCOUNT_NOT_REACHABLE`.
  Nothing is read or written either way; reopen the Page in the account you
  want.

Do not bypass a Page by placing credentials, bank details, or authorization
decisions in another tool's free text.

## Declarative writes

A `save_<noun>` call states the desired noun state. Responses distinguish:

* `created` or `updated` — a write occurred;
* `unchanged` — the current state already matched;
* `pending_approval` — a governed or seller-applied change was staged, not
  committed; the object keeps its live state, and a media buy's
  `pendingChange` describes the waiting change;
* a `*_requested` action (`cancellation_requested`, `pause_requested`,
  `resume_requested`) — the seller accepted the change and is still applying
  it, so the object keeps its live state until the seller confirms; and
* an error or partial-write result — the response explains what did and did not
  persist.

Nothing is reported as done before it is.

Send patches, not reconstructed whole objects, unless a schema explicitly asks
for replacement. Preserve returned identifiers exactly. Qualified product and
proposal IDs encode seller routing and must not be shortened or rebuilt.

## Concurrency and idempotency

These mechanisms answer different questions:

* `expectedRevision` prevents overwriting a newer object state. Re-read after a
  `REVISION_CONFLICT` and decide whether the intended change still applies.
* `idempotencyKey` deduplicates one logical attempt where the tool promises it.
  Reusing a key for different intent is an error; inventing a new key for every
  retry can duplicate work.
* `correlationId`, where offered, is a tracing handle and is not necessarily an
  idempotency key.

Use the exact semantics in each tool's runtime schema and workflow guide.

## Includes keep diagnostics attached to the object

Health, sync history, and capability evidence are facts about a resource, so
they are generally requested through `get.include`:

```json theme={null}
{
  "kind": "inventory_source",
  "id": "SOURCE_ID",
  "include": ["diagnostics"]
}
```

Successful facts remain available when another requested include cannot be
resolved. A response marks unsupported or partial detail instead of presenting
it as an empty result.

## Pagination and completeness

Pass the returned opaque `nextCursor` back to the same query while `hasMore` is
true. Do not edit, reuse across kinds, or interpret a cursor. A partial response
must say what was not reached; an empty `items` array alone is not evidence that
the underlying system contained no results.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.