Files
twenty/packages/twenty-docs
Abdul Rahman 9e3c3131f7 feat(run-agent): let apps run an agent on behalf of a workspace member (#23470)
## Why

`runAgent()` always runs with the agent's own role, so an app has no way
to scope a run to the person who triggered it. Chat-style apps (Slack,
Discord, Teams) need the opposite: the agent should never be able to do
more than the member who asked.

This is the server/SDK prerequisite for per-user permissions in the
Slack app (#22984). It is self-contained and reviewable without any
Slack context.

> **Scope note.** Review surfaced two authorization problems adjacent to
this code that are not part of the original feature — a cross-app agent
hole (#21157) and two fail-open branches in application-token auth. Both
are fixed here rather than deferred, since they sit directly on the path
this PR changes. They are called out separately below so they can be
reviewed on their own terms.

## The feature

- **`runAsWorkspaceMemberId` (optional) on `RunAgentInput`** — shared
type, DTO, and the generated GraphQL artifacts.
- **`AgentActorContextService.buildRunAsWorkspaceMemberContext`**
resolves member → userWorkspace → role and returns an actor context, a
*user* auth context, and the role id. Mirrors what
`WorkflowExecutionContextService` already does for acting on behalf of a
user.
- **`AgentRunService`** swaps the application auth context for the
member's, passes their actor context, and attributes AI credit usage to
them.
- **`buildAgentRolePermissionConfig`** (new util) returns
`intersectionOf: [agentRoleId, runAsRoleId]`, agent role first —
explicit object grants in `database-tool.provider` resolve against the
first role, so it defines which objects are in scope at all and later
roles only narrow permissions on them. Collapses to a single entry when
the member already holds the agent role, because the permission-flag
checks reject an intersection listing the same role twice.
- **`ToolContext`** gains an optional `rolePermissionConfig`. The lazy
tool path resolved permissions from a single `roleId`, so without this
the narrowing would not reach the tool catalog or call-time
`execute_tool` — and lazy is the strategy `runAgent` uses. Falls back to
the previous `unionOf: [roleId]` default when absent.
- Docs: a "Running on behalf of a workspace member" section in
`skills-and-agents.mdx`.

Omitting the field preserves today's behavior exactly, which is what
autonomous runs (scheduled jobs, database-event triggers) need. **Fails
closed:** an unresolvable member errors rather than falling back to the
agent role, which would grant more than the caller asked for.

## Who may name a member

`runAsWorkspaceMemberId` names another person, so it needs an
authorization rule of its own. An application token is not sufficient on
its own: `frontComponent(id)` is guarded by `UserAuthGuard,
NoPermissionGuard` and mints an `APPLICATION_ACCESS` token pair for the
requesting user, so any authenticated user can obtain one for an
installed app.

Those tokens record who they were minted for, and the caller cannot
strip that. The rule keys on that binding:

| Token | May name |
| --- | --- |
| No application token | nothing — rejected |
| Application token **with** a user binding | only that user's own
member |
| Application token with **no** user binding | any member |

The third row is unattended app code — a database-event-triggered logic
function is the Slack worker's path, and `client_credentials` or
API-key-minted tokens land here too.

## Adjacent fixes

**Cross-app agents** (pre-existing, #21157). The agent lookup was not
scoped to the caller, so any app token could run any agent in the
workspace, including one belonging to an app with wider permissions —
while `skills-and-agents.mdx` promised an app can only run its own. Now
rejected with `RUN_AGENT_NOT_ALLOWED`. Guarded on
`isDefined(callerApplication)`, so callers without an app token are
unaffected; `twenty-front` never calls `runAgent`.

**Two fail-open branches in `validateApplicationToken`.** Both populated
the auth context conditionally instead of failing closed, and both are
now asserted, making the application path structurally identical to
`validateAccessToken`:

1. An unresolvable user left the token presenting as *unbound*, so
removing someone from a workspace widened their live token instead of
revoking it, until it expired.
2. A missing workspace member let the token carry on with the
application's own permissions after that member was removed or
deactivated.

Both mirror `validateAccessToken`, down to its `PENDING_CREATION` /
`ONGOING_CREATION` escape hatch. **Behaviour change beyond this PR:** an
application token whose user has been removed now 401s where it
previously degraded to app-only. That is the point, and it matches
access-token semantics, but it is shared auth and worth a careful look.

## Known limitation

If the app's own agent role declares row-level predicates, those are not
applied in run-as mode, because the query builders resolve row-level
rules from a single role via the auth context. The member's own
row-level rules do apply, which is the direction that matters here.
Multi-role row-level support does not exist anywhere in the codebase
today.

## Tests

| Check | Result |
| --- | --- |
| ai-agent-execution, tool-provider, record-crud, user-workspace, full
auth tree | 79 suites, 666 passed |
| `nx typecheck twenty-server` | clean |
| oxlint + oxfmt on the changed server files | clean |

Both auth regression tests were verified against the pre-fix code — each
fails when the fix is reverted, so they guard the behaviour rather than
passing incidentally.

## Note for reviewers

Rebased onto `main`, then merged `main` in once more after #23395
landed. The `getObjectsPermissionsFromRolePermissionConfig` intersection
fix this PR originally carried has since landed on main independently,
and main's version is stricter — it denies when an intersected role is
missing from the cache rather than treating it as empty — so this PR
takes main's and no longer touches that file.

`RunAgentInput` now composes with the `prompt` | `messages` XOR from
#23395: `runAsWorkspaceMemberId` sits on the base object, so it is
available to both variants.
2026-08-07 14:08:39 +00:00
..
2026-07-21 17:39:39 +02:00
2026-08-06 17:20:43 +02:00
2026-07-28 16:37:49 +00:00

Twenty Documentation

Official documentation for Twenty CRM, powered by Mintlify.

🌐 Live Site

Visit the documentation at docs.twenty.com

📚 Content

This repository contains:

  • User Guide (46 pages) - Complete guide for Twenty users
  • Developers (24 pages) - Technical documentation for developers
  • Twenty UI (25 pages) - UI component library documentation

🚀 Local Development

To run the documentation locally:

# From the twenty monorepo root
npx nx run twenty-docs:dev

The documentation will be available at http://localhost:3000

📝 Editing Content

Adding/Editing Pages

  1. Edit MDX files in the appropriate directory:

    • user-guide/ - User documentation
    • developers/ - Developer documentation
    • twenty-ui/ - Component documentation
  2. Update navigation/base-structure.json if you need to change the tab/group hierarchy or add/remove pages. This file stays in the repo and is not uploaded to Crowdin.

  3. Keep the translation template (navigation/navigation.template.json) in sync by running yarn docs:generate-navigation-template after editing the base structure. This template is the only file that should be pushed to Crowdin.

  4. For each translated locale pulled from Crowdin, ensure a packages/twenty-docs/l/<language>/navigation.json file exists. These files contain labels only; page slugs always come from the base structure.

  5. Run yarn docs:generate to rebuild docs.json from the base structure + translated labels.

MDX Format

All documentation pages use MDX format with frontmatter:

---
title: Page Title
description: Page description
image: /images/path/to/image.png
---

Your content here...

Adding Images

  1. Place images in the /images/ directory
  2. Reference them in MDX: ![Alt text](/images/your-image.png)
  3. Or use Mintlify Frame component:
<Frame>
  <img src="/images/your-image.png" alt="Description" />
</Frame>

🔧 Configuration

  • navigation/base-structure.json - Source of truth for tabs, groups, icons, and page slugs (English only, not sent to Crowdin).
  • navigation/navigation.template.json - Generated translation template (labels only) that is uploaded to Crowdin.
  • l/<language>/navigation.json - Locale-specific label files pulled from Crowdin.
  • docs.json - Generated Mintlify configuration (always run yarn docs:generate after modifying navigation files).
  • package.json - Package dependencies and scripts (docs:generate, docs:generate-navigation-template, …).
  • project.json - Nx workspace configuration

📦 Validation

# Validate the documentation build
npx nx run twenty-docs:validate

🤝 Contributing

To contribute to the documentation:

  1. Fork the repository
  2. Make your changes in the packages/twenty-docs directory
  3. Test locally with npx nx run twenty-docs:dev
  4. Submit a pull request

📄 License

This documentation is part of the Twenty project and is licensed under AGPL-3.0.