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, or INTERNAL.
  • 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_DATE and reports the advisory action CREATE_SERVICE_ACCESS_MEMBER.
  • The Project setting newServiceAccess is 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.com require 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