Skip to content
KONIGI

AI Assistants / Memory and context / Scoped context

5 of 5

Scoped context

The assistant sits inside a product full of data and the viewer cannot tell how much of it is in scope.

Updated September 12, 2026

Problem

An assistant inside a workspace answers a question about last quarter. Whether it read the one open document, the whole project, every file the viewer can access, or nothing at all is invisible, and each of those makes the answer mean something different.

Solution

Show the working set as a small, explicit list of what’s currently in scope, and let the viewer change it.

No other affordance in an in-product assistant repays its space as well, because scope is the one thing a viewer can’t infer and can’t test cheaply. PAIR’s argument about mental models applies with unusual force: people build a model of what a system can see from whatever evidence is available, and in the absence of evidence they invent one. The two inventions are symmetrical and both damaging. Assuming too much scope produces trust in an answer drawn from one file. Assuming too little means the viewer pastes in material the assistant already had.

Chips are the workable form. One per item, naming the document, project, or dataset in scope, each removable, with an obvious way to add more. Chips make scope composable rather than configured: the default is whatever context the viewer is sitting in, and adjusting it takes one click rather than a trip to settings. Where the product has a mention grammar, adding to scope by typing is faster still.

Permissions turn this from a clarity problem into a correctness problem. An assistant that can reach everything the viewer can reach is the only defensible default, and it means scope is per-person: the same question in the same document gives different answers to two colleagues. Both answers are right, and the difference between them is bewildering, so the interface has to show the boundary rather than leave someone concluding the assistant is unreliable.

HAX guideline 10 asks systems to scope their services when in doubt, and the conservative default is the narrow one. An assistant that starts with the open document and lets the viewer widen is easier to reason about than one that starts with everything and has to be reined in. It’s also cheaper, since scope and the context budget are the same resource seen from two directions.

Use when

The assistant lives inside a product with more material than fits in one window, and answers depend on which part was used.

Don’t use when

The assistant has exactly one possible scope and no way to change it. A chip showing the only document that could ever be in play is noise dressed as control.

Trade-offs

Explicit scope is comprehensible and makes the viewer responsible for a decision the product could have made. Broad default scope gives better answers and makes them unreproducible, since nobody can tell later what was read. Per-person permission scoping is correct and produces colleagues getting different answers to the same question, which reads as a bug. Scope chips occupy persistent space near the composer, competing with everything else that wants to live there.

Checklist

  • Can the viewer see what’s in scope without asking?
  • Can they add and remove items in one action?
  • Is the default the narrow scope or the broad one?
  • Is scope bounded by the viewer’s own permissions, and is that stated?
  • Does the answer say which of the in-scope items it actually used?
  • Does scope persist across turns, and across navigation?
  • How does scope interact with the context budget?
  • What happens when an in-scope document changes mid-conversation?
  • Is scope carried into a shared conversation, and should it be?
  • Can two colleagues tell why they got different answers?

Compare

Notion composes scope through @-mentions in the prompt, so the working set is typed into the question and visible in the text itself. Claude uses projects as the unit, where a deliberately assembled set of documents is the scope and the boundary is something the viewer built. Microsoft Copilot scopes to the tenant with permission trimming. It has the broadest reach of anything here, and the per-person boundary becomes the defining design problem. Cursor shows the working set as an explicit, editable list of files, treating scope as something to curate rather than infer and assuming a user who wants that job.

Assistant sidebar is where scope is usually displayed. Attachment tray is scope the viewer supplied from outside the product. Command menu is the fastest way to add to it. Source list says which in-scope material an answer actually used. Context meter is the same constraint measured in tokens.

Scoped context anatomy A working set shown as removable chips above the composer, bounded by the viewer's own permissions, with one item greyed as unreachable and a note that the same question gives two colleagues different answers. What it can see, as a list you can change seeing this page × Rivera project × + In scope Q3 forecast Rivera brief meeting notes, Aug 14 more pages Out of scope finance/, no permission the boundary is per person two colleagues ask the same question and get different answers, correctly 1 2 3 4 1 COMPOSABLE, NOT CONFIGURED The default is where the viewer is sitting. Changing it is a click, not a settings trip. 2 START NARROW Widening is easier to reason about than reining in, and it is cheaper. 3 BOUNDED BY PERMISSION The only defensible default, and it makes scope a property of the person asking. 4 TWO WRONG GUESSES Assuming too much trusts an answer from one file. Too little pastes in what it had. Broad scope gives better answers and makes them unreproducible, because nobody knows what was read.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

What the assistant can see, as a list you can change. The working set is bounded by the viewer's own permissions, so two colleagues asking the same question get different answers, correctly.

