diff --git a/docs/content/modeling/agents/task-based-authorization.mdx b/docs/content/modeling/agents/task-based-authorization.mdx
index c320755756..4e9d2cbfe4 100644
--- a/docs/content/modeling/agents/task-based-authorization.mdx
+++ b/docs/content/modeling/agents/task-based-authorization.mdx
@@ -11,6 +11,7 @@ import {
ProductName,
ProductNameFormat,
RelatedSection,
+ TupleViewer,
} from '@components/Docs';
# Modeling Task-Based Authorization for Agents
@@ -47,23 +48,13 @@ A `tool` represents a capability (e.g., `slack_send_message`), and a `tool_resou
For example, you can grant `task:1` access to send any Slack message, while restricting `task:2` to a specific channel:
-```yaml
-tuples:
- # Any task can list Slack channels
- - user: task:*
- relation: can_call
- object: tool:slack_list_channels
-
- # task:1 can send any Slack message
- - user: task:1
- relation: can_call
- object: tool:slack_send_message
-
- # task:2 can only send messages to channel XGA14FG
- - user: task:2
- relation: can_call
- object: tool_resource:slack_send_message/XGA14FG
-```
+
When checking whether `task:2` can call `tool_resource:slack_send_message/XGA14FG`, send a [contextual tuple](../../interacting/contextual-tuples.mdx) linking the resource to its tool. This avoids storing a tuple for every channel — you provide the tool-to-resource relationship at query time.
@@ -103,11 +94,11 @@ type organization
type project
relations
define organization: [organization]
-
+
# relations for users
define owner: [user]
define member: [user]
-
+
# relations for tasks
define read: [task]
define write: [task]
@@ -168,21 +159,13 @@ The `can_call` relation accepts three types of assignments:
Each task is linked to its session and agent when created:
-```yaml
-tuples:
- # Link task to its agent and session
- - user: task:1
- relation: task
- object: agent:1
- - user: task:1
- relation: task
- object: session:1
-
- # Grant session-level access
- - user: session:1#task
- relation: can_call
- object: tool:slack_send_message
-```
+
## Expiration and call count
@@ -209,27 +192,24 @@ condition max_call_count(max_tool_calls: int, current_tool_count: int) {
The `expiration` condition grants access for a fixed duration from the grant time. The `max_call_count` condition limits how many times the tool can be called. When writing the tuple, you provide the condition parameters:
-```yaml
-tuples:
- # task:1 can call the tool for 10 minutes
- - user: task:1
- relation: can_call
- object: tool:slack_send_message
- condition:
- name: expiration
- context:
- grant_time: "2026-03-22T00:00:00Z"
- grant_duration: 10m
-
- # task:2 can call the tool up to 2 times
- - user: task:2
- relation: can_call
- object: tool:slack_send_message
- condition:
- name: max_call_count
- context:
- max_tool_calls: 2
-```
+
When checking access, pass the current time or current call count in the request context.
@@ -255,15 +235,12 @@ type tool
The `can_call` relation requires both that the task has been granted access **and** that the agent making the call is linked to the task. When the task is created, link it to its agent:
-```yaml
-tuples:
- - user: task:1
- relation: task
- object: agent:1
- - user: task:1
- relation: can_call
- object: tool:slack_send_message
-```
+
At check time, send a contextual tuple identifying the calling agent. If the agent is linked to the task, the check returns `true`:
@@ -336,6 +313,6 @@ Mapping user intent to the right set of permissions is an active area of researc
title: 'Contextual Tuples',
description: 'Learn how to use contextual tuples to send dynamic context at query time',
link: '../../interacting/contextual-tuples',
- }
+ },
]}
/>
diff --git a/src/components/Docs/SnippetViewer/TupleViewer.tsx b/src/components/Docs/SnippetViewer/TupleViewer.tsx
new file mode 100644
index 0000000000..e5b9e1eba7
--- /dev/null
+++ b/src/components/Docs/SnippetViewer/TupleViewer.tsx
@@ -0,0 +1,220 @@
+/**
+ * TupleViewer — displays OpenFGA relationship tuples in a styled code block.
+ *
+ * - Renders tuples with column-aligned keys in italic muted color.
+ * - Exposes CSS hook classes so shared styles can control the block surface, keys, copy button, and responsive layout.
+ * - Copy button writes valid YAML list format to the clipboard.
+ * - Optional `rightColumnTuples` renders two columns via CSS grid with a mobile fallback.
+ */
+import React, { useMemo, useRef } from 'react';
+import { usePrismTheme } from '@docusaurus/theme-common';
+import { CodeBlockContextProvider, createCodeBlockMetadata } from '@docusaurus/theme-common/internal';
+import CopyButton from '@theme/CodeBlock/Buttons/CopyButton';
+import type { RelationshipCondition } from '../RelationshipTuples/Viewer';
+
+interface Tuple {
+ user: string;
+ relation: string;
+ object: string;
+ condition?: RelationshipCondition;
+}
+
+interface TupleViewerProps {
+ tuples: Tuple[];
+ rightColumnTuples?: Tuple[];
+}
+
+const PAD = 'condition'.length;
+const INNER_PAD = 'context'.length;
+const keyStyle: React.CSSProperties = { fontStyle: 'italic' };
+
+function Key({ name, pad }: { name: string; pad: number }): JSX.Element {
+ return (
+
+ {name.padEnd(pad)}
+
+ );
+}
+
+function formatDisplayValue(value: unknown): string {
+ return typeof value === 'string' ? value : JSON.stringify(value);
+}
+
+function formatYamlValue(value: unknown): string {
+ if (value === null) {
+ return 'null';
+ }
+
+ if (typeof value === 'string') {
+ return JSON.stringify(value);
+ }
+
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+
+ return JSON.stringify(value);
+}
+
+function getTupleKey(tuple: Tuple): string {
+ return JSON.stringify([tuple.user, tuple.relation, tuple.object, tuple.condition ?? null]);
+}
+
+function TupleBlock({ tuple }: { tuple: Tuple }): JSX.Element {
+ const contextEntries = Object.entries(tuple.condition?.context ?? {});
+
+ return (
+
+
+ : {tuple.user}
+
+
+ : {tuple.relation}
+
+
+ : {tuple.object}
+
+ {tuple.condition && (
+ <>
+
+ :
+
+
+
+ : {tuple.condition.name}
+
+ {contextEntries.length > 0 && (
+ <>
+
+ :
+
+
+ {contextEntries.map(([key, value]) => (
+
+ : {formatDisplayValue(value)}
+
+ ))}
+
+ >
+ )}
+
+ >
+ )}
+
+ );
+}
+
+function TupleList({ tuples }: { tuples: Tuple[] }): JSX.Element {
+ const seenTuples = new Map();
+
+ return (
+ <>
+ {tuples.map((tuple) => {
+ const baseKey = getTupleKey(tuple);
+ const count = seenTuples.get(baseKey) ?? 0;
+ seenTuples.set(baseKey, count + 1);
+ const tupleKey = count === 0 ? baseKey : `${baseKey}:${count}`;
+
+ return ;
+ })}
+ >
+ );
+}
+
+function formatYaml(tuple: Tuple): string {
+ const lines = [`- user: ${tuple.user}`, ` relation: ${tuple.relation}`, ` object: ${tuple.object}`];
+
+ if (tuple.condition) {
+ lines.push(` condition:`);
+ lines.push(` name: ${tuple.condition.name}`);
+
+ const contextEntries = Object.entries(tuple.condition.context ?? {});
+ if (contextEntries.length > 0) {
+ lines.push(` context:`);
+ }
+
+ for (const [key, value] of contextEntries) {
+ lines.push(` ${key}: ${formatYamlValue(value)}`);
+ }
+ }
+
+ return lines.join('\n');
+}
+
+function TupleViewerCopyButton({ text }: { text: string }): JSX.Element {
+ const codeBlockRef = useRef(null);
+ const metadata = useMemo(
+ () =>
+ createCodeBlockMetadata({
+ code: text,
+ className: 'language-yaml',
+ language: 'yaml',
+ defaultLanguage: undefined,
+ metastring: undefined,
+ magicComments: [],
+ title: null,
+ showLineNumbers: false,
+ }),
+ [text],
+ );
+
+ return (
+ undefined,
+ }}
+ >
+
+
+
+
+ );
+}
+
+export function TupleViewer({ tuples, rightColumnTuples }: TupleViewerProps): JSX.Element {
+ const { plain } = usePrismTheme();
+ const allTuples = rightColumnTuples ? [...tuples, ...rightColumnTuples] : tuples;
+ const yaml = allTuples.map(formatYaml).join('\n');
+
+ return (
+
+
+ {rightColumnTuples ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/Docs/SnippetViewer/index.ts b/src/components/Docs/SnippetViewer/index.ts
index 19efebe2d1..f21a41cd0e 100644
--- a/src/components/Docs/SnippetViewer/index.ts
+++ b/src/components/Docs/SnippetViewer/index.ts
@@ -11,3 +11,4 @@ export { SupportedLanguage, languageLabelMap } from './SupportedLanguage';
export * from './StreamedListObjectsRequestViewer';
export * from './WriteRequestViewer';
export * from './WriteAuthzModelViewer';
+export * from './TupleViewer';
diff --git a/src/css/custom.css b/src/css/custom.css
index d855cfbaac..bad775d014 100644
--- a/src/css/custom.css
+++ b/src/css/custom.css
@@ -35,6 +35,7 @@ html[data-theme='dark'] {
--ifm-background-color: #272b33;
--ifm-navbar-background-color: var(--ifm-background-color);
--ifm-footer-background-color: var(--ifm-background-color);
+ --tuple-viewer-content-background: rgb(19, 21, 25);
}
table td {
@@ -89,6 +90,76 @@ table td {
order: 2;
}
+.tuple-viewer {
+ background-color: rgb(40, 42, 54);
+ box-shadow: var(--ifm-global-shadow-lw);
+}
+.tuple-viewer__body {
+ background-color: var(--tuple-viewer-content-background, inherit);
+ border-radius: inherit;
+ white-space: pre;
+}
+
+.tuple-viewer__key {
+ color: rgba(248, 248, 242, 0.68);
+}
+
+.tuple-viewer__tuple-block + .tuple-viewer__tuple-block {
+ margin-top: 1.1rem;
+}
+
+.tuple-viewer__nested-block {
+ margin-top: 0.15rem;
+ margin-left: 2ch;
+ padding-left: 1.25ch;
+ border-left: 1px solid rgba(248, 248, 242, 0.1);
+}
+
+.tuple-viewer__context-block {
+ margin-top: 0.1rem;
+}
+
+.tuple-viewer__button-group {
+ display: flex;
+ column-gap: 0.2rem;
+ position: absolute;
+ top: calc(var(--ifm-pre-padding) / 2);
+ right: calc(var(--ifm-pre-padding) / 2);
+}
+
+.tuple-viewer__button-group button {
+ display: flex;
+ align-items: center;
+ background: rgb(40, 42, 54);
+ color: var(--prism-color);
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: var(--ifm-global-radius);
+ padding: 6.4px;
+ line-height: 0;
+ transition: opacity var(--ifm-transition-fast) ease-in-out;
+ opacity: 0;
+}
+
+.tuple-viewer__button-group button:focus-visible,
+.tuple-viewer__button-group button:hover {
+ opacity: 1 !important;
+}
+
+.tuple-viewer:hover .tuple-viewer__button-group button {
+ opacity: 0.4;
+}
+
+.tuple-viewer__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0 2.5ch;
+}
+
+.tuple-viewer__column--secondary {
+ padding-left: 2ch;
+ border-left: 1px solid rgba(248, 248, 242, 0.1);
+}
+
/* Mobile Layout */
@media (max-width: 996px) {
@@ -138,4 +209,16 @@ table td {
width: 100%;
min-width: 0;
}
+
+ .tuple-viewer__grid {
+ grid-template-columns: 1fr;
+ gap: 1em 0;
+ }
+
+ .tuple-viewer__column--secondary {
+ padding-left: 0;
+ border-left: 0;
+ padding-top: 1em;
+ border-top: 1px solid rgba(248, 248, 242, 0.1);
+ }
}