App access control for agents
This reference extends https://app.tokay.io/llms.txt with app access operations for AI agents. Change access only when the user explicitly asks or a named readiness action requires it.
Last updated
AI agent? Start with llms.txt.
The access model
- A Project is one app and one login and sign out realm.
- Each WEB or FUNCTION Service directly stores
PUBLIC,PRIVATE, orINTERNAL. - Internal has no public address, no custom domain routing, and no sign in. Only the other Services in the Project reach it, at its
internalAddress. - Public bypasses Tokay sign in and forces every request to be anonymous.
- Private admits the union of same-project Groups, exact-email People, company domains, and project Machines in that Service's audience.
- Private with no audience members admits nobody. A deployed Service remains
UP_TO_DATEand reports the advisory actionCREATE_SERVICE_ACCESS_MEMBER. - The Project setting
newServiceAccessis copied into new routable Services. Changing it never changes existing Services. - Project and Workspace membership do not grant app access.
- Groups are reusable inside one Project. Each Group has a mutable name and an immutable slug used by app code.
- A Group may contain exact email People, company email domains, and Machines from the same Project. Public mailbox providers such as
gmail.comrequire an exact address. - Groups are reusable; direct Person, domain, and Machine members avoid throwaway Groups for one-off access. Groups do not contain Groups.
- Matching Groups define the Group slugs sent to that Service. Direct-only admission sends an empty Group list.
- There are no deny rules or inherited audiences.
- A Private WEB Service may add public routes, forced anonymous by HTTP method and exact path or trailing
/*prefix. - OAuth clients are Person delegations, not Group members. Independent automation uses a Machine identity.
Switching a Service to Public is nondestructive. Audience members, public routes, browser realm records, OAuth grants, Machine credentials, and Group membership stay stored. Returning to Private evaluates them against current authority.
What to call
| Intent | Platform API | Official Tokay MCP |
|---|---|---|
| Change the default for future Services | setProjectNewServiceAccess |
mutate_platform_graphql |
| Change one Service posture | setServiceAccess |
mutate_platform_graphql |
| Create a Group | createAccessGroup |
mutate_platform_graphql |
| Rename a Group | updateAccessGroup |
mutate_platform_graphql |
| Delete an unused Group | deleteAccessGroup |
mutate_platform_graphql |
| Create one Group member | createAccessGroupMember |
mutate_platform_graphql |
| Delete one Group member by member ID | deleteAccessGroupMember |
mutate_platform_graphql |
| Remove one Person's direct memberships from a Project | deleteAppPersonAccessMembers |
mutate_platform_graphql |
| Remove a departing Workspace member's direct app access everywhere | deleteWorkspaceMemberAppAccessMembers |
mutate_platform_graphql |
| Create one Service audience member | createServiceAccessMember |
mutate_platform_graphql |
| Delete one Service audience member by member ID | deleteServiceAccessMember |
mutate_platform_graphql |
| Add a WEB public route | createServicePublicRoute |
mutate_platform_graphql |
| Remove a WEB public route | deleteServicePublicRoute |
mutate_platform_graphql |
| Create a Machine | createMachine |
mutate_platform_graphql |
| See access history | Project.accessServiceEvents, Project.accessGroupEvents |
Query through the normal read surface |
App access is part of PROJECT_WRITE. setServiceAccess, in either direction, and createServicePublicRoute change who reaches a Service, so they confirm first: the first call changes nothing and fails with CONFIRMATION_REQUIRED, carrying the impact in extensions.confirmation. Repeat the unchanged call with confirmationToken added to the input to apply it. Membership changes, Groups, public route removal, creating a Machine, and the default for future Services are ordinary mutations. Issuing or revoking a Machine credential and deleting a Machine confirm like any credential change.
Protect a Service
mutation ProtectService($service: ID!) {
setServiceAccess(input: { service: $service, access: PRIVATE }) {
service {
id
access
deployReadiness { status action reason message }
}
}
}
This change takes effect through the access authority feed. It does not run a deployment. If the audience is empty, the Service safely denies everyone and reports UP_TO_DATE with CREATE_SERVICE_ACCESS_MEMBER.
Use PUBLIC to bypass Tokay sign in for the whole Service. Public requests never receive Tokay identity, even when the caller presents a recognizable browser, OAuth, or Machine credential.
Create and populate a Group
mutation CreateGroup($project: ID!) {
createAccessGroup(input: {
project: $project
name: "Finance"
slug: "finance"
}) {
accessGroup { id name slug memberCount }
}
}
The slug must begin with a lowercase letter and contain only lowercase letters, digits, and underscores. It is immutable. Rename only the display name with updateAccessGroup.
Add one member per mutation. Set exactly one of personEmail, emailDomain, or machine.
mutation CreateGroupMember($group: ID!) {
createAccessGroupMember(input: {
accessGroup: $group
emailDomain: "acme.com"
}) {
accessGroup { id memberCount }
}
}
Exact email addition may create the workspace scoped app Person internally. Public mailbox providers may be added only as exact emails, never as domain members. Partial Person search stays limited to active People already known through that Project. A Machine must belong to the same Project as the Group.
Populate the Service audience
Create exactly one Group, Person email, domain, or Machine member per mutation.
mutation CreateServiceMember($service: ID!, $group: ID!) {
createServiceAccessMember(input: {
service: $service
accessGroup: $group
}) {
service { id access }
}
}
Creation is idempotent. A Service admits an actor when the actor matches any audience member. It sends only matching Group slugs; direct-only admission sends [].
Each Service may include at most 32 Group slugs and 384 bytes of encoded slug JSON. Tokay enforces this complete claim envelope when creating a Group member and never truncates identity claims.
Read current rows through Service.accessMembers(searchText:). Service.accessMemberCandidates(searchText:) lists the verified Workspace email domains, same-project Groups, eligible People, and live Machines that can still be added, excluding rows already in the audience. Delete Group and Service members by the stable member Node ID from these connections. Other domains and previously unseen Person emails are direct create inputs. A Group cannot be deleted while any Service uses it.
Remove a Person from one Project
deleteAppPersonAccessMembers(input: { project, appPerson }) deletes that Person's direct membership rows from every Group and Service in the named Project and returns deletedMembershipCount. It does not change another Project and does not create a hidden block. If the Person still matches a Group or direct Service domain rule, that rule still admits them.
Removing a Workspace member does not remove their app access. Before deleteWorkspaceMember, read workspace.servicesAdmittingEmail(email:) and ask the user whether to also call deleteWorkspaceMemberAppAccessMembers(input: { workspaceMember }), which deletes their direct memberships in every Project of the Workspace.
Workspace wide suspension uses the app Person principal's suspendedAt authority state. It blocks the Person across that Workspace without deleting Group membership.
Add public routes to a WEB Service
mutation PublicRoute($service: ID!, $confirmationToken: String) {
createServicePublicRoute(input: {
service: $service
methods: ["POST"]
pathPattern: "/webhooks/stripe"
confirmationToken: $confirmationToken
}) {
servicePublicRoute { id methods pathPattern }
}
}
A public route widens access, so the first call changes nothing and fails with CONFIRMATION_REQUIRED. Show the user impact.warning, then repeat the call with confirmationToken. List a Service's routes with Service.publicRoutes { nodes { id methods pathPattern } } and remove one with deleteServicePublicRoute(input: { servicePublicRoute }), which does not confirm.
An exact rule matches one proxy visible path. A trailing /* matches descendants but not the unslashed root. GET also covers HEAD. /* is invalid. FUNCTION does not support public routes.
A matching public route is always anonymous. Tokay strips browser, OAuth, and Machine credentials before proxying. Your code must still verify provider signatures or its own application credentials.
Public routes may be added while a Service is Public. They stay stored, dormant, and take effect when the Service is Private.
Machines and OAuth
Creating a Machine emits no secret. Add it directly to each intended Service or through a reusable Group, then issue an independently named credential. Store its one time value and send it as Authorization: Bearer <token>.
Tokay Access OAuth acts for an admitted Person and stays bound to one Private Service and exact resource URI. It never grants admission by itself. Public makes authorization and refresh unavailable but preserves grants. Returning Private revalidates every grant against the current Service audience.
Identity and propagation
Private requests receive X-Tokay-Actor-Type, X-Tokay-Actor-Id, optional Person email and name, and complete JSON Group slugs in X-Tokay-Actor-Groups. Values are PERSON, MACHINE, or SERVICE. SERVICE marks a call from another Service in the same Project through its internalAddress: X-Tokay-Actor-Id is the calling Service's ID, Groups are [], and there is no email. It reaches Public and Private Services alike, and never needs sign in. Same origin browser code can read the same Person and Groups from GET /.tokay/access/me. On a Tokay Service address, the browser session works only on requests from the Service's own origin and on top level navigations. Browser code on another origin, including a sibling Service, cannot call a Private Service with it. Custom domains keep standard SameSite=Lax behavior. An app that wants the stricter rule there can reject requests whose Sec-Fetch-Site header is not same-origin or none.
Every public request reaches the app through one proxy, which states the visitor's address as the only X-Forwarded-For entry and as X-Real-Ip. Configure exactly one trusted proxy hop, for example Express app.set('trust proxy', 1) or Flask ProxyFix(x_for=1), and the framework reports the visitor rather than Tokay's gateway.
Active traffic refreshes access state promptly. The first request after idle attempts a refresh before deciding. During an Auth state feed outage, the standard plan may use last known authorization for at most 24 hours. Cold or older state fails closed. Verify a security change with a real request instead of waiting for worker reconciliation.
Common errors
| Result | Meaning | Response |
|---|---|---|
CREATE_SERVICE_ACCESS_MEMBER |
Deployment is complete but a Private Service admits nobody | Create a Group, Person, domain, or Machine member |
SERVICE_ACCESS_GROUP_CLAIM_BUDGET_EXCEEDED |
The Service Group-claim vocabulary is too large | Include fewer Groups or use shorter slugs |
ACCESS_GROUP_IN_USE |
A Service still references the Group | Delete its Service audience memberships before deleting it |
ACCESS_GROUP_MACHINE_PROJECT_MISMATCH |
The Machine and Group belong to different Projects | Use a Machine from the Group's Project |
401 invalid_token for OAuth |
Token expired, its grant SID changed, or the resource is wrong | Refresh when possible or authorize again |
| Prefix root remains protected | /x/* does not match /x |
Add a separate exact rule when both should be public |
PROJECT_WRITE_REQUIRED |
The Platform credential cannot change app access | Report the missing grant to the user |