# Agent Permissions

> How roles grant capabilities to humans and agents through one permission engine

Struere has one permission engine. It evaluates the same way for human users and for agents — both are actors. Roles define capabilities. Actors inherit them. Granting a role to a person and granting the same role to an agent are two paths to the same outcome: the actor runs under that role's policies, scope rules, field masks, and tool permissions.

This page explains the model end-to-end, including the field naming that often confuses people: `agentAccess` on a role and `roles` on an agent are unrelated and do different things.

## Roles Define Capabilities

A role is the unit of capability. Every role document declares what an actor holding it can do, and which rows and fields they can see.

| Field | Purpose |
|-------|---------|
| `policies` | Resource + action allow/deny rules. Deny overrides allow. |
| `scopeRules` | Row-level filters. Restrict which entities the actor can see. |
| `fieldMasks` | Column-level masks. Hide or redact specific fields. |
| `toolPermissions` | Which tools the actor is allowed to invoke. |
| `agentAccess` | Dashboard ACL. Which agents users with this role can chat with in the UI. |

The first four fields define what the actor can do at runtime. `agentAccess` is a UI-only ACL — see [What `agentAccess` is for](#what-agentaccess-is-for) below.

## Granting Roles

There are two ways a role becomes effective for an actor.

### Humans get roles via `userRoles`

When a user is invited to or assigned within an organization, they are linked to one or more roles in the `userRoles` table. This happens through the dashboard or the invite flow. Once assigned, every dashboard request the user makes builds an `ActorContext` populated with their role IDs.

### Agents get roles via `defineAgent({ roles: [...] })`

An agent declares its roles in code. The CLI syncs the declaration into `agentConfigs.roleSlugs`, which the runtime uses to populate the agent's `ActorContext` on every chat request.

```typescript
import { defineAgent } from 'struere'

export default defineAgent({
  name: "Coach Stats",
  slug: "coach-stats",
  version: "0.1.0",
  systemPrompt: "You report stats for Team A players.",
  model: { model: "openai/gpt-5-mini" },
  tools: ["entity.query"],
  roles: ["team-a-coach"],
})
```

The same `team-a-coach` role document defines what `coach-stats` (the agent) can do at runtime. If a human user is also assigned `team-a-coach` via `userRoles`, they get the same capabilities when querying through the dashboard.

## Runtime Evaluation

When a permission-checked operation runs — a tool call like `entity.query`, an `entity.update`, or a dashboard query — the engine builds an `ActorContext` from the actor's roles and applies the same four-stage pipeline regardless of actor type:

```
Build ActorContext (organizationId, actorType, actorId, roleIds, environment)
    │
    ▼
Policies      canPerform / assertCanPerform — deny overrides allow
    │
    ▼
Scope rules   restrict which rows are returned
    │
    ▼
Field masks   hide or redact specific fields on returned rows
    │
    ▼
Tool          canUseTool — restrict which tools the actor may invoke
```

The pipeline is identical for `actorType: "user"` and `actorType: "agent"`. The role definition is the source of truth in both cases.

For the full pipeline reference, see [Permissions](./permissions).

## Default Behavior

If an agent has no `roles` declared in `defineAgent`, the runtime falls back to a singleton role named `agent`. This is the legacy zero-config behavior — existing agents continue to work without modification.

The fallback `agent` role is created automatically and grants the broad permissions previously associated with agent execution. To narrow an agent's permissions, declare explicit roles in `defineAgent({ roles: [...] })`. As soon as `roles` is non-empty, the agent uses the union of those roles instead of the fallback.

## Worked Example

A team of coaches each see only their own team's players. The `coach-stats` agent reports stats for Team A and inherits the same scoping. A second `league-stats` agent reports across all teams and uses no scoping.

### Define the role

```typescript
import { defineRole } from 'struere'

export default defineRole({
  name: "team-a-coach",
  policies: [
    { resource: "player", actions: ["list", "read"], effect: "allow" },
  ],
  scopeRules: [
    { entityType: "player", field: "data.teamId", operator: "eq", value: "team-A" },
  ],
})
```

### Define the scoped agent

```typescript
import { defineAgent } from 'struere'

export default defineAgent({
  name: "Coach Stats",
  slug: "coach-stats",
  version: "0.1.0",
  systemPrompt: "List players on your team.",
  model: { model: "openai/gpt-5-mini" },
  tools: ["entity.query"],
  roles: ["team-a-coach"],
})
```

When `coach-stats` calls `entity.query` for the `player` data type, the scope rule on `team-a-coach` is applied. Only players where `data.teamId` equals `"team-A"` are returned. Team B players are invisible to this agent — not filtered post-hoc, but excluded at the query layer.

### Define an unscoped agent for comparison

```typescript
import { defineAgent } from 'struere'

export default defineAgent({
  name: "League Stats",
  slug: "league-stats",
  version: "0.1.0",
  systemPrompt: "Report player stats across the entire league.",
  model: { model: "openai/gpt-5-mini" },
  tools: ["entity.query"],
  roles: ["league-analyst"],
})
```

Where `league-analyst` has a `player` allow policy and no scope rules. The same `entity.query` call from `league-stats` returns every player in the org.

The difference is entirely in the role assignments — the tool, the data type, and the agent code are otherwise structured the same way.

## What `agentAccess` Is For

`agentAccess` on a role is a dashboard ACL. It controls which agents users holding the role can open and chat with in the dashboard UI. It does not affect the agent's runtime permissions in any way.

```typescript
defineRole({
  name: "support-staff",
  policies: [
    { resource: "ticket", actions: ["read", "update"], effect: "allow" },
  ],
  agentAccess: ["support-agent", "billing-agent"],
})
```

A user assigned `support-staff` can open `support-agent` and `billing-agent` from the dashboard's chat surfaces. The `support-agent` itself runs under whatever roles its own `defineAgent({ roles: [...] })` declares. Listing an agent in `agentAccess` does not grant that agent any policies, scope rules, or field masks.

The two relationships answer different questions:

| Question | Field |
|----------|-------|
| Which humans can chat with this agent in the UI? | The role's `agentAccess` |
| What can this agent do when it executes a tool call? | The agent's `roles` |

You will often want both. A `coach` role can list `coach-stats` in `agentAccess` (so coaches can open the chat) while the `coach-stats` agent declares `roles: ["coach"]` (so it inherits the same capabilities the coach has when querying directly). Same role document, two relationships, one mental model: **roles are the unit of capability; both humans and agents inherit them**.
