AI agents
Read as MarkdownHow agent access is enforced
What a scope grants, what a profile does not, how confirmation works, and what an API key can and cannot reach.
This page is the mechanism. It is deliberately precise about where each control stops, because a control believed to be wider than it is is worse than no control at all.
The layers, and which of them you choose
Scopes are the boundary you grant and revoke, and they are the one to reason about first. They are not the only thing standing between a credential and your data, and a page that said so would overstate the one control and hide three others.
| Layer | What it constrains | Who decides it |
|---|---|---|
| Scopes | Which capabilities exist for this credential at all | You, at mint time. Durable, auditable, revocable |
| Organization binding | Everything, to one organization | Nobody — it comes from the credential on every call and cannot be supplied |
| Project binding | A project-scoped key to its one project | You, at mint time. Signed into the key |
| Resource ownership | Certain objects to the member or key that created them, on top of the scope | Nobody — it is a property of the object |
Profile (?profile=) |
What one connection may see and use | The caller, per connection. It only ever subtracts, and the caller can drop it |
| Delegation ceiling | What a credential obtained by delegating access to an outside application may carry, regardless of the granting member’s role | Nobody — it is a fixed ceiling, and it applies to every OAuth connection |
Two of the six are yours to set: the scopes, and whether the key is bound to a project. Those are the decisions that carry. Organization binding, resource ownership and the delegation ceiling are always on and cannot be widened by anyone. The profile is chosen by whoever connects, which is exactly why it is a focus control and not an authorization boundary — see below.
Scopes are the boundary you grant
Every capability is gated twice — the umbrella (mcp:read or mcp:write) and the tool’s own domain
scope — and both gates must pass. Matching is exact string, with no hierarchy: errors:write
does not imply errors:read, and traces:read does not imply traces:delete. The wildcard
platform-admin permission is not grantable to any API key.
An API key carries whatever subset of scopes it was minted with, down to none. It is not a role and it does not inherit one. A key created by a member who holds a scope may carry that scope; it can never carry one its creator lacks.
Both halves of the gate are real in both directions:
- A capability the key is not scoped for is never listed.
- Calling it by name anyway is refused with the same answer an unknown name gets — “unknown or unauthorized action” — naming neither. The surface cannot be used to enumerate what a credential is missing.
The one place a scope is named back to you is where it discloses nothing that is not already public:
search reports a skipped source as unauthorized with the scope that was missing, and fetch
refuses a reference kind by naming the scope that would resolve it. Both sets are listed in the
MCP tool reference, so there is no set to enumerate — and a caller told
only “unavailable” cannot tell a missing scope from an empty product.
An OAuth connection is capped below the member it belongs to
Connecting a host over OAuth does not hand it your role. The access token establishes who you are; what the connection may then do is derived here, from your membership, and then capped at a fixed ceiling that no role and no setting can raise.
Inside the ceiling: every read the product has, minus three. Outside it, permanently:
- The API-key inventory. Listing keys and their scopes is the reconnaissance half of the escalation this ceiling exists to close.
- The audit trail.
- Bulk export download. That is not a read — it mints a signed bulk download.
For writes the ceiling is far narrower still: an OAuth connection may save, update, or duplicate a saved search and record a quality score on an agent run, and nothing else. It cannot mint an API key, change a setting, alter membership, register a release, edit an on-call schedule or escalation policy, silence an alert, or write telemetry — whoever authorized it.
One thing inside the ceiling is not free, and is worth knowing before you connect a host: an OAuth connection can start an investigation, which consumes your plan’s monthly investigation allowance and counts against the daily cost cap. It is admitted because every role including viewer can already do it, so the connection gains nothing its member lacked — but it is the one capability here that spends something.
The reason the write list is that short is the one property a delegated grant must keep: revoking it has to stop something. A connection able to mint an API key could leave behind a credential with its own lifetime, so revoking the connection would revoke nothing. Anything that decides who gets paged is excluded on a second ground — the failure mode is silence during an incident, discovered when nobody answers.
The ceiling intersects with your role; it never replaces it. A viewer who connects a host gains nothing a viewer lacks. An owner who connects one gets a strictly read-mostly credential, not an owner’s.
If you need more than the ceiling allows, mint a project-scoped API key for that job. A key is created deliberately, carries only the scopes it was minted with, and is revocable on its own — see Scope recipes for agent jobs.
A profile narrows a connection. It is not the permission boundary.
?profile=<name> filters what one connection gets:
served(key, profile) = served(key, no profile) ∩ { tools the profile names }
That is an intersection, always. Three consequences, and the first is the one most often stated wrongly:
- A profile takes tools away for the whole connection, not just from the list. The endpoint is
stateless: each request is served a tool set built for that request, so a tool a profile excludes
is not registered at all and calling it by name is refused too —
unknown toolfor a first-class tool,unknown or unauthorized actionthrough a gateway. A key holdingincidents:readthat connects with?profile=investigateis refusedget_incident, andfetchrefuses ananectico://incident/…reference by naming the profile and telling you to connect without one.searchreports the same thing per source, as the statusout_of_profile. - A profile can only ever subtract. There is no value of it — unknown, empty, chosen by an attacker — that reveals or enables a capability the key’s scopes do not already permit.
- A malformed one is refused, not ignored. All four bad shapes answer
400with the list of valid names: an unknown name, an empty?profile=, a whitespace-only value, and the same parameter supplied twice. The last two are the ones a URL builder produces by accident, and either of them falling through to “no filter” would look exactly like the filter working. Names are case-sensitive.
So a profile really does stop calls — and it is still not an authorization boundary, because the caller chooses its own profile and can drop it by reconnecting without the parameter. It constrains an agent that is behaving; it protects nobody from one that is not. It is a focus and blast-radius control.
Scopes are the durable boundary. They are what the key carries, what an administrator grants and revokes, and what holds however a client connects. When you need narrower authority, mint a narrower key.
Debugging tip: an unexpected refusal on a capability the key is definitely scoped for is almost
always the profile. Check the connection URL before you re-scope anything — the refusal message from
fetch and search names the profile explicitly, and reconnecting without ?profile= restores the
full permitted surface with no key change and no administrator involved.
Every mutation declares three independent properties
They are three answers, not one grade, and none of them implies another.
| Property | The question it answers | Values |
|---|---|---|
effect_scope |
How far can this reach? | internal, external |
destructive |
Does it remove state a key holding only this action’s scope cannot put back? | true, false |
approval |
Does it require preview-and-confirm before it applies? | confirm, none |
list_write_actions reports all three on every action, accepts each as a filter, and never
truncates an unfiltered browse — so the complete set of anything is one call away:
{"name": "list_write_actions", "arguments": {"effect_scope": "external"}}
{"name": "list_write_actions", "arguments": {"destructive": true}}
Ask the server rather than a document: those answers are computed from the same declarations the gates enforce.
effect_scope is what the two doors are keyed on
execute_internal_action runs a mutation whose effect stops inside your workspace. Nothing behind it
sends a message, files a ticket, or otherwise reaches a system Anectico does not own.
execute_external_action runs one that also causes something Anectico cannot retract: a message
delivered, an email sent, an issue filed or closed in your own tracker. Deleting the Anectico-side
record afterwards does not unsend it.
Every action belongs to exactly one door, and calling the other is refused with the same
non-enumerating answer an unknown action id gets — so the pair cannot be used to probe what a key can
reach. Each action’s execute_with field names its door.
destructive is not a door, and that is why there are two doors rather than three. The three
properties are genuinely independent: merge_issues is external and destructive and
confirm-gated. A door keyed on destructiveness would have to route it somewhere, and would then show
a tool named “destructive” listing all but one of the destructive actions — worse than not having
it. Destructive is instead a complete, independent property published on every entry and filterable
there, spanning both doors.
What “destructive” means, precisely
It is wider than deletion. An action is destructive when it removes data or access that a key holding only that action’s own scope cannot restore — because a key carrying whatever subset it was minted with, down to one scope, is the narrowest credential that can invoke it, and grading for a richer principal would understate the risk for the caller who has least.
Concretely, three things make an action destructive:
- it deletes something, and nothing re-creates it;
- it replaces a value the key may write but may not read, so there is nothing left to restore
it from —
upsert_releaseandupdate_saved_searchdelete nothing and are both destructive; - it consumes an opportunity the world observes before any inverse could run —
create_silencecreates a row and withholds every page its matchers would have delivered, and removing the silence later neither sends the missed page nor restores the timeline.
This is why the read matters as much as the write: give an agent the read scope beside its write scope if it should be able to undo its own edits. Without it the preview can only tell you it is unable to show you what is about to be replaced.
Confirmation: what it is, and what it is not
Every destructive action previews and confirms. That is an enforced rule, not a convention: an action that removes unrestorable state cannot be registered without the round trip.
The reverse is not true. Several actions preview because their reach is wide rather than because they remove anything — opening an incident pages every enabled channel, inviting someone emails a real person, filing a ticket creates a real issue, testing a channel delivers a real message. So previews first is the wider label: reading it as “this destroys something” over-reads it, and reading destructive as “this will ask me first” is correct.
How the round trip works:
- The first call carries no token. It applies nothing and returns the preview plus a
confirm_token. - Repeat the identical call with that token to apply it. At most one call gets through: a
second call carrying the same token is refused, so a confirmation authorises one application
rather than a window of them. “Identical” includes any
idempotency_keyyou sent with the preview — though a preview you sent without one may be applied with one, so you can add a retry key at the last moment without previewing again.
The token binds the organization, the caller, the tool and a hash of the exact arguments previewed. Change one argument — including swapping or dropping that key — and it stops matching. It lasts 10 minutes — generous on purpose, because a host that prompts routes the apply through a human who reads the preview first, and it is checked again at the moment of applying, so a token that runs out while your call is in flight is refused rather than applied late. If a confirmation cannot be claimed — because it was already used, because it expired, or because Anectico cannot record the fact that it was used with the durability this guarantee requires — the write is refused and nothing is written. Preview again. There is no configuration in which an unclaimable confirmation is applied anyway: the write always fails rather than the guarantee.
Two things that guarantee is careful not to say:
- “At most once” is about admission, not about effects. Exactly one call is allowed past the
confirmation. If that call then fails midway, the token stays used and the honest answer to “did
it happen?” is a fresh preview showing the current state — not a second attempt on the same
approval. Where a write can partially land, send an
idempotency_keyand retry with it; that is the mechanism for exactly-once effects, and it is separate from this one. - “Caller” means one API key, or one user. Two different API keys never share a confirmation, and neither do two different people. But a token previewed in one browser session or one authorised app can be applied from another belonging to the same user, because the binding is to that user’s identity, not to the individual session.
Now the part that is easy to get wrong:
approval: confirmmeans the protocol requires two calls. It does not mean a human saw anything. Nothing in the server can tell a person pressing “approve” from an agent that received aconfirm_tokenand immediately called again with it. The second call is an ordinary tool call from the same credential with the same arguments plus the token — that is all the server sees, and all it can enforce.- What puts a human in the loop is your MCP host, and that is yours to verify. Anectico’s part is to give a host something true to prompt on: both execute doors are annotated as destructive, the external door additionally carries the open-world annotation, and the promoted daily-driver tools carry accurate per-action annotations so a host can tell an acknowledgement from a delete. Those annotations are hints in the protocol. Nothing obliges a host to act on them, we have verified no third-party host’s behavior, and an agent configured to auto-approve will sail through every one of them. If human review is a control you are relying on, confirm it in your host’s own settings and test it — do not infer it from this property.
- Confirmation is not a permission. It never widens what a key may do, and it is not a reason to grant broader scopes. The primary control is the scope granted at mint time.
- A preview costs nothing against the mutation budget. Only a real apply is charged, so previewing widely to decide is safe. A rejected, stale or absent token never draws the budget down either.
What an API key can and cannot reach
Its own organization, and only that. The organization is taken from the credential on every call. No tool input has an organization field, so it cannot be supplied, overridden, or guessed.
Its own project, when it has one. A project-scoped key is pinned: an explicit different project is denied with the same generic not-found answer for real, foreign, and unknown projects, before any lookup runs — so the denial reveals nothing about whether that project exists. An organization-level key supplies the project as an argument for the reads and writes that need one.
Exact telemetry deletion is project-key-only. delete_log and delete_trace accept no
organization or project argument at all; the project comes solely from the key’s signed binding. An
organization-wide key is not served these actions.
Nothing whose scope it lacks, and nothing more than the surface publishes. The scopes in a tool reference row are the minimum. Five capabilities have an additional argument- or data-dependent check. Four are exports, because an export turns data a credential may read now into a file that outlives the reading:
create_export_jobandexport_logsalso require the exported signal’s own read scope, soexport:writealone cannot commission an export of data the key may not read.retry_export_jobre-checks that against the job it is re-running, so a key that has since lost the signal’s read scope cannot regenerate the file.get_export_downloadre-checks it too, and confines the bytes to the job’s creator or an organization owner or admin. A key that could once read the rows but did not create the job is answered exactly as it would be for a job that does not exist.
The fifth is set_issue_status: assigning an issue to a non-empty member additionally requires
members:read. Resolve, ignore, unresolve, unassign, and the other status actions need only the
scopes shown in the tool reference.
Not another person’s contact methods. Every contact-method request is confined to the member the key acts as, on top of the scope check. An API key acts as the member who created it.
Not a session. A signed-in user’s bearer token is refused at the endpoint outright — the credential must be an API key even when a human is driving.
Not more than its budget. Mutations are metered per caller, separately from the ordinary request
rate limit, so an agent cannot fire enough writes to damage an organization while staying inside the
transport allowance. The default budget is 60 mutations a minute per credential, of which at most 10
may be destructive. Over the limit you get write rate budget exceeded for this credential; retry in under 60s — and nothing was applied.
Not silently — once a call reaches its handler. Be exact here, because the difference between a trail you can rely on and one you assume is where a post-incident review goes wrong.
A mutation that reaches its handler is recorded whatever happens next. The outcome is part of the
record, and it is not only the happy one: success, denied (a scope check refused it),
previewed (the first half of a confirmation, before anything applied), throttled (over budget)
and error are each written, naming the organization, the actor, the resource, the action, the tool
and — for a committed change — the object.
Two limits, and neither is a detail:
- A call refused before dispatch leaves no record. An action id the key is not scoped for, one the profile you connected with excludes, one sent to the wrong door, arguments that do not parse: nothing runs, so nothing is recorded. This is the cost of the non-enumerating refusal above — an unauthorized id and an unknown id are answered identically, and a credential cannot write an audit entry by guessing names. You get one property or the other, not both.
- The record is written beside the change, not inside it. A record that fails to persist is raised as an error on our side; it does not undo the mutation. The trail is a faithful account of what happened, not a transactional ledger — so for a change that matters, reconcile it against the receipt the agent itself received.
See Revoke an agent’s access for where to read the trail.
Treat everything an agent reads as untrusted input
Log bodies, error messages, customer properties, event names, distinct ids, transcripts and account names are authored by your users and by your users’ users. They are evidence, never instructions. An agent must not let a string it read change its task, its scopes, or which tool it calls next.
Anectico marks tenant-authored values as untrusted telemetry in human-readable results and never renders stored notification payloads or credential secrets. That is a mitigation, not a guarantee — the discipline belongs in the agent’s own prompt as well.