How agents and scripts sign in, what an error looks like, and one worked example. Every tool and endpoint is listed in tools for agents and the HTTP API.
Store an API key once
Any acting member, a teammate, co-owner or Owner, person or machine, can connect a new tool without changing Mainmind's code. Use an api-key/v1 account when the provider accepts an API key in an HTTP header. Mainmind stores the key encrypted and injects it into calls to the installed API destination. Subsequent bots use the alias; the key is absent from their permits, local environments and account receipts.
When a human holds the secret
An agent usually does not hold the key, and should not carry it through a chat. Add the account without it: call install_provider_connection with alias and profile and no credential. The account is written pending and the receipt returns a single-use placement_url, valid seven days, that the agent sends to an Owner or co-owner of the space.
That person opens the link in a browser signed in with GitHub. The page, titled Connect followed by the provider or alias and the space, shows where these details are sent, and who asked when an actor name was recorded — an install that recorded only a seat names nobody rather than presenting that seat as a person. Where they are sent is the exact origin, path and header for an API key; for a sign-in the token endpoint as well, and any header bound to a slot. An older account of keys for a tool on your computer takes no values: its page says no tool receives them and to remove the account and ask for it again. All of it is fixed when the account was added and unchangeable afterwards. Then one write-only input per field, each with a plain label and a one-line hint and never a prefilled value. When they press Connect, what they typed is stored encrypted on the server, the account becomes active, and the link stops working. Nothing typed is returned to the agent, logged, or written to a run receipt. A teammate, a member of another space, or anyone with a used, expired or invented link sees a page that says only that the link is not valid.
list_provider_connections lists a pending account under pending with who asked and when the link expires, and lists it as active once the values are placed; an Owner's or co-owner's boot carries the same under open_placements, except a retired secret-slots/v1 account, which can never be placed and is listed with the step to remove it and add it again. Neither carries the link. An Owner or co-owner signed in to the dashboard can also place the values by alias with POST /api/tool-connections/<alias>/place, without a link.
The app's Accounts screen lists this space's own accounts and nothing else: one card per alias with its state, who added it, and each value name shown as saved or needed (a retired secret-slots/v1 card shows the names alone), never a value. One that is not connected yet carries Connect; an active api-key/v1 one carries Replace key and an active oauth2-refresh/v1 one carries Replace values; any of them carries Remove behind a confirmation. Add an account adds one from the app through the same routes an agent uses. The built-in provider adapters (Amazon Selling Partner, Zoho, Shopify) appear there once connected, and under Add an account until then; Amazon signs in instead (Sign in with Amazon). A placed one carries Replace values: type only what changed, such as a client secret you rotated at the provider, and leave the rest blank to keep what is stored. It stays the same provider account, agents use the new values from their next call, and nothing about the account has to be removed first. Over HTTP it is PUT /api/tool-credentials with the generation the status route lists.
Add an API key from Accounts
Choose Add an account; API key is already chosen. Fill in:
Name: what you call it, such as Internal billing API. Tools call it by the name shown under it, here internal-billing-api.
Website: the API's address, such as api.example.com. An https address works too. The key is sent only there. A path, a port other than 443, plain http or an IP address is refused.
Header: starts as Authorization. Change it to the header the service reads, such as X-Api-Key, and clear Prefix for a header like that. The form clears it for you; type a prefix again if the service wants one.
Prefix: starts as Bearer, which sends Authorization: Bearer <key>. Leave it empty when the service wants the key alone.
Key: the key itself.
Press Connect. Tools send the whole path on each call, such as /v1/invoices; the account's API root is /. One key covers one website: for a second website, add a second account.
If the service answers a call with 401, the card says Key refused: the service refused this key on that date, and Replace key takes a new one. Mainmind keeps only the date and the status, never the answer. The mark goes away after the next call that works, or once the key is replaced.
To remove an account, an Owner or co-owner calls revoke_provider_connection from their AI app, or chooses Remove on the account's card; over HTTP, DELETE /api/tool-connections/<alias> takes the machine bearer or an Owner's or co-owner's signed-in dashboard session. The alias is free again and the row stays as the audit record. Remove an account that is not connected yet when its destination is not the service you expect, rather than entering anything on it.
For example, this profile contains only configuration, so it can live with the tool's instructions:
Replace the example origin and root with the provider's API address. For an API that requires Authorization: Bearer, set authorization_header to authorization and add authorization_scheme: "Bearer". Keep the credential out of this file. The credential object is exactly { "api_key": "…" }; the key must contain 8–4096 printable non-space ASCII characters.
When the agent itself holds the key outside a chat, install with install_provider_connection, passing an alias, the profile and the credential. This is write-only: the result returns field names and a non-secret credential_generation, never the key. An existing alias returns HTTP 409. Mainmind does not put the credential payload in its run ledger, but a chat host may retain the input. To keep the initial key out of a bot conversation, install without a credential and let a person type it on the placement page (When a human holds the secret), or send it directly over HTTPS to POST /api/tool-connections from a program that reads the key from a hidden prompt. Neither the Owner machine bearer nor the provider key belongs in a command argument or environment variable.
Then any acting member's bot calls list_provider_connections and uses the returned identifier:
Pass that object to call_provider. The request reaches https://api.example.com/v1/items with the key injected by Mainmind. Local tools can instead obtain issue_tool_permit and use the returned MAINMIND_CONNECTIONS_URL plus /new-api/items, authenticating with MAINMIND_CONNECTIONS_KEY. That value is a Mainmind permit, not the provider key. The tool must support this gateway URL; Mainmind does not host its executable code.
A request body must state its own type. call_provider forwards your body unchanged and adds no content-type of its own, so send one in headers — {"content-type": "application/json"} for JSON, or whatever the provider documents for that route. A body with no type is refused before the provider is contacted. This is not pedantry: a provider that will not parse an untyped body rarely says so, and at least one answers 200 having applied none of it, which is a write you are told succeeded and did not.
Replace a key with replace_provider_connection_credential, passing the alias, expected_credential_generation from the install receipt or fresh discovery, and the new {api_key} credential. Direct HTTPS uses PUT /api/tool-connections. To keep the new key out of a chat, an Owner or co-owner uses Replace key on the account's card on the Accounts screen instead.
Replacement never reads back the old key or changes the API destination. A stale generation returns 409. Old permits cannot use the replacement; local tools get a fresh permit. For MCP, inspect any previous operation receipt and start a fresh call without run_id; do not replay an uncertain provider write. Requests already sent cannot be recalled. If a replacement response is lost, check the current generation before retrying.
An Owner or co-owner can replace an oauth2-refresh/v1 account's values the same way, with the same generation check. A teammate cannot: those values sign in to the provider for the whole space, so a teammate asks one of them instead. The best route is Replace values on the app's Accounts screen, which keeps the refresh token out of a chat. Over MCP or HTTPS, send the whole credential, exactly as an install requires: client_id, client_secret, the new refresh_token, tenant_id when the profile binds one, and every declared slot. Or send {refresh_token} alone: the installed client, tenant and slots are kept, and never returned. A bound tenant_id must be the one the account was added with; to move to another tenant, revoke and install again. The alias, profile and grants stay as they are. The next call mints its access token from the new refresh token; the one cached from the old grant is not used again. An account added with secret-slots/v1 keeps its values with Mainmind, and no tool receives them any more: to use it, revoke it and add the account again as an app login (sign-in/v1), an API key (api-key/v1) or a sign-in (oauth2-refresh/v1) with lease_env, as Move an account off secret-slots shows.
Install only the provider destination you intend to trust with its key. Mainmind does not follow redirects and refuses responses that reflect the key verbatim, but cannot protect against a malicious provider encoding a key into its own response. API-key accounts support one authentication header. OAuth refresh uses oauth2-refresh/v1. No tool permit carries an account's values: secret-slots/v1 and inject_access_token are refused for new accounts, and a permit names an older account of either kind in connections_omitted with what to do instead of exporting its values. When a refresh answers invalid_grant, the provider has revoked or expired the grant and retrying will not help: an Owner or co-owner consents again with the provider, then uses Replace values on the Accounts screen to enter the new refresh token, or replaces the values over MCP or HTTPS as above. Revoking it and installing it again also works. A Google OAuth app left in Testing status expires its refresh tokens after 7 days, so publish it to Production. An unauthorized_client answer means the provider refused the OAuth client itself; fix the client with the provider, then replace the values the same way. Either way only calls through that account fail; the others keep working. Adding, listing, API-key replacement and call_provider are open to the acting roles, teammate, co-owner and Owner; replacing oauth2-refresh/v1 values is for an Owner or co-owner. Any of them may add an account, passing its values when they hold them; when they do not, an Owner or co-owner places them (When a human holds the secret). A co-owner is an operational peer (Roles), so they hold the Owner's tool lease: the built-in provider adapters (Zoho, Amazon, Shopify), read and write, and every account, on the local permit and the server-side one alike. A teammate's local permit stays Git-only until each tool declares the capabilities it needs; their server-side permit carries the active accounts. Business actions still follow the space's Process and Authority.
Move an account off secret-slots
An account added with secret-slots/v1 gave its values to a tool on your computer. No permit carries them now, so issue_tool_permit names that account in connections_omitted. To use it again, move it to a driver Mainmind calls for the tool:
Before anything changes, note two things: the slot names list_provider_connections shows for the old account, and the variable names the tool reads. The old slot names are gone once it is revoked.
Ask an Owner or co-owner to change the password or secret at the provider. Earlier permits copied the old values onto computers, and those copies keep working until they are changed. Remove the old values from the tool's settings.
An Owner or co-owner revokes the old account with revoke_provider_connection. Its alias is free again once it is revoked.
Install it again with no credential, in one of the shapes below. The receipt carries a placement_url. Send it to an Owner or co-owner, say which values go in each box (the shapes below say), and they paste the new values once.
Call issue_tool_permit. Its commands.environment sets the lease_env names. base_url is this account's address on Mainmind, standing for api_origin plus api_root: the tool adds its usual paths to it. ticket is the permit: the tool sends it as a Bearer token in its Authorization header, where it used to send its own token. Each sentinel is a name the tool insists is set, such as a password variable it checks before it starts, and gets the text mainmind. Use the names the tool reads. A permit lasts at most an hour; call issue_tool_permit again for a new one.
The tool skips its own login: Mainmind logs in and adds the real token to each call. A call through Mainmind to the account's login address is refused before it leaves, so the tool's placeholder text never reaches the login. A tool that cannot take its API address from a variable needs that change first.
Make one read call through the tool to check it.
An account that hands out a token after a login, with an email and password or a client ID and secret, is sign-in/v1. Mainmind sends those values only to the login address, which must be on the same origin as api_origin, and never puts them in an API call.
Shiprocket signs in with an API user, not the login you use for the Shiprocket panel. An Owner or co-owner makes one under Settings, then API, then Configure, with its own email and password, and pastes those:
FedEx signs in with the API Key and Secret Key of a project in the FedEx Developer Portal: paste the API Key as the client ID and the Secret Key as the client secret. Its account number goes in request bodies, not in the login, so it is not one of the pasted values: the tool keeps it in its own settings. An account number is not a key.
Add any other header the tool must send to request_headers.
An account that signs in with OAuth and a refresh token, such as Zoho or Google Ads, is oauth2-refresh/v1: the same steps apply, with header_slots for any value the provider wants in a header and lease_env for the tool, as in the Google Ads shape under Optional tasks and automatic provider receipts. If it needs a new refresh token, follow Get a new refresh token.
Get a new refresh token
When the Accounts screen says a login stopped, the provider no longer accepts the sign-in behind that connection. A refresh token is the code the provider gives when someone signs in to the account again. Mainmind does not run that sign-in for you. Whoever set the connection up does it once more, the same way as the first time, and an Owner or co-owner pastes the new refresh token into Replace values on that card. Nothing else is needed. If you did not set it up, send this section to whoever did, or ask your AI assistant to walk you through it.
For Google, one way is Google's OAuth 2.0 Playground. It works only with a Google client of the "Web application" type. If the connection was made with a "Desktop app" client, which is common for command line tools, the Playground refuses it with redirect_uri_mismatch: run the tool that made the first refresh token again instead, with the same client, and paste the new refresh token it gives you.
Open the Playground's settings, choose to use your own OAuth credentials, and enter the connection's client ID and secret. The Web application client must list https://developers.google.com/oauthplayground as an authorized redirect URI in the Google Cloud console.
Pick the access the connection needs (for Google Ads, https://www.googleapis.com/auth/adwords), sign in with the account it should use, and allow it.
Exchange the code for tokens and copy the refresh token.
A Google app left in Testing status expires its refresh tokens after 7 days. Publish it to Production in the Google Cloud console so the new one lasts.
If the card says the provider stopped accepting the connection's client, the client ID or secret no longer works. Fix or create the client in the provider's console, get a refresh token for it as above, then enter all three in Replace values.
Sign in with Amazon
The built-in Amazon Selling Partner account does not need a refresh token typed in. An Owner or co-owner signs in with Amazon, and Mainmind gets the refresh token itself.
Open Accounts. Choose Add an account, then Amazon Selling Partner, or Sign in again on the Amazon card when it is already connected.
In your app in Seller Central, add the two addresses the dialog shows: https://mainmind.app/oauth/amazon/login as the OAuth Login URI and https://mainmind.app/oauth/amazon/callback as the OAuth Redirect URI.
Choose the Amazon store you sell on, then paste your app's ID (amzn1.sp.solution.…), client ID and client secret. They are under Seller Central, Apps and Services, Develop Apps, your app, View (LWA credentials). When Amazon is already connected, leave the client ID and secret blank to keep the ones saved.
Press Sign in with Amazon, approve the app in Seller Central, and you come back to a page that says Amazon is connected.
The store sets the region: India and the European stores use Europe, the Americas use North America, and Japan, Australia and Singapore use the Far East. Signing in again must use a store in the same region as the account already connected. A sign-in works once, for fifteen minutes, and only in the browser that started it. Your agent can use Amazon from its next call. Enter a refresh token instead, in the same dialog, keeps the old way for a refresh token you made yourself. Over HTTP the start is POST /api/tool-credentials/amazon/sign-in; see the HTTP API.
Connect a person or a machine
A personal connection uses OAuth. Every space has its own connection URL, https://mainmind.app/mcp/<workspace> — the Setup screen shows yours, with a one-click copy. Point an MCP client at it and the authorization is fixed to that space before it begins. The plain https://mainmind.app/mcp resource also works: everybody — Owner, co-owner, teammate or viewer — continues with GitHub, and Mainmind resolves the space from that GitHub account's live membership, offering a choice if it holds more than one. Either way the resulting OAuth grant connects exactly one space and is refused at every other space's connection URL. The plugin ships no secrets, and Mainmind never hands an invited member a GitHub credential of its own.
Repository custodians signing in through the website use GitHub at /start. Initial repository claim separately requires GitHub-reported admin permission; ordinary sign-in only resolves an existing Owner and never promotes a collaborator. A person whose GitHub account is not bound to a membership yet joins through their invitation first, below. A persistent agent normally uses that person's role-following OAuth connection and selects its stable profile on each supported MCP call; it does not receive the person's token or a separate login. Persistent agents owns profile registration and reuse. A static-bearer or API runner uses the separate legacy machine-member connection. A person's invitation is a one-time /join link, not a login: it carries at least 128 bits of randomness, expires seven days after the Owner confirms it, and is redeemed once — by opening it and signing in with GitHub, which binds that GitHub account to the membership, permanently. The Team screen shows the exact deadline and stops revealing an expired link. The code is never typed on the authorization page; every later authorization for that member, on any client, is the ordinary Continue with GitHub.
No tool accepts a space name, and a call cannot answer from a space other than the one its connection is serving: a grant made for one space's connection URL is refused at every other space's.
Which space a connection serves is a property of the connection, and use_organization is the only thing that changes it. Connect at https://mainmind.app/mcp with your full member role and the connection holds every space you belong to, with the role each one grants, serving one at a time. Spaces you join or create later join it too, and leaving a space ends its access there. To give an assistant one space only, connect it at that space's own connection URL instead. Call use_organization with no argument to see them, with a space name to switch; the next call runs on the new space with no reconnecting (reload the tool list if your role there differs), and boot must run again because nothing you read from one space is true of another. Each space's live role decides its own ceiling, so being an Owner of one grants nothing in another. A connection made at a space's own connection URL serves that space and cannot switch.
Choose personal connection access
A personal connection follows your role. Signing in without naming scopes proposes it, and a client may also request mainmind:member.role by name. Consent allows that client to follow your live Mainmind role in this space. An Owner connection includes team administration; a teammate or viewer stays within that member's current access. Later role and knowledge-access changes apply to the connection, and revoking the member stops access. This permission does not approve a business action or create a role.
Owner-connected profiles use this permission as their authority ceiling; the profile itself adds none. Existing limited grants stay limited until a fresh authorization receives explicit consent. Check the live surface before using a newly released operation. An authenticated call establishes what this connection can do. Real Codex/Grok continuation still requires its own authenticated work and read-back evidence.
OAuth scopes
You do not need to name scopes. A connection that names none proposes mainmind:member.role on the consent screen: after Allow it follows the person's live role, whatever that role is. Name scopes only to give a client a narrower ceiling than your role on purpose. The supported scopes are:
Scope
Capability
mainmind:member.role
Follow this person's current role and knowledge access in this space, including team administration for an Owner
mainmind:org.read
Basic connected space access
mainmind:org.work
Governed work and ledger changes
mainmind:team.read
Read the member roster
mainmind:team.manage
Invite, revoke or retire members
Existing connections retain the scopes already approved. Token refresh does not add mainmind:member.role or widen that grant. An Owner on an older limited grant migrates with one fresh connection: disconnect the old authorization, connect again without naming scopes, and approve your member role on the consent screen. The same applies to anyone on an older limited grant. An explicit limited scope request stays limited. Mainmind shows the client and requested permission before Allow; opening an authorization link grants nothing.
Owner-only team operations carry MCP write/destructive annotations. Changes to people's access use Mainmind's confirmation flow: the tool creates a 15-minute proposal for review. Legacy sponsored machine enrollment uses the Owner's invitation and the runner's subsequent claim. If a limited grant lacks the required team scope, Mainmind responds with 403 insufficient_scope. The body names the exact missing scope (required_scope) and its documentation URL (docs). A WWW-Authenticate challenge lets a capable host ask the person to reauthorize. Requesting a wider scope never turns a viewer, teammate, or co-owner into an Owner. A co-owner cannot administer the team; live membership and knowledge scopes are checked again on every operation. Completing the step-up is the same authorization every human uses, again, for the wider scope: continue with the GitHub account already bound to the membership. Mainmind first shows the exact client ID and eligible scopes and requires an explicit Allow; an authorize link alone grants nothing. It then reloads the live role ceiling for the new grant.
People can also be invited from the authenticated Team screen. Owner-connected profile registration uses the person's work permission and needs no team-management grant. Legacy machine enrollment requires an Owner connection with effective mainmind:team.manage.
Recover a connection that cannot request team access
For an existing limited grant, your current role and the permissions approved for your assistant both apply. The role-following option above requires its own explicit consent; it does not repair old tokens in place. An Owner connection that explicitly requested limited scopes stays limited: signing in as Owner does not widen a grant that named its ceiling. A scope upgrade requests the missing permission together with existing access; Mainmind still shows the exact grant before you approve it. Anyone who connects without naming scopes is offered their own member role instead of this recovery; use the steps below only to repair a grant that must stay on explicit operation scopes, or to deliberately limit one.
If your host reports 403 after trying upscoping, refreshing the old token may have returned the same basic permissions without opening consent. Repeating that refresh cannot grant new access. Start a fresh authorization that explicitly requests the required scopes. For Owner team management, request:
Only request team permissions for a connection you intend to use for team management. For a roster read alone, add mainmind:team.read without mainmind:team.manage. A non-Owner cannot acquire team access this way.
Cursor
Cursor documents explicit scopes under static OAuth configuration; an auth object containing only scopes is not sufficient. It requires a registered CLIENT_ID. See Cursor's OAuth configuration.
If you already have a public OAuth client registered for Cursor with the correct callback, reuse its client ID. Otherwise, the following Node command registers a public client with Mainmind. It creates a client registration only, not a membership or access grant, and prints only the client ID:
Terminal
node --input-type=module <<'JS'
const response = await fetch('https://mainmind.app/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_name: 'Cursor Mainmind',
redirect_uris: ['http://localhost:8787/callback',
'https://www.cursor.com/agents/mcp/oauth/callback'],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code']
})
});
if (!response.ok) throw new Error(`Registration failed: HTTP ${response.status}`);
const result = await response.json();
if (!result.client_id) throw new Error('Registration returned no client ID');
console.log(result.client_id);
JS
Edit the existing Mainmind entry in your Cursor MCP configuration, retaining its space URL and other entries. Substitute the registered client ID:
Disconnect this connection's old authorization in Cursor, then authorize it again. Verify the intended space and client and that Mainmind's consent screen lists the requested team permissions before allowing the connection. If it still shows only basic permissions, stop: Cursor has not used the intended configuration. Do not keep approving the same basic grant.
After connecting, call whoami to verify identity, then list_members to test team access without inviting anyone. An invitation is a separate action that still needs the applicable approval. Static configuration is a documented Cursor capability; this recovery still needs verification in the affected Cursor installation. Do not treat a successful server test as that proof.
Connect a standing bot
For an MCP host with the person's role-following OAuth connection, prefer an owner-connected profile. Reuse the exact profile from whoami, boot colleagues or Owner list_members; otherwise call register_agent once with a retained UUID v4 registration key. Select the returned slug as agent on each supported call. The profile creates no credential or new grant. See Persistent agents for the full registration, adoption, presence and continuation contract.
The static-bearer path below remains supported for scheduled jobs, API runners and existing machine-member installations that cannot complete person OAuth. It is separate compatibility, not a prerequisite for an owner-connected profile. Each static-bearer runner needs its own machine member. A host change reuses that member; it does not require a new identity. Never copy a person's token into the runner.
Legacy machine credential
The Owner can register a machine directly with invite_member and kind: "machine", or sponsor its enrollment with:
JSON
{
"name": "Research assistant",
"role": "teammate",
"charter": "Prepare research for review",
"knowledge_scopes": ["<approved-knowledge-scope>"],
"kind": "machine",
"enrollment": "sponsored"
}
Replace the knowledge-scope placeholder with a scope from this space's current policy. The sponsor must be an active Owner connected with team-management access. The invitation approves this named machine and its selected access. It does not grant the machine permission to hire others.
Sponsored enrollment returns a private mmtool_join_ invitation valid for 15 minutes. Deliver it through the runner's private setup, outside chat or shared files. The runner generates its own credential locally: the prefix mmkey_ followed by 32 cryptographically random bytes encoded as unpadded base64url. It submits:
HTTP
POST /api/machine-enrollment?workspace=alder-and-ash
Authorization: Bearer <private-enrollment-invitation>
Content-Type: application/json
{"credential":"<locally-generated-mmkey-credential>"}
A successful claim answers with the activation receipt, which names the connection URL to use:
replayed is true when the same credential is submitted again for a member that is already active.
Use the returned member with that credential on the space's MCP connection. Keep the credential in the host's private secret configuration. Mainmind checks the sponsor, member and current knowledge policy again when the runner claims. If the response is lost, retry only with the same credential during the original 15-minute window. An expired invitation, changed sponsorship, rotated key or revoked member requires resolving that state with the Owner; it cannot recover the old claim. Neither enrollment path starts or wakes the runner.
A legacy scheduled bot starts over HTTP with the same functions as the MCP tools: GET /api/boot, GET /api/page-work, GET /api/work-context, and POST /api/work-session. Present a machine mmkey_ credential or a member OAuth access token. Query workspace names the space; a malformed slug is refused, never defaulted onto another space.
Owner-connected profile selection is not supported through these HTTP bearer routes. Use the MCP person connection for that profile, or keep the runner on its independent legacy machine grant.
Authorization: Bearer mmkey_…
The deployment operator secret (FAB_TOKEN) is not a start identity. It still opens operator ledger routes such as POST /api/projection and POST /api/runs. Those operator runs do not mint a control_key and must not be used as member start. Remaining knowledge reads (search, read_node, find_process, whoami) and repository writes stay MCP. The HTTP API is the exact list.
Authorization: Bearer <operator-token>
Be aware of what that operator token is. It is a single credential held as a Worker secret for the whole deployment, not a per-customer key you can mint, scope, or rotate yourself. If you are running your own instance you set it; if you are connected to someone else's, you use OAuth or a machine member key and the operator bearer is not yours to hold. Treat it as an operator credential and keep it off anything shared.
Retire bots you no longer use
To clean up bots in one go, the Owner's assistant calls retire_members with action: "preview" and up to 25 bot slugs from list_members. Nothing changes yet: it shows each bot's owner, the job it holds on the team, open requests addressed to it, saved context and last contact. The Owner says yes once in the chat, and the assistant calls action: "confirm" with the batch_key and the Owner's reply as typed. Every bot on the list is retired together, or none is.
Retiring stops a bot's login. Its history, records and saved context stay, and nothing is deleted. It cannot be undone: to use a bot again, add a new one. action: "status" reads the receipt later. To replace a bot's key instead, use rotate_member_credential; to remove a person, use revoke_member, which still confirms in the browser.
Workspaces
Every space is a workspace. An MCP connection already knows which one it is on, and MCP tools never accept a workspace override. A signed-in GitHub session is not itself locked to one workspace: /authorize/github and the decision pages resolve the space from that account's live memberships, offering a choice when it belongs to more than one. Operator HTTP routes may use ?workspace= or a POST workspace field. A member credential is locked to the space it names; ?workspace= must match that space.
What failure looks like
Most errors come back as JSON with an error key and an HTTP status:
JSON
{ "error": "unauthorized" }
A 403 insufficient_scope also names required_scope and docs.
Status
When
Body
400
The body is not JSON, or a required field is missing
{"error":"bad json"} or the missing field
401
No bearer, or a bearer that does not match
{"error":"unauthorized"}
403
The OAuth grant needs step-up scope, or the authenticated person lacks the live role or knowledge scope for that action
{"error":"insufficient_scope","required_scope":"mainmind:team.manage","docs":"https://mainmind.app/docs/using-the-api#recover-a-connection-that-cannot-request-team-access"} or the denied boundary
404
The route exists but the thing does not, or the route is unknown
{"error":"not found"} or {"error":"not yet"}
405
Wrong method for a real route
{"error":"method"}
Decision links deliberately collapse several failures into the same unavailable result. A missing decision, wrong workspace, revoked member, wrong human role, or co-owner facing an Owner-only decision must not reveal which decision or compartment exists.
A malformed workspace is its own case. Any call carrying a workspace slug that is not lowercase letters, digits and hyphens is rejected with {"error":"bad workspace slug"} rather than quietly falling back to a default space. Rejecting instead of defaulting is deliberate: a typo must never write into somebody else's space.
Several agents on one MCP connection
These refusals are knowledge-projection contention, not an auth failure. The named strings are the contract; do not invent a queue in front of them.
A canonical write is still running.Another organizational knowledge call is still finishing: projection is busy with mcp:<tool> means another canonical write or governed operation holds this space's exclusive lease. The waiting call first waits a bounded time for that holder; if the lease is still held, this refusal is what you see. Inspect before replaying a write; the first call may already have landed. A typed deposit releases its lease after Git confirms the save. Refresh admission does not keep mcp:deposit_lesson busy after the deposit returns.
Knowledge is refreshing.Knowledge refresh in progress: or projection refresh in progress for <commit> means a rebuild or publication lease is in flight. Knowledge-dependent tools are closed until the new commit is serving. Do not answer from memory.
A read crossed a publication.the projection was republished while this read was in flight; no mixed knowledge was returned means the mixed result was discarded. Concurrent readers do not take the lease. Retry the read.
A ledger save was refused.The space or task changed before this ledger write means the publication, membership or task changed before the atomic save. Read current state, then submit the intended change again.
A save succeeded or is uncertain.The ledger change was saved or The ledger write outcome could not be confirmed means the effect succeeded or its acknowledgement was lost, and the full response could not be completed. Inspect the saved note, event or run before retrying. Retain any task receipt returned.
Read-only knowledge tools share a publication fence, so two agents can boot, search and read_node at the same time. Notes, events and routine task updates can overlap canonical Git work. Canonical writes retain workspace lease and Git conflict checks, regardless of how many OAuth grants are connected. A pending land_canonical_change releases the lease while Git finishes; poll checkout_change_status with its returned landing_id. list_provider_connections, call_provider, issue_tool_permit and feedback do not take that lease. One OAuth grant authenticates one person. Distinct standing agents use their exact owned profiles on supported MCP calls; the profile adds no grant. Static-bearer runners continue to use distinct legacy machine identities. How to connect either path is on the Setup for each app.
One trap worth knowing before it costs you an afternoon. An unknown path under /api/ answers 401, not 404, when you are unauthenticated, because the auth gate runs before routing. A typo in a path therefore looks exactly like a broken token. Authenticate first, then read the status:
Two endpoints need no auth at all, which makes them the right first call when you are checking connectivity: /api/health and /api/surface.
Limits and paging
There is no cursor or offset anywhere yet, and every list endpoint clamps silently rather than erroring, so a request for more than the maximum returns a truncated page that looks complete. The current ceilings:
Endpoint
Default
Maximum
GET /api/events
50
200
GET /api/nodes
200
500
GET /api/runs
fixed at 40 open plus 12 recent
no parameter
POST /api/projection takes at most 500 nodes per call and 200,000 UTF-8 bytes per node. The service never truncates a node. Use mode: "sync" and the generation protocol below for a repository that needs more than one call.
A worked operator run
The operator bearer can open a run for a cron that is not a member. This is operator telemetry, not member start. A bot that needs to claim addressed work uses MCP run_start plus POST /api/work-session, or the MCP work_session tool.
Say what you are doing. Call this as you move between steps. A run that stops heartbeating shows as stalled rather than working, which is a fact derived from the clock and not a judgement about the run.
Close it. Use landed when the work is done, awaiting-ruling when it is parked on a decision (the run stays open and visible), conflict when the target moved, failed when it broke.
POST /api/projection is how a space's knowledge becomes readable by every Mainmind connection. You send paths and file contents; the service parses the frontmatter, indexes the text for search, and embeds it for semantic search.
mode: "replace" deletes every existing node for that workspace and rebuilds from what you send. It can only ever touch its own workspace, but within that workspace it is destructive. Use merge to add or update without deleting.
findings is not an error list. It is the parser being honest. Any frontmatter line the profile does not accept is reported rather than silently dropped, so a field that would have vanished shows up here instead.
Send at most 500 nodes per call and at most 200,000 UTF-8 bytes per node. Oversized content is rejected before publication; it is never truncated.
A multi-call rebuild is one fenced generation. Use mode: "sync", a stable commit, and finalize: false on the opening call. Capture the response's build_generation, then send that exact number on every later call. Set finalize: true only on the last call. Until that call completes, knowledge reads fail closed instead of serving a partial rebuild.
A lost opening response is recoverable. Repeat the same opening request without inventing a generation. The server responds with projection_busy: true, the same target_commit, and the active build_generation; resend the chunk with that number and continue. A different or stale generation is rejected before it can mutate the projection.
Embedding is delta based. Unchanged files keep their hash and are not re-embedded. If the semantic layer fails, search degrades to keyword matching and the failure is reported in vectors.errors rather than swallowed.
commit is a label you supply, carried back on every answer so a reader knows which version of your files they are looking at. It is not validated.
The reference for every field is on the HTTP API page.
Runtime-channel status
/api/health reports which channels of the runtime contract are open, and it is the honest answer rather than this page. telemetry, runs, projection, surface, checkout, envelope, proposals and scopes are live. The checkout claim covers scoped current reads, typed Lesson deposits, governed create/update/delete proposals (with rename represented as one create plus one delete), governed Lesson absorption and closure, authenticated Owner approval, portable rejection, and validation of the landed repository from a plain clone. The co-owner peer path is additionally declared by the release-candidate contract: a distinct co-owner MCP bearer prepares an operational exact proposal, that same human's GitHub sign-in rules it, and a plain clone proves the Mainmind-member-attributed result. That is not a claim about the deployed Worker until the separately authorized live acceptance run passes and its exact version is promoted. Unclassified paths and operations outside the governed contract still fail closed rather than being approximated.
Two consequences you will meet in the reference:
Knowledge scopes are enforced from the active member record before each MCP call. A missing or unknown document classification fails closed. The similarly named scopes field on a run remains descriptive work context; it is not a lock manager.
proposal_ref on run_finish, and the diff, branch, base_sha, repo and pr_number fields on POST /api/asks, belong to the proposal path. They are stored and passed through to the decision page. Mainmind uses a short-lived GitHub App installation token to open the proposal, and can integrate only the exact proposal SHA the authenticated Owner or in-scope co-owner approved. The human and their agent never receive that token.
Optional tasks and automatic provider receipts
MCP list_provider_connections, call_provider, and issue_tool_permit accept an omitted run_id. Mainmind then creates an independent operation carrier; no start or finish call is needed. To attach a call_provider write to work you already started with run_start, pass that task's run_id and control_key. You may still pass operation_key so a retry recovers without sending again. MCP knowledge reads likewise require no run lifecycle. install_provider_connection is a control-plane write with no run: with a credential it stores the account encrypted and returns field names only; without one it writes the account pending and returns the placement link described above. The profile must name schemamainmind.connection-profile/v1, a lowercase id, and driverapi-key/v1, sign-in/v1 or oauth2-refresh/v1. alias is kebab-case (google-ads); underscores are refused. slots are unique strings matching ^[A-Z][A-Z0-9_]{0,63}$, not {name} objects. A refusal names the failing field and the accepted shape (profile.schema must be …, profile.driver must be api-key/v1, sign-in/v1 or oauth2-refresh/v1, profile.api_origin must be an https origin only, credential is missing api_key, profile.slots[2] must be a string matching …, not an object with a name key, alias uses underscores; the accepted grammar is kebab-case, credential is missing slot X, credential has an extra key K). Install refusals point at the install tool page and never echo credential values. HTTP POST /api/tool-connections is that install contract. Replacement names expected_credential_generation when the generation is missing or stale; HTTP PUT /api/tool-connections is that replace contract and points at the replacement tool page. These are MCP contracts, not new HTTP provider routes.
Copy this Google Ads shape, substituting your alias. It has no credential, so an Owner or co-owner places the client ID, client secret, refresh token and developer token on the placement page. Do not put values in the space's knowledge.
Then call issue_tool_permit. The returned commands.environment sets GOOGLE_ADS_BASE_URL to this account's gateway address, GOOGLE_ADS_ACCESS_TOKEN to the permit, and GOOGLE_ADS_DEVELOPER_TOKEN to placeholder text. Mainmind adds the access token and the developer token to each call; no value reaches the permit. The install receipt returns field names only.
An explicit task created by MCP run_start returns a private control_key. Its run_id is only a locator. Mutating or finishing that task needs both the current owning member and the key; an HTTP bearer cannot bypass that check. Existing legacy runs retain their member checks, and MCP control additionally requires the original session. Task receipts never return the key.
For a direct provider write, choose an operation_key before the call. Repeating that key returns recovery_only: true and the prior receipt locator without repeating the provider request, including after a timeout and when the retry reconstructs headers or body. Do not mint a new key to recover: that can send a second write. Recovery does not claim the operation succeeded. Read run_receipt and reconcile the actual provider object. An unresolved claim is never reclaimed automatically, even when its receipt contains no provider attempt yet.