shadcn
npx shadcn@latest add badge button
Tokens
--card--card-foreground--muted-foreground--border--status-warn--chart-1
seeing
this page
Rivera project

In scope

  • Q3 forecast
  • Rivera brief
  • meeting notes, Aug
  • 14 more pages

Out of scope

  • finance/, no permission

ScopeChips.tsxChips for the working set, the pages they resolve to, and the paths the viewer's own permissions keep out. The denied list is a required prop.

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

/** One thing in the working set: a page, a project, a dataset. It carries
 *  the pages it resolves to, so the list below the chips is computed rather
 *  than asserted. */
export type ScopeItem = {
  id: string;
  label: string;
  pages: string[];
};

type Props = {
  scope: ScopeItem[];
  /** Which item is where the viewer is sitting. It is the default scope and
   *  the one chip that reads as the page itself. */
  here?: string;
  /** Paths inside the scope the viewer's own permissions exclude. Required,
   *  even when empty: scope is bounded by permission, and a component that
   *  cannot say so leaves two colleagues arguing over different answers. */
  denied: string[];
  onRemove: (id: string) => void;
  onAdd: () => void;
  /** How many in-scope pages to name before collapsing to a count. */
  listed?: number;
};

export function ScopeChips({ scope, here, denied, onRemove, onAdd, listed = 3 }: Props) {
  const pages = scope.flatMap((s) => s.pages);
  const rest = pages.length - listed;

  return (
    <div>
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-[11px] text-muted-foreground">seeing</span>
        {scope.map((s) => {
          const isHere = s.id === here;
          return (
            <Badge
              key={s.id}
              variant="outline"
              className={cn("gap-2 rounded-full py-1 font-normal", isHere && "border-chart-1 bg-chart-1/10 text-chart-1")}
              title={isHere ? s.pages[0] : `${s.pages.length} pages`}
            >
              {isHere ? "this page" : s.label}
              <button
                type="button"
                onClick={() => onRemove(s.id)}
                className={cn("leading-none", !isHere && "text-muted-foreground")}
                aria-label={`remove ${s.label} from scope`}
              >
                ×
              </button>
            </Badge>
          );
        })}
        {/* Widening is one click, here, rather than a trip to settings. */}
        <Button variant="outline" size="sm" className="h-7 rounded-full px-2.5 text-[11px] text-muted-foreground" onClick={onAdd} aria-label="add to scope">
          +
        </Button>
      </div>

      <div className="mt-4 grid grid-cols-2 gap-5">
        <div className="rounded-lg border border-dashed p-3">
          <p className="text-[11px] uppercase tracking-wide text-muted-foreground">In scope</p>
          <ul className="mt-2 text-[11px] text-card-foreground">
            {pages.slice(0, listed).map((p) => <li key={p} className="py-1">{p}</li>)}
            {rest > 0 && <li className="py-1 text-muted-foreground">{rest} more {rest === 1 ? "page" : "pages"}</li>}
          </ul>
        </div>
        <div className="rounded-lg border border-dashed p-3">
          <p className="text-[11px] uppercase tracking-wide text-muted-foreground">Out of scope</p>
          {denied.length ? (
            <ul className="mt-2 text-[11px] text-status-warn">
              {denied.map((d) => <li key={d} className="py-1">{d}, no permission</li>)}
            </ul>
          ) : (
            <p className="mt-2 text-[11px] text-muted-foreground">nothing you can open is excluded</p>
          )}
        </div>
      </div>
    </div>
  );
}

demo.tsxHow it is called: the open page plus a sixteen-page project, with one folder the viewer cannot read. Remove drops a chip; add appends one.

import { useState } from "react";
import { ScopeChips, type ScopeItem } from "./ScopeChips";

/**
 * The viewer is on the Q3 forecast page and has added the Rivera project,
 * sixteen pages of it. The finance folder is in the project, and this viewer
 * cannot read it.
 */
const START: ScopeItem[] = [
  { id: "page:q3-forecast", label: "Q3 forecast", pages: ["Q3 forecast"] },
  {
    id: "project:rivera",
    label: "Rivera project",
    pages: ["Rivera brief", "meeting notes, Aug", ...Array.from({ length: 14 }, (_, i) => `Rivera page ${i + 1}`)],
  },
];

export default function Demo() {
  const [scope, setScope] = useState(START);
  return (
    <div className="rounded-lg border bg-card p-4">
      <ScopeChips
        scope={scope}
        here="page:q3-forecast"
        denied={["finance/"]}
        onRemove={(id) => setScope(scope.filter((s) => s.id !== id))}
        onAdd={() => setScope([...scope, { id: "page:figures", label: "figures spreadsheet", pages: ["figures spreadsheet"] }])}
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.