Get started

Introduction

AgentFGA is a fine-grained authorization server for AI agents and agentic products. It decides what each agent, and each user it acts for, can see and do, down to a single document, tool or record.

You describe your rules once, as an authorization model. You store relationships as they change, such as “the support bot may use the refund tool”. Then your agent asks AgentFGA before it acts. AgentFGA speaks the OpenFGA HTTP API, so the official OpenFGA SDKs, the fga CLI and the modeling tools work against it unchanged.

Two ways to run it

What you get

  • Check every action. Check and BatchCheck answer whether an agent may use a tool or touch a record, fast enough to ask before every call.
  • Filter what the model sees. ListObjects and BatchCheck keep retrieval to what the user may read.
  • Access that expires. Conditions limit a grant by time, IP range or any other request detail. Contextual tuples grant access for a single request.
  • One store per customer. Each customer's model and relationships live apart from everyone else's, and capacity grows as you add stores.
  • Compatible with OpenFGA. Every response, including error codes and messages, was compared with OpenFGA v1.22.0 across more than 1.7 million side-by-side requests.

Next steps

Get started

Quickstart

Run AgentFGA on your machine, give an agent permission to use a tool, and check it.

You need Node.js 22.13 or later and a copy of the AgentFGA repository. The examples use the fga CLI and the OpenFGA JavaScript SDK; any OpenFGA SDK works.

  1. Start AgentFGA

    From the repository, install dependencies, choose an API key and start the local server. The key in .dev.vars is the token your agents and apps will send.

    shell
    npm install
    echo "AGENTFGA_AUTHN_PRESHARED_KEYS=dev-key-123" > .dev.vars
    npm run dev                      # http://127.0.0.1:8787
  2. Create a store

    A store holds one customer's model and relationships. Point your tools at the server, create a store, and keep its id in FGA_STORE_ID.

    export FGA_API_URL=http://127.0.0.1:8787
    export FGA_API_TOKEN=dev-key-123
    
    fga store create --name acme
    export FGA_STORE_ID=01JBZ8M2X3K4Q5R6S7T8V9W0XY   # the id it printed
  3. Write your rules

    Save this model as model.fga. Agents can use tools they are granted directly or through a team, and can view a document when someone grants them temporary access.

    model.fga
    model
      schema 1.1
    
    type user
    
    type agent
    
    type team
      relations
        define member: [user, agent]
    
    type tool
      relations
        define can_use: [agent, team#member]
    
    type document
      relations
        define owner: [user]
        define temp: [agent with not_expired]
        define viewer: [user, team#member] or owner or temp
    
    condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
      current_time < grant_time + grant_duration
    }
    fga model write --file model.fga
  4. Grant access

    Store who is related to what. Here the support bot may use the refund tool, and Anne owns the Q3 plan.

    fga tuple write agent:support-bot can_use tool:issue-refund
    fga tuple write user:anne owner document:q3-plan
  5. Check before acting

    Before the agent calls the tool, ask whether it may.

    fga query check agent:support-bot can_use tool:issue-refund
    Response 200
    { "allowed": true, "resolution": "" }

Next steps

Get started

Authorization for agents

An agent acts for a user, calls many tools and reads a lot of data, faster than anyone can review. These patterns keep each agent to what it, and the person it works for, may do.

The examples use the model from the Quickstart and an OpenFGA SDK client called fga.

Give each agent an identity

Model agents as their own type, such as agent:support-bot, instead of letting them borrow a user's identity. Grants then say exactly what each agent may do, and they show up separately in Read and ReadChanges. For short-lived agents, give each run its own id, such as agent:run-7f3, and grant access through a team or with contextual tuples. For agents your customers create and manage themselves, with managers, an off switch and grants that expire, see Agent service accounts.

Check before every tool call

Put one check in front of the code that runs tools, so no tool runs without a yes. A check takes about 6 ms on the server, so it can sit on every call.

tools.ts
async function runTool(agentId: string, tool: Tool, input: unknown) {
  const { allowed } = await fga.check({
    user: `agent:${agentId}`,
    relation: "can_use",
    object: `tool:${tool.name}`,
  });
  if (!allowed) throw new Error(`agent:${agentId} may not use ${tool.name}`);
  return tool.run(input);
}

Filter what the model sees

Before retrieved content reaches the model, keep only what the user may read. There are two ways to do it.

  • Filter first, with ListObjects, when a user can see a modest number of documents. Ask for every document the user may view, then search only those. ListObjects returns at most 1,000 results within 3 seconds by default.
  • Search first, then check, with BatchCheck, when the corpus is large. Retrieve candidates as usual, then check them all in one request. The SDK splits the list into batches of 50.
const hits = await vectorStore.search(query, { limit: 20 });

const { result } = await fga.batchCheck({
  checks: hits.map((hit, i) => ({
    user: "user:anne",
    relation: "viewer",
    object: `document:${hit.documentId}`,
    correlationId: String(i),
  })),
});

const allowed = new Set(result.filter((r) => r.allowed).map((r) => r.correlationId));
const context = hits.filter((_, i) => allowed.has(String(i)));

Act for a user

When an agent works for someone, the action should pass two tests: the agent may use the tool, and the user may touch the thing. Ask both in one BatchCheck and proceed only if both are allowed. Agent service accounts extends this to report which side refused.

TypeScript
const { result } = await fga.batchCheck({
  checks: [
    { user: "agent:support-bot", relation: "can_use", object: "tool:read-document", correlationId: "agent" },
    { user: "user:anne", relation: "viewer", object: "document:q3-plan", correlationId: "user" },
  ],
});
const allowed = result.every((r) => r.allowed);

Access that expires

The temp relation in the model only accepts grants with the not_expired condition. Store when the grant starts and how long it lasts with the tuple, then send the current time with each check. Once the hour is up, the same check answers false, with nothing to clean up.

TypeScript
// Grant the support bot one hour with the Q3 plan.
await fga.write({
  writes: [{
    user: "agent:support-bot",
    relation: "temp",
    object: "document:q3-plan",
    condition: {
      name: "not_expired",
      context: { grant_time: new Date().toISOString(), grant_duration: "1h" },
    },
  }],
});

// Later, pass the current time with the check.
const { allowed } = await fga.check({
  user: "agent:support-bot",
  relation: "viewer",
  object: "document:q3-plan",
  context: { current_time: new Date().toISOString() },
});

Grants for a single run

Contextual tuples are relationships you send with a query instead of storing. They count for that one request and are then gone, which suits access tied to one agent run or session. Here a run joins the support team for one check, and so may use the tools the team may use.

TypeScript
// Stored once: members of team:support may issue refunds.
// team:support#member  can_use  tool:issue-refund

const { allowed } = await fga.check({
  user: "agent:run-7f3",
  relation: "can_use",
  object: "tool:issue-refund",
  contextualTuples: [{ user: "agent:run-7f3", relation: "member", object: "team:support" }],
});

One store per customer

If you build an agentic product for many customers, create a store for each. Their models and relationships stay apart, a mistake in one customer's grants can't leak into another's, and on Cloudflare each store runs on its own, so capacity grows as you add customers. See Stores and scaling.

Examples

Custom roles

Your app fixes the permissions. Each organization builds its own roles from them, such as “Billing manager”, and gives them to people, agents and teams. A role is tuples, so creating or changing one never needs a new model.

The complete example is in examples/custom-roles in the AgentFGA repository: the model, tests for it, the TypeScript an app calls, and a demo that runs against a server. It uses only the OpenFGA API, so it runs on OpenFGA too. See Run it.

The model

Each permission is a relation on the organization that accepts the members of a role. Owners hold every permission, so no organization can lock itself out.

model.fga
model
  schema 1.1

type user

type agent

type team
  relations
    define member: [user, agent]

type role
  relations
    define organization: [organization]
    define assignee: [user, agent, team#member, role#assignee]
    define member: assignee and member from organization

type organization
  relations
    define owner: [user]
    define member: [user, agent] or owner
    define can_manage_roles: [role#member] or owner
    define can_manage_members: [role#member] or owner
    define can_manage_billing: [role#member] or owner
    define can_view_billing: [role#member] or can_manage_billing
    define can_create_projects: [role#member] or owner
    define can_delete_projects: [role#member] or owner
    define can_view_audit_log: [role#member] or owner
    define can_export_data: [role#member] or owner

type project
  relations
    define organization: [organization]
    define creator: [user, agent]
    define can_delete: creator or can_delete_projects from organization
  • A role is an object. Its assignees are users, agents, teams, or the assignees of another role, so one role can include another.
  • Roles end when people leave. A role's member is its assignees who still belong to the organization, and permissions go to role:<id>#member. Remove someone from the organization and every role they held stops applying, with no assignments to clean up.
  • Permissions can imply each other. Whoever manages billing can view it, whatever role they hold.
  • Permissions flow down. A role that may delete projects may delete every project in the organization.

Create and assign a role

A role is a link to its organization and one tuple per permission. Each assignment is one more tuple. Keep the role's name and description in your own database; the tuples hold only its id.

# Create "Billing manager" with two permissions
fga tuple write organization:acme organization role:billing-manager
fga tuple write role:billing-manager#member can_manage_billing organization:acme
fga tuple write role:billing-manager#member can_export_data organization:acme

# Give it to the finance team and to an agent
fga tuple write team:finance#member assignee role:billing-manager
fga tuple write agent:invoice-bot assignee role:billing-manager

# Make "Finance lead" include it
fga tuple write role:finance-lead#assignee assignee role:billing-manager

Assignees must be members of the organization for their roles to apply, agents included: agent:invoice-bot member organization:acme.

Check a permission

Your app checks permissions, never roles, so organizations can reshape their roles without any change to your code.

fga query check user:anne can_manage_billing organization:acme

# Everything Anne may do in the organization
fga query list-relations user:anne organization:acme

# Everyone who may manage billing, through any role
fga query list-users --object organization:acme --relation can_manage_billing \
  --user-filter user

Hand out only what you hold

Someone who may manage roles shouldn't be able to create a role with more than they have, or give one like that to themselves. Before each change, check in one BatchCheck that the person making it holds can_manage_roles and every permission the change hands out.

TypeScript
async function requireAll(actor: string, object: string, relations: string[]) {
  const { result } = await fga.batchCheck({
    checks: relations.map((relation) => ({
      user: actor,
      relation,
      object,
      correlationId: relation,
    })),
  });
  const missing = result.filter((r) => !r.allowed).map((r) => r.correlationId);
  if (missing.length) {
    throw new Error(`${actor} lacks ${missing.join(", ")} on ${object}`);
  }
}

// Carla may manage roles, but doesn't hold can_export_data: refused.
await requireAll("user:carla", "organization:acme", ["can_manage_roles", "can_export_data"]);

To assign a role, check the permissions it carries, read with fga.read({ user: "role:billing-manager#member", object: "organization:acme" }), plus those of every role it includes. To remove a permission from a role, require that permission too: you can't take away what you couldn't give.

Change or delete a role

Adding or removing a permission is one tuple, and every holder has the change on the next check. To delete a role, read its tuples and delete them in one write:

  • those on the role: fga.read({ object: "role:billing-manager" }) returns its organization link, its assignees and the roles that include it;
  • its permissions: fga.read({ user: "role:billing-manager#member", object: "organization:acme" });
  • the roles it includes: fga.read({ user: "role:billing-manager#assignee", relation: "assignee", object: "role:" }).

Use this pattern when a role means the same thing across the organization, like the roles in an admin console. When the same person needs different roles on different projects, see Custom roles per project. An app can use both.

Run it

From the AgentFGA repository. The demo creates a new store, walks through creating, assigning, changing and deleting roles, and asserts every answer.

shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/custom-roles/store.fga.yaml

# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/custom-roles/demo.ts

Examples

Custom roles per project

Define roles once and assign them on each project: Anne is release manager on the API and a developer on the website. An assignment on the organization covers every project, and a change to a role applies everywhere it is assigned.

The complete example is in examples/custom-roles-per-project in the AgentFGA repository, with tests and a demo. See Run it.

The model

A role assignment ties one role to its assignees on one project, or on the organization. Projects collect their own assignments and the organization's.

model.fga
model
  schema 1.1

type user

type agent

type team
  relations
    define member: [user, agent]

type organization
  relations
    define owner: [user]
    define member: [user, agent] or owner
    define can_manage_roles: owner
    define assignment: [role_assignment]
    define can_view: can_view from assignment or owner
    define can_edit: can_edit from assignment or owner
    define can_deploy: can_deploy from assignment or owner
    define can_manage_access: can_manage_access from assignment or owner
    define can_delete: can_delete from assignment or owner

type role
  relations
    define organization: [organization]
    define can_view: [user:*, agent:*]
    define can_edit: [user:*, agent:*]
    define can_deploy: [user:*, agent:*]
    define can_manage_access: [user:*, agent:*]
    define can_delete: [user:*, agent:*]

type role_assignment
  relations
    define role: [role]
    define assignee: [user, agent, team#member]
    define can_view: assignee and can_view from role
    define can_edit: assignee and can_edit from role
    define can_deploy: assignee and can_deploy from role
    define can_manage_access: assignee and can_manage_access from role
    define can_delete: assignee and can_delete from role

type project
  relations
    define organization: [organization]
    define assignment: [role_assignment]
    define can_view: can_view from assignment or can_view from organization or can_edit
    define can_edit: can_edit from assignment or can_edit from organization
    define can_deploy: can_deploy from assignment or can_deploy from organization
    define can_manage_access: can_manage_access from assignment or can_manage_access from organization
    define can_delete: can_delete from assignment or can_delete from organization
  • A role carries a permission through a wildcard. user:* can_deploy role:release-manager says any assignee of the role may deploy. A wildcard names one type, so with agents as well as people, write one tuple per type.
  • An assignee holds what the role carries, through assignee and can_deploy from role, and only where the assignment is attached.
  • The organization's permissions mean every project. Owners hold them all, and on a project can_view includes can_edit, so a role that edits can always read.

Define a role

A role is a link to its organization and two wildcard tuples per permission: one for people, one for agents.

fga tuple write organization:acme organization role:release-manager
fga tuple write "user:*" can_edit role:release-manager
fga tuple write "agent:*" can_edit role:release-manager
fga tuple write "user:*" can_deploy role:release-manager
fga tuple write "agent:*" can_deploy role:release-manager

Assign it

Use one assignment object per role on each project, such as role_assignment:project.api.release-manager. The first assignee creates it with three tuples; each later assignee adds one. To assign on the whole organization, attach the assignment to organization:acme instead.

# Anne is release manager on the API
fga tuple write role:release-manager role role_assignment:project.api.release-manager
fga tuple write role_assignment:project.api.release-manager assignment project:api
fga tuple write user:anne assignee role_assignment:project.api.release-manager

# Engineering reads every project
fga tuple write role:reader role role_assignment:organization.acme.reader
fga tuple write role_assignment:organization.acme.reader assignment organization:acme
fga tuple write team:engineering#member assignee role_assignment:organization.acme.reader

Check and list

fga query check user:anne can_deploy project:api      # allowed
fga query check user:anne can_deploy project:web      # denied

# The projects an agent may edit
fga query list-objects agent:coding-agent can_edit project

For a project's access page, read the assignments attached to it, then each assignment's role and assignees:

TypeScript
const { tuples: links } = await fga.read({ relation: "assignment", object: "project:web" });

for (const { key } of links) {
  const { tuples } = await fga.read({ object: key.user });
  const role = tuples.find((t) => t.key.relation === "role")?.key.user;
  const assignees = tuples.filter((t) => t.key.relation === "assignee").map((t) => t.key.user);
  console.log(role, assignees); // role:developer [ "agent:coding-agent", "user:anne" ]
}

Hand out only what you hold

Defining roles takes can_manage_roles on the organization. Assigning one takes can_manage_access where it is assigned, and every permission the role carries there. A website admin can give out developer, but not release manager unless they can deploy the website themselves, and nothing on projects they don't administer. With requireAll from Custom roles:

TypeScript
const { tuples } = await fga.read({ user: "user:*", object: "role:release-manager" });
const carried = tuples.map((t) => t.key.relation); // ["can_edit", "can_deploy"]

// Ben administers the website, but can't deploy it: refused.
await requireAll("user:ben", "project:web", ["can_manage_access", ...carried]);

Run it

From the AgentFGA repository. The demo defines four roles, assigns them per project and on the organization, shows a project admin being refused, and changes a role everywhere at once.

shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/custom-roles-per-project/store.fga.yaml

# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/custom-roles-per-project/demo.ts

Examples

Agent service accounts

Give each AI agent its own identity, owned by the organization. Managers decide what it may do and can switch it off with one write. When a person runs the agent, it acts for them and can do only what both of them may do.

The complete example is in examples/agent-service-accounts in the AgentFGA repository, with tests and a demo. See Run it.

The model

model.fga
model
  schema 1.1

type user

type team
  relations
    define member: [user]

type organization
  relations
    define admin: [user]
    define member: [user] or admin
    define can_create_service_accounts: admin

type service_account
  relations
    define organization: [organization]
    define enabled: [service_account]
    define manager: [user] or admin from organization
    define runner: [user, team#member] or manager
    define can_manage: manager
    define can_run: runner

type tool
  relations
    define organization: [organization]
    define can_use: [user, team#member, service_account#enabled, service_account#enabled with not_expired]

type project
  relations
    define organization: [organization]
    define editor: [user, team#member, service_account#enabled, service_account#enabled with not_expired]
    define viewer: [user, team#member, service_account#enabled, service_account#enabled with not_expired] or editor

condition not_expired(current_time: timestamp, grant_time: timestamp, grant_duration: duration) {
  current_time < grant_time + grant_duration
}
  • The off switch is one tuple. enabled holds the account itself while it is on. Every grant names service_account:<id>#enabled, and the model rejects a grant to the bare account with validation_error, so no grant can bypass the switch.
  • Grants can expire. A grant written with not_expired stores when it starts and how long it lasts; each check sends the current time.
  • Managers and runners are people. Managers change what the account may do and switch it off; organization admins manage every account. Runners may start the agent as the account, but can't change it.

Create an account

Link it to the organization, switch it on, and name its managers and runners.

fga tuple write organization:acme organization service_account:support-bot
fga tuple write service_account:support-bot enabled service_account:support-bot
fga tuple write user:anne manager service_account:support-bot
fga tuple write team:support#member runner service_account:support-bot

Grant access

Grant to the account's #enabled set, for good or for a while. Let only managers grant, and only access they hold themselves: check can_manage on the account and the same relation on the object first.

TypeScript
await fga.write({
  writes: [
    // For good
    {
      user: "service_account:support-bot#enabled",
      relation: "can_use",
      object: "tool:issue-refund",
    },
    // For 15 minutes
    {
      user: "service_account:deploy-bot#enabled",
      relation: "editor",
      object: "project:web",
      condition: {
        name: "not_expired",
        context: { grant_time: new Date().toISOString(), grant_duration: "15m" },
      },
    },
  ],
});

Switch it off

Delete the enabled tuple. Every grant stops applying on the next check, including in runs already under way. The grants stay stored, so writing the tuple back restores them all.

# Off
fga tuple delete service_account:support-bot enabled service_account:support-bot

# Back on
fga tuple write service_account:support-bot enabled service_account:support-bot

Act for a person

Before a run starts, check that the account is on and that the person may run it. Then put one BatchCheck in front of every tool call and data access: the account's side and the person's side. Both must allow it, and the answer says which side refused.

TypeScript
async function authorize(
  account: string,
  onBehalfOf: string | undefined,
  relation: string,
  object: string,
) {
  // Every check sends the time, for grants that expire.
  const context = { current_time: new Date().toISOString() };
  const checks = [{ user: account, relation, object, context, correlationId: "account" }];
  if (onBehalfOf) {
    checks.push({ user: onBehalfOf, relation, object, context, correlationId: "user" });
  }

  const { result } = await fga.batchCheck({ checks });
  const side = (id: string) => result.find((r) => r.correlationId === id)?.allowed === true;
  const decision = { account: side("account"), user: onBehalfOf ? side("user") : undefined };
  return { allowed: decision.account && decision.user !== false, ...decision };
}

await authorize("service_account:support-bot", "user:ben", "can_use", "tool:issue-refund");
// { allowed: false, account: true, user: false }
RunActionAccountPersonAnswer
support-bot for Annecan_use tool:issue-refundyesyesallowed
support-bot for Anneviewer project:payrollnoyesdenied
support-bot for Bencan_use tool:issue-refundyesnodenied
support-bot, on a schedulecan_use tool:issue-refundyesnoneallowed

A run with no person behind it, such as a scheduled job, checks the account alone.

Review what an agent can do

ListObjects answers what one account may do right now; ListUsers answers which accounts may do something. Send the current time with both, so expired grants drop out.

TypeScript
const context = { current_time: new Date().toISOString() };

// The tools support-bot may use
const { objects } = await fga.listObjects({
  user: "service_account:support-bot",
  relation: "can_use",
  type: "tool",
  context,
});

// The agents that may issue refunds
const { users } = await fga.listUsers({
  object: { type: "tool", id: "issue-refund" },
  relation: "can_use",
  user_filters: [{ type: "service_account" }],
  context,
});

Authentication stays in your app. Map the agent's credential, such as an API key or a workload token, to service_account:<id>, then ask AgentFGA what the account may do. Revoking a credential and switching the account off are separate controls; the switch also stops runs that already hold a valid credential.

Run it

From the AgentFGA repository. The demo creates two accounts, grants them access, runs one for two different people, switches it off mid-run and back on, and lets a 15-minute grant lapse.

shell
# The model's tests, on AgentFGA's own engine: no server needed
npx tsx src/server/main.ts model test --tests examples/agent-service-accounts/store.fga.yaml

# A local server, then the demo in another terminal
npm run server -- --authn-method preshared --authn-preshared-keys dev-key-123
FGA_API_URL=http://127.0.0.1:8080 FGA_API_TOKEN=dev-key-123 npx tsx examples/agent-service-accounts/demo.ts

Concepts

Authorization models

A model describes the types of things in your system and how people and agents can be related to them. AgentFGA uses OpenFGA's modeling language unchanged.

Types and relations

Each type is a kind of object, such as document or tool. Each define names a relation an object of that type can have. A relation either lists who can be related directly, in square brackets, or is computed from other relations.

model.fga
model
  schema 1.1

type user

type folder
  relations
    define viewer: [user]

type document
  relations
    define parent: [folder]
    define owner: [user]
    define blocked: [user]
    define viewer: ([user, user:*] or owner or viewer from parent) but not blocked

Who can be related directly

Written asAllows tuples like
[user]user:anne, one user
[team#member]team:support#member, everyone with that relation to the team
[user:*]user:*, every user, for public access
[agent with not_expired]agent:support-bot, only with the condition attached

Combining relations

OperatorMeaningExample
orEither relationdefine viewer: [user] or owner
andBoth relationsdefine can_publish: editor and approver
but notThe first, unless the seconddefine viewer: [user] but not blocked
fromA relation on a related objectdefine viewer: viewer from parent

Writing a model

The API takes models as JSON. Write them in the modeling language and convert them with the fga CLI or the @openfga/syntax-transformer package. AgentFGA validates a model exactly as OpenFGA does and rejects it with the same error if, for example, a relation refers to one that doesn't exist.

fga model write --store-id $FGA_STORE_ID --file model.fga

Versions

Models are never changed in place. Each write creates a new model with its own id, and queries use the newest model unless the request names one in authorization_model_id. Responses name the model that answered in the Openfga-Authorization-Model-Id header.

In production, pin the model id in your client. A new model then takes effect only when you deploy code that uses it.

Schema versions

AgentFGA accepts schema 1.1 and schema 1.2. Schema 1.2 is for modular models, split across files with module and extend type, and keeps their module metadata. A model can have up to 100 types by default.

Concepts

Relationship tuples

A tuple records one relationship: this user, or agent, has this relation to this object. Your model says what tuples mean; tuples say who actually has access.

Parts of a tuple

FieldExampleNotes
useragent:support-botAn object, a userset such as team:support#member, or a wildcard such as user:*
relationcan_useA relation the object's type defines
objecttool:issue-refundWritten as type:id
conditionnot_expiredOptional. A condition name and the parameters stored with it

Writing and deleting

One Write request can add and remove up to 100 tuples in total, and applies all of them or none. Writing a tuple that already exists, or deleting one that doesn't, is an error unless you set on_duplicate or on_missing to ignore.

TypeScript
await fga.write({
  writes: [{ user: "agent:support-bot", relation: "member", object: "team:support" }],
  deletes: [{ user: "agent:support-bot", relation: "can_use", object: "tool:issue-refund" }],
});

Reading and following changes

Read returns stored tuples that match a filter, a page at a time. ReadChanges returns every write and delete in order, which suits audit logs and keeping another system in sync. Save the continuation token it returns and pass it next time to get only newer changes.

Contextual tuples

Check, BatchCheck, ListObjects, ListUsers and Expand accept up to 100 contextual tuples. They are treated as if they were stored, for that one request only. Use them for facts you already know at request time, such as the groups in a user's sign-in token or the agent run a request belongs to.

Every read in AgentFGA sees the latest writes, so a tuple you have just written applies to the very next check.

Concepts

Conditions

A condition makes a grant apply only when an expression is true, such as before a time or from an IP range. Conditions are written in CEL, as in OpenFGA.

Define a condition

Declare the condition in the model with typed parameters, then allow it on a relation with with.

model.fga
type document
  relations
    define editor: [user, user with ip_allowed]

condition ip_allowed(ip: ipaddress, cidr: string) {
  ip.in_cidr(cidr)
}

Supply the parameters

Parameters can be stored with the tuple, sent with the request in context, or both. Usually the tuple holds what the grant allows and the request holds the facts of the moment.

TypeScript
await fga.write({
  writes: [{
    user: "user:dana",
    relation: "editor",
    object: "document:roadmap",
    condition: { name: "ip_allowed", context: { cidr: "10.0.0.0/8" } },
  }],
});

await fga.check({ user: "user:dana", relation: "editor", object: "document:roadmap", context: { ip: "10.1.2.3" } });
// allowed: true
await fga.check({ user: "user:dana", relation: "editor", object: "document:roadmap", context: { ip: "192.168.0.1" } });
// allowed: false

Parameter types

bool, string, int, uint, double, duration (such as "1h"), timestamp (RFC 3339, such as "2026-10-27T09:00:00Z"), ipaddress, and list<T> and map<T> of these.

When a parameter is missing

If a condition needs a parameter that neither the tuple nor the request supplies, the request can fail with validation_error instead of answering. Always send the parameters your conditions read.

OpenFGA decides at random, per request, whether some of these cases fail or answer, so in rare cases the two servers can differ here. See OpenFGA compatibility.

How conditions are evaluated

AgentFGA evaluates CEL with cel-js, because OpenFGA's Go library can't run on Workers. All of OpenFGA's parameter types and condition tests behave the same. Error messages for a condition that doesn't compile are worded differently, and OpenFGA's limit on how much work a condition may do isn't enforced. The experimental inline $expression conditions are not supported.

Concepts

Stores and scaling

A store holds one set of models, tuples and assertions. Stores never see each other's data, which makes them the natural unit for customers and for capacity.

One store per customer

Create a store for each customer of your product and keep its id with the customer's record. Queries never cross stores, so one customer's grants can't affect another's.

How stores run on Cloudflare

On Workers, each store is its own Durable Object with its own SQLite database. The code that answers a check runs in the same process as the data, so each lookup during a check is a local read rather than a network round trip.

Your agent or app Calls the OpenFGA API with an SDK or plain HTTP.
Worker Checks the API key, handles CORS and creates and lists stores.
One Durable Object per store Holds the store's tuples, models, change history and assertions, and answers its queries.
  • Placement sets latency. A store lives near where it was created. A check takes about 6 ms when the request arrives in the same data center and about 20 ms from one region away, so create each customer's store from a server near where their agents run.
  • Size doesn't. Checks were as fast with 1.1 million relationships in a store as with 1,400. Each relationship takes about 361 bytes, including history and indexes.
  • Every read is up to date. The consistency option is accepted and changes nothing.

Capacity

One store answers about 500 to 700 single checks a second, or about 3,600 checks a second sent through BatchCheck. Past that, requests wait in line, then fail with 503 unavailable; retry them with backoff. Stores run independently, so total capacity grows with the number of stores: 16 stores served 6,799 requests a second with no errors.

The Node server has no per-store limit: any number of processes can serve one store from a shared database. See Node server and the benchmarks.

Run AgentFGA

Cloudflare Workers

Deploy AgentFGA to your own Cloudflare account. Each store runs in its own Durable Object, close to its data.

Requirements

  • Node.js 22.13 or later.
  • A Cloudflare account with Workers and Durable Objects. Wrangler, Cloudflare's CLI, is installed with the project's dependencies.

Run locally

shell
npm install
echo "AGENTFGA_AUTHN_PRESHARED_KEYS=dev-key-123" > .dev.vars
npm run dev                      # http://127.0.0.1:8787

Deploy

Save your API keys as a secret, separated by commas, then deploy. Wrangler prints the Worker's URL; use it as the SDK's apiUrl.

shell
npx wrangler secret put AGENTFGA_AUTHN_PRESHARED_KEYS
npm run deploy

Configure

Set options as vars in wrangler.jsonc, and secrets with wrangler secret put. Every option is listed in the configuration reference.

wrangler.jsonc
{
  "vars": {
    "AGENTFGA_AUTHN_METHOD": "preshared",
    "AGENTFGA_MAX_CHECKS_PER_BATCH_CHECK": "50",
    "AGENTFGA_DIAGNOSTIC_HEADERS": "true"
  }
}

Diagnostic headers

With AGENTFGA_DIAGNOSTIC_HEADERS set to true, responses carry X-Agentfga-Edge-Colo and X-Agentfga-Store-Colo, naming the data center that received the request and the one the store runs in. When the two differ, the request crossed regions; see placement.

Run AgentFGA

Node server

Run AgentFGA where you run OpenFGA today: on Postgres, MySQL, SQLite or memory. It takes the same flags, environment variables and defaults as openfga run, so an existing deployment's configuration starts it.

Build and start

npm run build:server produces dist/agentfga.mjs, one file with its database drivers and migrations inside. It needs Node.js 22.13 or later.

shell
npm run build:server

node dist/agentfga.mjs migrate --datastore-engine postgres \
  --datastore-uri 'postgres://user:pass@db:5432/openfga'

node dist/agentfga.mjs run --datastore-engine postgres \
  --datastore-uri 'postgres://user:pass@db:5432/openfga' \
  --authn-method preshared --authn-preshared-keys key1,key2

To try it from source without building: npm run server -- --datastore-engine sqlite --datastore-uri ./openfga.db. agentfga run --help lists every flag.

Datastores

Engine--datastore-uri
memoryNot needed. The default: a private SQLite database that lasts as long as the process.
sqliteA file path, or a file: URI with _pragma= parameters.
postgresA URI such as postgres://user:pass@db:5432/openfga.
mysqlA URI, or a Go DSN such as user:pass@tcp(db:3306)/openfga.

--datastore-username and --datastore-password override the credentials in the URI.

Share a database with OpenFGA

AgentFGA uses OpenFGA's own schema and migrations, recorded in the same goose_db_version table, so either tool can migrate the database and migrate --version N moves up or down to a version. AgentFGA and OpenFGA servers can serve one database at the same time, for example behind one load balancer during a migration. They read each other's models, tuples, conditions, change history and assertions, and accept each other's continuation tokens.

Read replicas

Set --datastore-secondary-uri to send reads to a replica, as OpenFGA does: tuple reads go to the replica unless the request asks for HIGHER_CONSISTENCY; model, store, assertion and change-history reads always do. Writes, and the reads inside a write, go to the primary.

Use more than one core

Each process uses one core. --workers N runs N processes behind the same ports, and --workers 0 runs one per CPU. The memory datastore is private to one process, so use SQLite, Postgres or MySQL with more than one worker. On a 16-core test machine sharing cores with Postgres, one worker served about 2,500 to 3,000 checks a second and four workers about 8,400.

Metrics

Prometheus metrics are served on --metrics-addr (:2112/metrics) with OpenFGA's names and labels: the grpc_server_* families, openfga_request_duration_ms, openfga_datastore_query_count, openfga_datastore_item_count, openfga_dispatch_count, openfga_check_result_count and the process_* metrics. With several workers, one scrape sums them all.

Health and shutdown

  • /healthz and gRPC grpc.health.v1.Health/Check answer SERVING once the database is at the schema version AgentFGA needs, and 503 NOT_SERVING until then. The server starts against an unmigrated database, so a migration job can run alongside it.
  • On SIGTERM or SIGINT the server stops accepting connections and finishes requests in progress, for up to --shutdown-timeout (10 s). gRPC clients get a GOAWAY.
  • Every response carries X-Request-Id, and every request is logged.

Database notes

  • Postgres behind PgBouncer in transaction mode: add default_query_exec_mode=simple_protocol to the URI, as you would for OpenFGA.
  • MySQL: tested against MySQL 8.4. Run it in UTC, the default in MySQL's Docker images and on RDS. In any other time zone OpenFGA reports timestamps off by the offset and AgentFGA doesn't, so the two disagree on a shared database.

OpenFGA settings AgentFGA has no use for, such as caches, tracing, throttling and the playground, are accepted with a warning. --access-control-enabled is refused at startup, so a deployment never runs with less protection than it asked for.

Run AgentFGA

Docker

The repository's Dockerfile builds the Node server into an image laid out like openfga/openfga, so it can stand in for it.

shell
docker build -t agentfga .

docker run agentfga migrate --datastore-engine postgres \
  --datastore-uri 'postgres://user:pass@db:5432/openfga'

docker run -p 8080:8080 -p 8081:8081 agentfga run --datastore-engine postgres \
  --datastore-uri 'postgres://user:pass@db:5432/openfga'

Ports

PortServes
8080The HTTP API
8081The gRPC API, openfga.v1.OpenFGAService and grpc.health.v1.Health
2112Prometheus metrics

The image

  • The command is the container's argument: run by default, or migrate.
  • The health check is the same as OpenFGA's, through grpc_health_probe on port 8081.
  • It runs as a non-root user on a distroless Node.js base.

Run AgentFGA

Authentication

Every request must prove it may use the API, with an API key or an OIDC token. Both work on Workers and on the Node server.

API keys

The default on Workers. Clients send a key as a bearer token, and AgentFGA accepts any key in its list. Keys are compared in constant time.

# Comma-separated keys, stored as a secret.
npx wrangler secret put AGENTFGA_AUTHN_PRESHARED_KEYS
request header
Authorization: Bearer key1

To rotate a key, add the new key next to the old one, move every client to the new key, then remove the old one.

OIDC

Accept tokens from your identity provider instead of shared keys. AgentFGA checks each token's signature against the issuer's published keys (RS256), and its issuer, audience and expiry, as OpenFGA does.

{
  "vars": {
    "AGENTFGA_AUTHN_METHOD": "oidc",
    "AGENTFGA_AUTHN_OIDC_ISSUER": "https://auth.example.com/",
    "AGENTFGA_AUTHN_OIDC_AUDIENCE": "https://fga.example.com/"
  }
}

Optional settings narrow what is accepted: other issuer names (_ISSUER_ALIASES), allowed subjects (_SUBJECTS) and the claims that hold the client id (_CLIENT_ID_CLAIMS), each comma-separated. On the Node server they are the matching --authn-oidc-* flags. Clients use the SDK's client-credentials method:

TypeScript
const fga = new OpenFgaClient({
  apiUrl: process.env.FGA_API_URL!,
  storeId: process.env.FGA_STORE_ID!,
  credentials: {
    method: CredentialsMethod.ClientCredentials,
    config: {
      apiTokenIssuer: "auth.example.com",
      apiAudience: "https://fga.example.com/",
      clientId: process.env.FGA_CLIENT_ID!,
      clientSecret: process.env.FGA_CLIENT_SECRET!,
    },
  },
});

No authentication

Setting the method to none accepts every request. Use it only on your own machine. It is the Node server's default, as in OpenFGA, so set a method before exposing it.

A malformed request is reported as invalid before its credentials are checked, as OpenFGA does. /healthz never needs credentials.

Run AgentFGA

Configuration reference

Every setting for Workers, and the most used settings for the Node server.

Workers

Set these as vars in wrangler.jsonc, or as secrets.

VariableDefault
AGENTFGA_AUTHN_METHODpreshared, or oidc. none turns off authentication, for local use only.
AGENTFGA_AUTHN_PRESHARED_KEYSA secret, comma-separated. Required when the method is preshared.
AGENTFGA_AUTHN_OIDC_ISSUER / _AUDIENCERequired when the method is oidc.
AGENTFGA_AUTHN_OIDC_ISSUER_ALIASES / _SUBJECTS / _CLIENT_ID_CLAIMSUnset. Comma-separated, as OpenFGA's --authn-oidc-*.
AGENTFGA_REQUEST_TIMEOUT_MS3000. 0 means no limit.
AGENTFGA_MAX_TUPLES_PER_WRITE100
AGENTFGA_MAX_TYPES_PER_AUTHORIZATION_MODEL100
AGENTFGA_MAX_CHECKS_PER_BATCH_CHECK50
AGENTFGA_RESOLVE_NODE_LIMIT25
AGENTFGA_LIST_OBJECTS_MAX_RESULTS / _DEADLINE_MS1000 / 3000
AGENTFGA_LIST_USERS_MAX_RESULTS / _DEADLINE_MS1000 / 3000
AGENTFGA_DIAGNOSTIC_HEADERSUnset. true adds headers showing which data centers handled each request.

Node server

The Node server takes OpenFGA's flags and defaults. Each flag can also be set as an environment variable: --datastore-uri becomes OPENFGA_DATASTORE_URI. Flags win over variables. agentfga run --help lists them all.

FlagDefault
--datastore-enginememory. Or sqlite, postgres, mysql.
--datastore-uriUnset. See datastores.
--datastore-secondary-uriUnset. A read replica for Postgres or MySQL.
--http-addr / --grpc-addr0.0.0.0:8080 / 0.0.0.0:8081
--http-tls-* / --grpc-tls-*Off. The certificate and key are reloaded when the files change.
--http-cors-allowed-origins*
--metrics-addr0.0.0.0:2112
--authn-methodnone. Or preshared with --authn-preshared-keys, or oidc with --authn-oidc-*.
--request-timeout3s
--max-tuples-per-write100
--max-checks-per-batch-check50
--resolve-node-limit25
--listObjects-max-results / --listObjects-deadline1000 / 3s
--listUsers-max-results / --listUsers-deadline1000 / 3s
--workersOne process. 0 runs one per CPU.
--shutdown-timeout10s
--log-format / --log-leveltext / info

Run AgentFGA

OpenFGA compatibility

AgentFGA covers OpenFGA's API: stores, models, tuples, Check, BatchCheck, ListObjects, ListUsers, Expand, change history, assertions and conditions. Its responses were compared with OpenFGA v1.22.0 request by request.

What was tested

Test tools send identical requests to AgentFGA and to OpenFGA, then compare every status, error code, message and response. npm test replays the results, so they are rechecked on every run.

What was comparedRequestsDifferences
Random models, relationships and queries864,8272, both caused by OpenFGA
The same, with condition parameters sometimes missing864,750159, of which 157 are OpenFGA's random choice described below
Malformed and edge-case requests to every endpoint5,0200
gRPC requests to the Node server1,9240
OIDC token cases300
OpenFGA's own conformance tests 171 testsall but 180. The 18 use experimental $expression conditions.

The Node server was also run on one Postgres database, one MySQL database and one SQLite file alongside OpenFGA. Each wrote data and both read everything back, with every response matching.

Not supported

  • Experimental inline $expression conditions.
  • AuthZEN endpoints and OpenFGA's access control.
  • Tracing and gRPC server reflection.
  • On Workers: the gRPC API and metrics, which the Node server has.

Works differently

  • Stores that don't exist return 404 store_id_not_found on every route. OpenFGA accepts some requests for them, such as writing a model.
  • The consistency option changes nothing on Workers, where every read is up to date. On the Node server with a read replica, it routes reads as OpenFGA does.
  • StreamedListObjects works out the full result, then sends it.
  • Condition errors are worded differently, and OpenFGA's cost limit for conditions isn't enforced.
  • Missing condition parameters can give a different result in rare cases, because OpenFGA decides how to handle them at random.
  • OpenFGA bugs found during testing are not reproduced; the repository's COMPATIBILITY.md lists them.

API reference

Overview

AgentFGA serves OpenFGA's HTTP API. Requests and responses are JSON, and field names, errors and limits match OpenFGA's.

Base URL

Where it runsBase URL
Workers, locallyhttp://127.0.0.1:8787
Workers, deployedThe URL npm run deploy prints
Node serverhttp://your-host:8080, and gRPC on port 8081

Authentication

Send an API key or OIDC token as a bearer token. See Authentication.

shell
curl $FGA_API_URL/stores -H "Authorization: Bearer $FGA_API_TOKEN"

Conventions

  • Ids of stores and models are 26-character ULIDs, such as 01JBZ8M2X3K4Q5R6S7T8V9W0XY.
  • Pages: list endpoints take page_size (1 to 100, default 50) and return a continuation_token. Pass it back to get the next page; an empty token means there are no more.
  • Models: queries use the store's newest model unless they name one in authorization_model_id. Responses name the model used in the Openfga-Authorization-Model-Id header.
  • Consistency: queries accept consistency (MINIMIZE_LATENCY or HIGHER_CONSISTENCY). See compatibility.
  • Health: GET /healthz answers {"status": "SERVING"} without credentials.

Errors

Errors return a status and a JSON body with a code and a message, as OpenFGA's do.

Response 400
{
  "code": "validation_error",
  "message": "relation 'document#approver' not found"
}
StatusCommon codesRetry?
400validation_error, authorization_model_not_found, latest_authorization_model_not_found, invalid_continuation_token, exceeded_entity_limitNo. Fix the request.
401bearer_token_missing, unauthenticatedNo. Fix the credentials.
404store_id_not_found, undefined_endpointNo.
409Aborted: a conflicting writeYes.
500deadline_exceeded, internal_errorSometimes. A deadline usually means the query is too broad.
503unavailable: the store is overloadedYes, with backoff.

Limits

LimitDefault
Tuples per Write, writes and deletes together100
Checks per BatchCheck50
Contextual tuples per request100
Types per model100
Resolution depth25
ListObjects and ListUsers results1,000
ListObjects and ListUsers time3 s
Request time3 s

When ListObjects or ListUsers reach their time limit, they return what they found so far. Change limits in the configuration.

Stores

Create store

Creates an empty store. Create one per customer, and keep its id with the customer's record.

POST/stores

Body

namestringrequired

A name for the store, such as the customer's name. Names don't have to be unique.

Response

idstring

The new store's id, used in every store path.

namestring

The name you gave.

created_at, updated_attimestamp

When the store was created and last changed.

On Workers, a store lives near where it was created. Create each customer's store from a server near where their agents run.

curl -X POST $FGA_API_URL/stores \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "acme"}'
Response 201
{
  "id": "01JBZ8M2X3K4Q5R6S7T8V9W0XY",
  "name": "acme",
  "created_at": "2026-10-27T09:00:00.512Z",
  "updated_at": "2026-10-27T09:00:00.512Z"
}

Stores

List stores

Lists stores, a page at a time.

GET/stores

Query

page_sizeintegerdefault 50

How many stores to return, from 1 to 100.

continuation_tokenstring

The token from the previous page.

namestring

Only return stores with exactly this name.

Response

storesobject[]

Stores, each with id, name, created_at, updated_at and deleted_at.

continuation_tokenstring

Pass it back for the next page. Empty on the last page.

curl "$FGA_API_URL/stores?page_size=20" \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "stores": [
    {
      "id": "01JBZ8M2X3K4Q5R6S7T8V9W0XY",
      "name": "acme",
      "created_at": "2026-10-27T09:00:00.512Z",
      "updated_at": "2026-10-27T09:00:00.512Z",
      "deleted_at": null
    }
  ],
  "continuation_token": ""
}

Stores

Get store

Returns one store.

GET/stores/{store_id}

Path

store_idstringrequired

The store's id.

Response

The store's id, name, created_at and updated_at. A store that doesn't exist, or was deleted, returns 404 store_id_not_found.

curl $FGA_API_URL/stores/$FGA_STORE_ID \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "id": "01JBZ8M2X3K4Q5R6S7T8V9W0XY",
  "name": "acme",
  "created_at": "2026-10-27T09:00:00.512Z",
  "updated_at": "2026-10-27T09:00:00.512Z"
}

Stores

Delete store

Deletes a store. Requests to it then return 404 store_id_not_found, and it no longer appears in the list.

DELETE/stores/{store_id}

Path

store_idstringrequired

The store's id.

Response

204 No Content, with an empty body.

Deleting a store can't be undone through the API.

curl -X DELETE $FGA_API_URL/stores/$FGA_STORE_ID \
  -H "Authorization: Bearer $FGA_API_TOKEN"

Authorization models

Write model

Adds a new model to the store. Models are never changed in place: each write gets a new id and becomes the store's newest model.

POST/stores/{store_id}/authorization-models

Path

store_idstringrequired

The store's id.

Body

The model as JSON. Write it in the modeling language and convert it with the fga CLI or @openfga/syntax-transformer.

schema_versionstringrequired

1.1, or 1.2 for modular models.

type_definitionsobject[]required

One entry per type, with its relations and the types that can be related directly. Up to 100 types.

conditionsobject

Conditions by name, each with its CEL expression and typed parameters.

Response

authorization_model_idstring

The new model's id. Pass it as authorization_model_id to pin queries to this model.

An invalid model is rejected with 400 and the same error code and message OpenFGA gives.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/authorization-models \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_version": "1.1",
    "type_definitions": [
      { "type": "user" },
      {
        "type": "document",
        "relations": { "owner": { "this": {} } },
        "metadata": {
          "relations": {
            "owner": { "directly_related_user_types": [{ "type": "user" }] }
          }
        }
      }
    ]
  }'
Response 201
{ "authorization_model_id": "01JC0A7N4Q2R3S5T6V7W8X9Y0Z" }

Authorization models

List models

Lists the store's models, newest first.

GET/stores/{store_id}/authorization-models

Path

store_idstringrequired

The store's id.

Query

page_sizeintegerdefault 50

How many models to return, from 1 to 100.

continuation_tokenstring

The token from the previous page.

Response

authorization_modelsobject[]

Models, each with id, schema_version, type_definitions and conditions.

continuation_tokenstring

Pass it back for the next page. Empty on the last page.

curl "$FGA_API_URL/stores/$FGA_STORE_ID/authorization-models?page_size=10" \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "authorization_models": [
    {
      "id": "01JC0A7N4Q2R3S5T6V7W8X9Y0Z",
      "schema_version": "1.1",
      "type_definitions": [ ... ],
      "conditions": { ... }
    }
  ],
  "continuation_token": ""
}

Authorization models

Read model

Returns one model by id.

GET/stores/{store_id}/authorization-models/{id}

Path

store_idstringrequired

The store's id.

idstringrequired

The model's id.

Response

authorization_modelobject

The model's id, schema_version, type_definitions and conditions. An unknown id returns 400 authorization_model_not_found.

curl $FGA_API_URL/stores/$FGA_STORE_ID/authorization-models/01JC0A7N4Q2R3S5T6V7W8X9Y0Z \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "authorization_model": {
    "id": "01JC0A7N4Q2R3S5T6V7W8X9Y0Z",
    "schema_version": "1.1",
    "type_definitions": [
      { "type": "user" },
      {
        "type": "document",
        "relations": { "owner": { "this": {} } },
        "metadata": {
          "relations": {
            "owner": { "directly_related_user_types": [{ "type": "user" }] }
          }
        }
      }
    ],
    "conditions": {}
  }
}

Relationship tuples

Write tuples

Adds and removes tuples in one request. Either every change is applied or none is.

POST/stores/{store_id}/write

Path

store_idstringrequired

The store's id.

Body

Send writes, deletes or both, with up to 100 tuples between them.

writesobject

Tuples to add.

Child attributes
tuple_keysobject[]required

Each with user, relation, object and an optional condition: { "name": "...", "context": { ... } }.

on_duplicatestringdefault error

ignore skips tuples that already exist instead of failing the request.

deletesobject

Tuples to remove.

Child attributes
tuple_keysobject[]required

Each with user, relation and object.

on_missingstringdefault error

ignore skips tuples that don't exist instead of failing the request.

authorization_model_idstring

The model to validate tuples against. Defaults to the newest.

Response

An empty object. A tuple the model doesn't allow fails the whole request with 400 validation_error.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/write \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "writes": {
      "tuple_keys": [
        { "user": "agent:support-bot", "relation": "member", "object": "team:support" }
      ],
      "on_duplicate": "ignore"
    },
    "deletes": {
      "tuple_keys": [
        { "user": "agent:support-bot", "relation": "can_use", "object": "tool:issue-refund" }
      ]
    }
  }'
Response 200
{}

Relationship tuples

Read tuples

Returns stored tuples that match a filter, a page at a time. Contextual tuples and computed relations are not included.

POST/stores/{store_id}/read

Path

store_idstringrequired

The store's id.

Body

tuple_keyobject

A filter of user, relation and object. Leave it out to read every tuple. object can be a type alone, such as document:, when user is set.

page_sizeintegerdefault 50

How many tuples to return, from 1 to 100.

continuation_tokenstring

The token from the previous page.

Response

tuplesobject[]

Each with key (the tuple, including any condition) and timestamp (when it was written).

continuation_tokenstring

Pass it back for the next page. Empty on the last page.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/read \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tuple_key": {"user": "agent:support-bot", "object": "document:"}}'
Response 200
{
  "tuples": [
    {
      "key": {
        "user": "agent:support-bot",
        "relation": "temp",
        "object": "document:q3-plan",
        "condition": {
          "name": "not_expired",
          "context": { "grant_time": "2026-10-27T09:00:00Z", "grant_duration": "1h" }
        }
      },
      "timestamp": "2026-10-27T09:00:00.481Z"
    }
  ],
  "continuation_token": ""
}

Relationship tuples

Read changes

Returns every tuple write and delete in the order they happened. Use it for audit logs, or to keep another system in step with your permissions.

GET/stores/{store_id}/changes

Path

store_idstringrequired

The store's id.

Query

typestring

Only changes to objects of this type.

start_timetimestamp

Only changes from this time on.

page_sizeintegerdefault 50

How many changes to return, up to 100.

continuation_tokenstring

The token from the previous call.

Response

changesobject[]

Each with tuple_key, operation (TUPLE_OPERATION_WRITE or TUPLE_OPERATION_DELETE) and timestamp.

continuation_tokenstring

Keep the latest token and pass it next time to get only newer changes.

curl "$FGA_API_URL/stores/$FGA_STORE_ID/changes?type=document&page_size=50" \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "changes": [
    {
      "tuple_key": { "user": "user:anne", "relation": "owner", "object": "document:q3-plan" },
      "operation": "TUPLE_OPERATION_WRITE",
      "timestamp": "2026-10-27T09:00:00.481Z"
    }
  ],
  "continuation_token": "eyJ1bGlkIjoiMDFKQzBCLi4uIn0="
}

Queries

Check

Answers whether a user or agent has a relation to an object. Ask before every tool call, read or write an agent makes.

POST/stores/{store_id}/check

Path

store_idstringrequired

The store's id.

Body

tuple_keyobjectrequired

The question.

Child attributes
userstringrequired

Who is asking, such as agent:support-bot or team:support#member.

relationstringrequired

The relation to test, such as can_use.

objectstringrequired

The object, such as tool:issue-refund.

contextobject

Condition parameters for this request, such as { "current_time": "2026-10-27T09:30:00Z" }.

contextual_tuplesobject

{ "tuple_keys": [ ... ] }: up to 100 tuples that count for this request only.

authorization_model_idstring

The model to use. Defaults to the newest.

consistencystring

MINIMIZE_LATENCY or HIGHER_CONSISTENCY. Reads are always up to date on Workers.

Response

allowedboolean

Whether the relation holds.

resolutionstring

Always empty, as in OpenFGA.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "tuple_key": {
      "user": "agent:support-bot",
      "relation": "viewer",
      "object": "document:q3-plan"
    },
    "context": { "current_time": "2026-10-27T09:30:00Z" }
  }'
Response 200
{ "allowed": true, "resolution": "" }

Queries

Batch check

Runs up to 50 checks in one request. Use it to filter retrieved documents, or to test an agent's and a user's permissions together.

POST/stores/{store_id}/batch-check

Path

store_idstringrequired

The store's id.

Body

checksobject[]required

From 1 to 50 checks.

Child attributes
tuple_keyobjectrequired

user, relation and object, as in Check.

correlation_idstringrequired

Your id for this check, unique within the request. Its answer comes back under it.

contextobject

Condition parameters for this check.

contextual_tuplesobject

{ "tuple_keys": [ ... ] } for this check only.

authorization_model_idstring

The model to use for every check. Defaults to the newest.

Response

resultobject

Answers keyed by correlation_id. Each has allowed, or an error if that one check failed; the others still answer.

One store answers about 3,600 checks a second through BatchCheck, against 500 to 700 sent one at a time. The SDKs split longer lists into batches of 50 for you.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/batch-check \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "checks": [
      {
        "tuple_key": { "user": "user:anne", "relation": "viewer", "object": "document:q3-plan" },
        "correlation_id": "0"
      },
      {
        "tuple_key": { "user": "user:anne", "relation": "viewer", "object": "document:payroll" },
        "correlation_id": "1"
      }
    ]
  }'
Response 200
{
  "result": {
    "0": { "allowed": true },
    "1": { "allowed": false }
  }
}

Queries

List objects

Lists every object of a type that a user has a relation to, such as every document Anne may view. Use it to narrow retrieval before searching.

POST/stores/{store_id}/list-objects

Path

store_idstringrequired

The store's id.

Body

typestringrequired

The type of object to list, such as document.

relationstringrequired

The relation the user must have, such as viewer.

userstringrequired

The user or agent, such as user:anne.

contextobject

Condition parameters for this request.

contextual_tuplesobject

{ "tuple_keys": [ ... ] } for this request only.

authorization_model_idstring

The model to use. Defaults to the newest.

Response

objectsstring[]

Matching objects, in no particular order. At most 1,000 by default; if the 3-second limit is reached first, the objects found so far.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/list-objects \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "document", "relation": "viewer", "user": "user:anne"}'
Response 200
{ "objects": ["document:q3-plan", "document:roadmap"] }

Queries

Streamed list objects

The same query as List objects, with results sent as newline-delimited JSON, one object per line, for clients built around OpenFGA's streaming endpoint.

POST/stores/{store_id}/streamed-list-objects

Body

The same fields as List objects: type, relation and user, plus optional context, contextual_tuples and authorization_model_id.

Response

One line per object, each {"result": {"object": "..."}}.

AgentFGA works out the full result, then sends it, so the first line arrives no sooner than a List objects response would. Prefer List objects unless your client already uses this endpoint.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/streamed-list-objects \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "document", "relation": "viewer", "user": "user:anne"}'
Response 200
{"result":{"object":"document:q3-plan"}}
{"result":{"object":"document:roadmap"}}

Queries

List users

Lists who has a relation to an object, such as every user, team or agent that may view the Q3 plan.

POST/stores/{store_id}/list-users

Path

store_idstringrequired

The store's id.

Body

objectobjectrequired

The object as { "type": "document", "id": "q3-plan" }.

relationstringrequired

The relation to list users for.

user_filtersobject[]required

One filter naming the kind of user to return: { "type": "user" }, or a userset such as { "type": "team", "relation": "member" }.

contextobject

Condition parameters. Every condition on the way must be able to evaluate, or the request fails.

contextual_tuplesobject[]

Tuples for this request only. Unlike the other queries, this is a plain list of tuples.

authorization_model_idstring

The model to use. Defaults to the newest.

Response

usersobject[]

Each is an object ({ "type", "id" }), a userset ({ "type", "id", "relation" }) or a wildcard ({ "type" }, meaning everyone of that type). At most 1,000 by default.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/list-users \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "object": { "type": "document", "id": "q3-plan" },
    "relation": "viewer",
    "user_filters": [{ "type": "user" }]
  }'
Response 200
{
  "users": [
    { "object": { "type": "user", "id": "anne" } },
    { "object": { "type": "user", "id": "beth" } }
  ]
}

Queries

Expand

Returns the tree of how a relation on an object is made up, one level deep. Useful for debugging a model or explaining why someone has access.

POST/stores/{store_id}/expand

Path

store_idstringrequired

The store's id.

Body

tuple_keyobjectrequired

{ "relation": "viewer", "object": "document:q3-plan" }.

contextual_tuplesobject

{ "tuple_keys": [ ... ] } for this request only.

authorization_model_idstring

The model to use. Defaults to the newest.

Response

treeobject

A root node. Each node is a leaf (users stored directly, a computed relation, or a relation through another object) or a union, intersection or difference of child nodes. Expand usersets in the leaves with further calls.

curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/expand \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tuple_key": {"relation": "owner", "object": "document:q3-plan"}}'
Response 200
{
  "tree": {
    "root": {
      "name": "document:q3-plan#owner",
      "leaf": {
        "users": { "users": ["user:anne"] }
      }
    }
  }
}

Assertions

Write assertions

Saves test cases for a model: checks and the answers you expect. Writing replaces all of the model's assertions.

PUT/stores/{store_id}/assertions/{authorization_model_id}

Path

store_idstringrequired

The store's id.

authorization_model_idstringrequired

The model the assertions are for.

Body

assertionsobject[]required

Up to 100 assertions.

Child attributes
tuple_keyobjectrequired

user, relation and object to check.

expectationbooleanrequired

The answer the check should give.

contextual_tuplesobject[]

Tuples to apply for this assertion.

contextobject

Condition parameters for this assertion.

Response

204 No Content, with an empty body.

curl -X PUT $FGA_API_URL/stores/$FGA_STORE_ID/assertions/01JC0A7N4Q2R3S5T6V7W8X9Y0Z \
  -H "Authorization: Bearer $FGA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "assertions": [
      {
        "tuple_key": { "user": "agent:support-bot", "relation": "can_use", "object": "tool:issue-refund" },
        "expectation": true
      }
    ]
  }'

Assertions

Read assertions

Returns the assertions saved for a model.

GET/stores/{store_id}/assertions/{authorization_model_id}

Path

store_idstringrequired

The store's id.

authorization_model_idstringrequired

The model the assertions are for.

Response

authorization_model_idstring

The model's id.

assertionsobject[]

The saved assertions, as written.

curl $FGA_API_URL/stores/$FGA_STORE_ID/assertions/01JC0A7N4Q2R3S5T6V7W8X9Y0Z \
  -H "Authorization: Bearer $FGA_API_TOKEN"
Response 200
{
  "authorization_model_id": "01JC0A7N4Q2R3S5T6V7W8X9Y0Z",
  "assertions": [
    {
      "tuple_key": { "user": "agent:support-bot", "relation": "can_use", "object": "tool:issue-refund" },
      "expectation": true
    }
  ]
}
Esc
↑ ↓ to moveEnter to open