Skip to content
Star on GitHub
Review and approvePermission gate
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Permission gate

The agent asks before it touches something new. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add permission-gate
Allow the agent to edit Figma?It needs this to apply the pricing fixes
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add permission-gate

React

$npx designme add permission-gate --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Permission gate is the card an agent shows when it wants to do something it has not been allowed to do yet, like editing Figma frames. The person ticks which scopes to grant, picks how long the permission lasts, and allows or declines, and they can revoke or undo that choice afterwards.

  • When the agent is about to use a new tool or touch a new part of a workspace for the first time.
  • When a request bundles several powers and people should be able to grant only some of them.
  • When the agent is already running and reaches something it needs one more scope for, so it asks again for just that scope.
  • When one of the scopes is risky, like publishing a shared library, and you want it flagged.
  • When a permission should last only for this task or for today.
03

Usage

Import it and drive it from your own state.

Import

import { PermissionGate } from '@/components/designme/permission-gate/PermissionGate';

Example

The demo above as a React component, 78 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { PermissionGate, type PermissionScope, type PermissionStatus } from './PermissionGate';
import { Icon } from '../lib/icons';
import { useStory, useWait } from '../lib/useScript';

/* ---------- Permission gate: hold back the risky scope, allow the rest, and grant it when the agent needs it for one frame ---------- */
const FIRST: PermissionScope[] = [
  { key: 'read', label: 'Read frames', detail: 'Pricing, Checkout' },
  { key: 'edit', label: 'Edit frames', detail: 'Only on a new page' },
  { key: 'publish', label: 'Publish library', detail: 'Affects 4 other files', risk: 'Risky', riskDetail: 'Checkout, Marketing site, Email kit and Docs use this library' }
];
const MORE: PermissionScope[] = [{ key: 'publish', label: 'Publish library', detail: 'Updates Checkout, Marketing site, Email kit and Docs', risk: 'Risky' }];
// the frames the agent edits once it may. Plan toggle is a library component, so editing it needs Publish library
const FRAMES = ['Hero', 'Plan cards', 'Compare table', 'FAQ', 'Plan toggle', 'Checkout summary'], LIB = 4;
const clock = (ms: number) => { const s = Math.floor(ms / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };

export default function PermissionGateDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const [more, setMore] = useState(false);
  const [value, setValue] = useState(['read', 'edit']);
  const [duration, setDuration] = useState('This task');
  const [status, setStatus] = useState<PermissionStatus>('asking');
  const [working, setWorking] = useState(false);
  const [title, setTitle] = useState<string>();
  const [detail, setDetail] = useState<string>();
  const run = useRef({ granted: 0, publish: false, skip: false, done: 0, id: 0 });
  // the clock counts the agent's working time only, not the time it waits for you
  const [ms, setMs] = useState(0);
  useEffect(() => {
    if (!working) return;
    const from = performance.now() - ms, t = setInterval(() => setMs(performance.now() - from), 250);
    return () => clearInterval(t);
  }, [working]); // eslint-disable-line react-hooks/exhaustive-deps

  // the agent works through the frames under what it was given, and stops to ask when it reaches one it may not touch
  const work = async () => {
    const r = run.current, id = ++r.id;
    setStatus('allowed'); setWorking(true); setTitle(undefined);
    while (r.done < FRAMES.length) {
      if (r.done === LIB && !r.publish && !r.skip) { setWorking(false); setMore(true); setValue(['publish']); setStatus('asking'); return; }
      setDetail(r.done === LIB && r.skip ? `Skipping ${FRAMES[r.done]}` : `Editing ${FRAMES[r.done]} · ${r.done + 1} of ${FRAMES.length}`);
      await wait(900); if (r.id !== id) return;
      r.done++;
    }
    setWorking(false);
    setTitle(`Done. ${r.skip ? FRAMES.length - 1 : FRAMES.length} frames edited`);
    setDetail(r.skip ? 'On a new page. Plan toggle skipped' : 'On a new page. Plan toggle published');
  };

  // the preview's story: the person ticks the risky scope, reads what it touches, leaves it off, allows, then grants it for one frame
  useStory(ref, async ({ wait, press }) => {
    await wait(1500); setValue(['read', 'edit', 'publish']);
    await wait(2500); setValue(['read', 'edit']);
    await wait(1000); press('.dm-pgx-ok');
    await wait(500 + 4 * 900 + 2000); press('.dm-pgx-ok');
  });

  const r = run.current;
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <PermissionGate bare={false} icon={<Icon name="figma" />} durationDetails={{ 'This task': 'Until the pricing fixes are done', Today: 'Until midnight', Always: 'Until you revoke it' }}
        title={more ? 'Also allow Publish library?' : 'Allow the agent to edit Figma?'}
        subtitle={more ? `${FRAMES[LIB]}, frame ${LIB + 1} of ${FRAMES.length}, is a library component` : 'It needs this to apply the pricing fixes'}
        scopes={more ? MORE : FIRST} value={value} onValueChange={setValue} duration={duration} onDurationChange={setDuration}
        status={status} onStatusChange={setStatus} followUp={more} declineLabel={more ? 'Skip this frame' : 'Not now'}
        onAllow={async keys => { r.granted += keys.length; if (keys.includes('publish')) r.publish = true; await wait(500); setMore(false); void work(); }}
        onDecline={() => { if (!more) return; r.skip = true; setMore(false); void work(); }}
        onRevoke={() => { run.current = { granted: 0, publish: false, skip: false, done: 0, id: r.id + 1 }; setValue(['read', 'edit']); setMs(0); setWorking(false); }}
        working={working} meta={clock(ms)} allowedDetail={detail}
        allowedTitle={title ?? `Allowed ${r.granted} ${r.granted > 1 ? 'scopes' : 'scope'} ${duration === 'Always' ? 'always' : 'for ' + duration.toLowerCase()}`}
        declinedTitle="The agent will skip the Figma edits" declinedDetail="It will list the fixes in chat instead" />
    </div>
  );
}
04

Props

Everything PermissionGate takes.

PropTypeWhat it does
titlerequiredReactNodeThe question, like "Allow the agent to edit Figma?"
subtitleReactNodeWhy it asks
iconReactNodeShown in the tile beside the question. Defaults to a shield
scopesrequiredPermissionScope[]
valuestring[]Keys of the ticked scopes. Leave it out to let the checkboxes hold their own
defaultValuestring[]
onValueChange(keys: string[]) => void
durationsstring[]How long the permission lasts, picked from a small dropdown in the footer. Defaults to This task, Today and Always
durationDetailsRecord<string, string>The quiet line under each duration in its menu, by duration, like { Today: 'Until midnight' }
durationstring
defaultDurationstring
onDurationChange(duration: string) => void
statusPermissionStatusasking, allowed or declined. Leave it out to let the buttons move it
defaultStatusPermissionStatus
onStatusChange(status: PermissionStatus) => void
onAllow(keys: string[], duration: string) => void | Promise<void>Called with the ticked scopes and the duration. Return a promise and the button spins until it settles
onDecline() => void
onRevoke() => voidCalled by Revoke, after allowing
onUndo() => voidCalled by Undo, after declining
allowedDetailstringThe line under "Allowed …", like "Applying the pricing fixes now". Crossfades when it changes
allowedTitlestringReplaces "Allowed 2 scopes for this task", like "Done. 6 frames edited". Crossfades when it changes
workingbooleanThe agent is still working under the permission: a spinner on a neutral row instead of the green check
metaReactNodeQuiet text at the end of the allowed row, like an elapsed time "0:04"
followUpbooleanA follow-up ask in the middle of a run, for one more scope: the icon tile turns amber
declineLabelstringThe decline button. Defaults to "Not now"; a follow-up ask might say "Skip this frame"
declinedTitleReactNodeShown after Not now
declinedDetailReactNode
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

PermissionGate.tsxReact · 177 lines
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './permission-gate.css';

/** Something the agent is asking to be allowed to do */
export interface PermissionScope {
  key: string;
  /** Like "Edit frames" */
  label: string;
  /** The quiet line under it, like "Only on a new page" */
  detail?: string;
  /** A warning tag after it, like "Risky" */
  risk?: string;
  /** What the risk tag reveals on hover, like "Checkout, Marketing site, Email kit and Docs use this library" */
  riskDetail?: string;
}

export type PermissionStatus = 'asking' | 'allowed' | 'declined';

export interface PermissionGateProps {
  /** The question, like "Allow the agent to edit Figma?" */
  title: ReactNode;
  /** Why it asks */
  subtitle?: ReactNode;
  /** Shown in the tile beside the question. Defaults to a shield */
  icon?: ReactNode;
  scopes: PermissionScope[];
  /** Keys of the ticked scopes. Leave it out to let the checkboxes hold their own */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (keys: string[]) => void;
  /** How long the permission lasts, picked from a small dropdown in the footer. Defaults to This task, Today and Always */
  durations?: string[];
  /** The quiet line under each duration in its menu, by duration, like { Today: 'Until midnight' } */
  durationDetails?: Record<string, string>;
  duration?: string;
  defaultDuration?: string;
  onDurationChange?: (duration: string) => void;
  /** asking, allowed or declined. Leave it out to let the buttons move it */
  status?: PermissionStatus;
  defaultStatus?: PermissionStatus;
  onStatusChange?: (status: PermissionStatus) => void;
  /** Called with the ticked scopes and the duration. Return a promise and the button spins until it settles */
  onAllow?: (keys: string[], duration: string) => void | Promise<void>;
  onDecline?: () => void;
  /** Called by Revoke, after allowing */
  onRevoke?: () => void;
  /** Called by Undo, after declining */
  onUndo?: () => void;
  /** The line under "Allowed …", like "Applying the pricing fixes now". Crossfades when it changes */
  allowedDetail?: string;
  /** Replaces "Allowed 2 scopes for this task", like "Done. 6 frames edited". Crossfades when it changes */
  allowedTitle?: string;
  /** The agent is still working under the permission: a spinner on a neutral row instead of the green check */
  working?: boolean;
  /** Quiet text at the end of the allowed row, like an elapsed time "0:04" */
  meta?: ReactNode;
  /** A follow-up ask in the middle of a run, for one more scope: the icon tile turns amber */
  followUp?: boolean;
  /** The decline button. Defaults to "Not now"; a follow-up ask might say "Skip this frame" */
  declineLabel?: string;
  /** Shown after Not now */
  declinedTitle?: ReactNode;
  declinedDetail?: ReactNode;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

const DURATIONS = ['This task', 'Today', 'Always'];
const DETAILS: Record<string, string> = { 'This task': 'Until the task is done', Today: 'Until midnight', Always: 'Until you revoke it' };
// "For this task" in the menu and "for this task" after Allowed; Always reads as it is
const durLabel = (d: string) => /^always$/i.test(d) ? d : 'For ' + d.toLowerCase();
const durSpan = (d: string) => /^always$/i.test(d) ? 'always' : 'for ' + d.toLowerCase();

/** The agent asks before it acts: pick the scopes and how long they last, then allow or decline. */
export function PermissionGate({
  title, subtitle, icon, scopes, value, defaultValue = [], onValueChange, durations = DURATIONS, durationDetails = DETAILS, duration, defaultDuration, onDurationChange,
  status, defaultStatus = 'asking', onStatusChange, onAllow, onDecline, onRevoke, onUndo, allowedDetail, allowedTitle, working = false, meta, followUp = false,
  declineLabel = 'Not now', declinedTitle = 'The agent will skip these changes', declinedDetail, bare = true, className
}: PermissionGateProps) {
  const [ticked, setTicked] = useControllable(value, defaultValue, onValueChange);
  const [dur, setDur] = useControllable(duration, defaultDuration ?? durations[0], onDurationChange);
  const durMenu = usePopover();
  const [state, setState] = useControllable(status, defaultStatus, onStatusChange);
  const [waiting, setWaiting] = useState(false);
  // a revoked permission fades out before the scopes come back
  const [leaving, setLeaving] = useState(false);
  // the scopes play their entrance when they come back, not when the card first appears
  const [back, setBack] = useState(0);
  const [granted, setGranted] = useState(0);
  // the scopes give way to a shorter result row: the gate keeps the height it had and centres the row in it, until Revoke or Undo
  const root = useRef<HTMLDivElement>(null);
  const [held, setHeld] = useState<number>();
  const hold = () => setHeld(h => h ?? root.current?.offsetHeight);
  const n = ticked.length;

  useEffect(() => {
    if (!leaving) return;
    const t = setTimeout(() => { setLeaving(false); setHeld(undefined); setState('asking'); setBack(b => b + 1); onRevoke?.(); }, 300);
    return () => clearTimeout(t);
  }, [leaving]); // eslint-disable-line react-hooks/exhaustive-deps

  const toggle = (key: string) => setTicked(ticked.includes(key) ? ticked.filter(k => k !== key) : scopes.map(s => s.key).filter(k => k === key || ticked.includes(k)));
  const allow = async () => {
    if (!n || waiting) return;
    setWaiting(true); setGranted(n);
    try { await onAllow?.(ticked, dur); } finally { setWaiting(false); }
    hold(); setState('allowed');
  };
  const decline = () => { hold(); setState('declined'); onDecline?.(); };
  const undo = () => { setHeld(undefined); setState('asking'); setBack(b => b + 1); onUndo?.(); };
  const again = (i: number) => (back ? { className: 'dm-enter', style: enter(i * 50).style, key: back } : { className: undefined, style: undefined, key: 0 });

  return (
    <div data-dm="" ref={root} className={cx('dm-pgx', !bare && 'dm-panel', followUp && state === 'asking' && 'dm-esc', held && 'dm-held', className)} style={held ? { minHeight: held } : undefined}>
      <div className="dm-pgx-h">
        <span className="dm-pgx-ic">{icon ?? <Icon name="shield" />}</span>
        <div className="dm-pgx-ht"><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
      </div>
      {state === 'asking' && (
        <>
          <div key={'sc' + again(0).key} className={cx('dm-pgx-sc', again(0).className)} style={again(0).style}>
            {scopes.map(s => (
              <label key={s.key} className="dm-pgx-o">
                <input type="checkbox" checked={ticked.includes(s.key)} onChange={() => toggle(s.key)} />
                <span className="dm-bx"><Icon name="check" size="xs" /></span>
                <span className="dm-t">{s.label}{s.detail && <span className="dm-d">{s.detail}</span>}</span>
                {s.risk && (s.riskDetail ? <Tip label={s.riskDetail}><span className="dm-tag dm-warn">{s.risk}</span></Tip> : <span className="dm-tag dm-warn">{s.risk}</span>)}
              </label>
            ))}
          </div>
          <div key={'f' + again(1).key} className={cx('dm-pgx-f', again(1).className)} style={again(1).style}>
            <button {...durMenu.trigger} type="button" className="dm-pgx-db" aria-label="How long"><Swap className="dm-pgx-dv">{durLabel(dur)}</Swap><Icon name="chev-d" size="xs" /></button>
            <span className="dm-sp" />
            <button type="button" className="dm-pill dm-ghost dm-sm dm-pgx-no" onClick={decline}>{declineLabel}</button>
            <button type="button" className="dm-pill dm-primary dm-sm dm-pgx-ok" disabled={!n} onClick={allow}>
              {waiting ? <Spin /> : <Swap>{n ? `Allow ${n} ${n > 1 ? 'scopes' : 'scope'}` : 'Pick a scope'}</Swap>}
            </button>
          </div>
        </>
      )}
      <div {...durMenu.pop} className={cx(durMenu.pop.className, 'dm-pgx-pop')} role="menu" aria-label="How long">
        {durations.map(d => (
          <button key={d} type="button" className="dm-mi" role="menuitemradio" aria-checked={d === dur} onClick={() => { setDur(d); durMenu.closeSoon(); }}>
            <span>{durLabel(d)}{durationDetails[d] && <span className="dm-d">{durationDetails[d]}</span>}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
          </button>
        ))}
      </div>
      {state === 'allowed' && (
        <div className={cx('dm-pgx-done', working ? 'dm-run' : 'dm-ok', leaving ? 'dm-leave' : 'dm-enter')}>
          <span className="dm-g">{working ? <Spin /> : <Icon name="ok" />}</span>
          <span className="dm-t">
            <b className="dm-pgx-a"><Swap>{allowedTitle ?? `Allowed ${granted || n} ${(granted || n) > 1 ? 'scopes' : 'scope'} ${durSpan(dur)}`}</Swap></b>
            {allowedDetail !== undefined && <Swap>{allowedDetail}</Swap>}
          </span>
          {meta !== undefined && <span className="dm-pgx-tm dm-num">{meta}</span>}
          <button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => setLeaving(true)}>Revoke</button>
        </div>
      )}
      {state === 'declined' && (
        <div className="dm-pgx-done dm-enter" style={{ background: 'var(--dm-chip)' }}>
          <Icon name="info" />
          <span className="dm-t">{declinedTitle}{declinedDetail && <span>{declinedDetail}</span>}</span>
          <button type="button" className="dm-pill dm-ghost dm-sm" onClick={undo}>Undo</button>
        </div>
      )}
    </div>
  );
}
permission-gate.cssReact · 52 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Permission gate ---------- */
/* the agent asks in the chat as one card: the question, the scopes to tick, and a footer with how long and the two
   answers. Without .panel it sits flat in the thread */
.dm-pgx{position:relative;width:100%;max-width:400px;margin:0 auto;padding:var(--dm-s-1) 0;display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-pgx.dm-panel{padding:var(--dm-s-4)}
.dm-pgx-h{display:flex;gap:var(--dm-s-3);align-items:center}
.dm-pgx-ic{width:32px;height:32px;border-radius:9px;background:var(--dm-chip);display:grid;place-items:center;flex:none}
.dm-pgx-ht{display:flex;flex-direction:column;min-width:0}
.dm-pgx-ht b{font-weight:500;font-size:14px}
.dm-pgx-ht span{font-size:13px}
.dm-pgx-sc{display:flex;flex-direction:column;margin-inline:-8px}
.dm-pgx-o{display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-2);border-radius:10px;cursor:pointer;font-size:13.5px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.dm-pgx-o:hover{background:var(--dm-row)}}
.dm-pgx-o input{position:absolute;opacity:0;pointer-events:none}
.dm-pgx-o .dm-bx{width:18px;height:18px;border-radius:6px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-muted) 80%,var(--dm-surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .3s var(--dm-ease),box-shadow .3s,color .3s}
.dm-pgx-o .dm-bx svg{transform:scale(.4);transition:transform .35s var(--dm-ease)}
.dm-pgx-o input:checked+.dm-bx{background:var(--dm-accent);box-shadow:inset 0 0 0 1px var(--dm-accent-mark,var(--dm-accent));color:var(--dm-on-accent)}
.dm-pgx-o input:checked+.dm-bx svg{transform:none}
.dm-pgx-o input:focus-visible+.dm-bx{outline:2px solid var(--dm-focus);outline-offset:2px}
.dm-pgx-o .dm-t{flex:1;min-width:0}
.dm-pgx-o .dm-d{display:block;font-size:12px;color:var(--dm-muted)}
.dm-pgx-f{display:flex;align-items:center;gap:6px;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-pgx-f .dm-sp{flex:1}
/* on a narrow stage the duration gets its own row, and the two buttons stay together under it on the right */
@container (max-width:340px){.dm-pgx-f{flex-wrap:wrap}.dm-pgx-f .dm-sp{flex-basis:100%}.dm-pgx-no{margin:var(--dm-s-2) 0 0 auto}.dm-pgx-ok{margin-top:var(--dm-s-2)}}
/* how long: a small dropdown, the way a menu of choices usually looks */
.dm-pgx-db{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--dm-s-2) 0 10px;margin-left:-2px;border-radius:var(--dm-r-pill);font-size:13px;color:var(--dm-fg2);white-space:nowrap;transition:background .16s,color .16s}
.dm-pgx-db[aria-expanded="true"]{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-pgx-db:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-pgx-db>svg{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-pgx-db[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-pgx-pop{min-width:230px}
.dm-pgx-ok[disabled]{opacity:1;cursor:not-allowed;background:var(--dm-dis-hatch),var(--dm-dis-fill);color:var(--dm-dis);box-shadow:var(--dm-soft-ring)}
.dm-pgx-done{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-ok-bg);font-size:13.5px}
/* its button is a white pill on the tinted row */
.dm-pgx-done .dm-pill{background:var(--dm-surface);color:var(--dm-fg);box-shadow:var(--dm-shadow)}
@media (hover:hover) and (pointer:fine){.dm-pgx-done .dm-pill:hover{background:var(--dm-surface);box-shadow:var(--dm-shadow-pop)}}
.dm-pgx-done .dm-t{flex:1;color:var(--dm-fg)}
.dm-pgx-done .dm-t span{display:block;font-size:12px;color:var(--dm-fg2)}
.dm-pgx-done>svg{color:var(--dm-ok)}
/* while the agent works under the permission: a neutral row with a spinner, a live line and a clock; green once done */
.dm-pgx-done.dm-run{background:var(--dm-chip)}
.dm-pgx-done .dm-g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--dm-fg2)}
.dm-pgx-done.dm-ok .dm-g{color:var(--dm-ok)}
.dm-pgx-done .dm-t b{display:block;font-weight:400}
.dm-pgx-tm{font-size:12px;color:var(--dm-muted)}
/* asking again mid-run, for one more scope: the tile turns amber */
.dm-pgx-ic{transition:background .32s var(--dm-ease)}
.dm-pgx.dm-esc .dm-pgx-ic{background:var(--dm-warn-bg)}
.dm-pgx-scope{display:flex;flex-direction:column;gap:var(--dm-s-3)}
permission-gate.htmlHTML · 19 lines
<div class="card bare" data-c="permission-gate">
  <div class="stage-in">
    <div class="pgx panel">
      <div class="pgx-h"><span class="pgx-ic"><svg class="i s"><use href="#i-figma"/></svg></span><div class="pgx-ht"><b>Allow the agent to edit Figma?</b><span class="muted">It needs this to apply the pricing fixes</span></div></div>
      <div class="pgx-sc">
        <label class="pgx-o" data-k="read"><input type="checkbox" checked><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Read frames<span class="d">Pricing, Checkout</span></span></label>
        <label class="pgx-o" data-k="edit"><input type="checkbox" checked><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Edit frames<span class="d">Only on a new page</span></span></label>
        <label class="pgx-o" data-k="publish"><input type="checkbox"><span class="bx"><svg class="i xs"><use href="#i-check"/></svg></span><span class="t">Publish library<span class="d">Affects 4 other files</span></span><span class="tag warn" data-tip="Checkout, Marketing site, Email kit and Docs use this library">Risky</span></label>
      </div>
      <div class="pgx-f"><button class="pgx-db" aria-haspopup="menu" aria-expanded="false" aria-label="How long" data-pop=".pgx-pop"><span class="pgx-dv">For this task</span><svg class="i xs"><use href="#i-chev-d"/></svg></button><span class="sp"></span><button class="pill ghost sm pgx-no">Not now</button><button class="pill primary sm pgx-ok">Allow 2 scopes</button></div>
      <div class="pop pgx-pop" role="menu" aria-label="How long">
        <button class="mi" role="menuitemradio" aria-checked="true" data-close data-v="This task"><span>For this task<span class="d">Until the pricing fixes are done</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="Today"><span>For today<span class="d">Until midnight</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
        <button class="mi" role="menuitemradio" aria-checked="false" data-close data-v="Always"><span>Always<span class="d">Until you revoke it</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button>
      </div>
    </div>
  </div>
</div>
permission-gate.cssHTML · 51 lines
/* ---------- Permission gate ---------- */
/* the agent asks in the chat as one card: the question, the scopes to tick, and a footer with how long and the two
   answers. Without .panel it sits flat in the thread */
.pgx{position:relative;width:100%;max-width:400px;margin:0 auto;padding:var(--s-1) 0;display:flex;flex-direction:column;gap:var(--s-3)}
.pgx.panel{padding:var(--s-4)}
.pgx-h{display:flex;gap:var(--s-3);align-items:center}
.pgx-ic{width:32px;height:32px;border-radius:9px;background:var(--chip);display:grid;place-items:center;flex:none}
.pgx-ht{display:flex;flex-direction:column;min-width:0}
.pgx-ht b{font-weight:500;font-size:14px}
.pgx-ht span{font-size:13px}
.pgx-sc{display:flex;flex-direction:column;margin-inline:-8px}
.pgx-o{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-2);border-radius:10px;cursor:pointer;font-size:13.5px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.pgx-o:hover{background:var(--row)}}
.pgx-o input{position:absolute;opacity:0;pointer-events:none}
.pgx-o .bx{width:18px;height:18px;border-radius:6px;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--muted) 80%,var(--surface));display:grid;place-items:center;color:transparent;flex:none;transition:background .3s var(--ease),box-shadow .3s,color .3s}
.pgx-o .bx svg{transform:scale(.4);transition:transform .35s var(--ease)}
.pgx-o input:checked+.bx{background:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-mark,var(--accent));color:var(--on-accent)}
.pgx-o input:checked+.bx svg{transform:none}
.pgx-o input:focus-visible+.bx{outline:2px solid var(--focus);outline-offset:2px}
.pgx-o .t{flex:1;min-width:0}
.pgx-o .d{display:block;font-size:12px;color:var(--muted)}
.pgx-f{display:flex;align-items:center;gap:6px;padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.pgx-f .sp{flex:1}
/* on a narrow stage the duration gets its own row, and the two buttons stay together under it on the right */
@container (max-width:340px){.pgx-f{flex-wrap:wrap}.pgx-f .sp{flex-basis:100%}.pgx-no{margin:var(--s-2) 0 0 auto}.pgx-ok{margin-top:var(--s-2)}}
/* how long: a small dropdown, the way a menu of choices usually looks */
.pgx-db{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--s-2) 0 10px;margin-left:-2px;border-radius:var(--r-pill);font-size:13px;color:var(--fg2);white-space:nowrap;transition:background .16s,color .16s}
.pgx-db[aria-expanded="true"]{background:var(--row);color:var(--fg)}
@media (hover:hover) and (pointer:fine){.pgx-db:hover{background:var(--row);color:var(--fg)}}
.pgx-db>svg{color:var(--muted);transition:transform .32s var(--ease)}
.pgx-db[aria-expanded="true"]>svg{transform:rotate(180deg)}
.pgx-pop{min-width:230px}
.pgx-ok[disabled]{opacity:1;cursor:not-allowed;background:var(--dis-hatch),var(--dis-fill);color:var(--dis);box-shadow:var(--soft-ring)}
.pgx-done{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--ok-bg);font-size:13.5px}
/* its button is a white pill on the tinted row */
.pgx-done .pill{background:var(--surface);color:var(--fg);box-shadow:var(--shadow)}
@media (hover:hover) and (pointer:fine){.pgx-done .pill:hover{background:var(--surface);box-shadow:var(--shadow-pop)}}
.pgx-done .t{flex:1;color:var(--fg)}
.pgx-done .t span{display:block;font-size:12px;color:var(--fg2)}
.pgx-done>svg{color:var(--ok)}
/* while the agent works under the permission: a neutral row with a spinner, a live line and a clock; green once done */
.pgx-done.run{background:var(--chip)}
.pgx-done .g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--fg2)}
.pgx-done.ok .g{color:var(--ok)}
.pgx-done .t b{display:block;font-weight:400}
.pgx-tm{font-size:12px;color:var(--muted)}
/* asking again mid-run, for one more scope: the tile turns amber */
.pgx-ic{transition:background .32s var(--ease)}
.pgx.esc .pgx-ic{background:var(--warn-bg)}
.pgx-scope{display:flex;flex-direction:column;gap:var(--s-3)}
permission-gate.jsHTML · 91 lines
DM.def('permission-gate', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{figma}', I('figma')).replace(/\{chk\}/g, I('check','xs')).replace('{chev}', I('chev-d','xs'));
  const root = $('.pgx', r), ok = $('.pgx-ok', r), no = $('.pgx-no', r), sc = $('.pgx-sc', r), title = $('.pgx-ht b', r), sub = $('.pgx-ht .muted', r);
  const first = { sc:sc.innerHTML, title:title.textContent, sub:sub.textContent, no:no.textContent };
  const boxes = () => $$('.pgx-o input', sc);
  // how long: This task, Today or Always, from the dropdown in the footer
  let dur = 'This task';
  const span = () => dur === 'Always' ? 'always' : 'for ' + dur.toLowerCase();
  const count = () => { const n = boxes().filter(b => b.checked).length; ok.disabled = !n; swap(ok, n ? 'Allow ' + n + (n > 1 ? ' scopes' : ' scope') : 'Pick a scope'); };
  c.on(sc, 'change', count);
  $$('.pgx-pop .mi', r).forEach(m => m.addEventListener('click', () => { dur = m.dataset.v; $$('.pgx-pop .mi', r).forEach(x => x.setAttribute('aria-checked', x === m)); swap($('.pgx-dv', r), dur === 'Always' ? 'Always' : 'For ' + dur.toLowerCase()); }));
  const body = [sc, $('.pgx-f', r)];
  // the scopes give way to a shorter result row: on the card it keeps the height it had and centres the row (hold)
  const show = on => { if (!on && c.card && !root.classList.contains('held')) hold(root); body.forEach(e => e.hidden = !on); if (on) stagger(body, 50); };
  // the frames the agent edits once it may. Plan toggle is a library component, so editing it needs Publish library
  const F = ['Hero', 'Plan cards', 'Compare table', 'FAQ', 'Plan toggle', 'Checkout summary'], LIB = 4;
  const clock = s => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
  // the clock counts the agent's working time only, not the time it waits for you
  let granted = 0, publish = false, skip = false, done = 0, ms = 0, run = 0, panel = null;
  function row(){
    panel = html(`<div class="pgx-done run"><span class="g"><span class="spin"></span></span><span class="t"><b class="pgx-a"></b><span class="pgx-d"></span></span><span class="pgx-tm num"></span><button class="pill ghost sm">Revoke</button></div>`);
    root.appendChild(panel); enter(panel); $('button', panel).onclick = revoke;
  }
  // the agent works through the frames under what it was given, and stops to ask when it reaches one it may not touch
  function work(){
    const id = ++run; if (!panel) row();
    const a = $('.pgx-a', panel), d = $('.pgx-d', panel), tm = $('.pgx-tm', panel);
    swap(a, `Allowed ${granted} ${granted > 1 ? 'scopes' : 'scope'} ${span()}`);
    const t0 = performance.now(), now = () => ms + performance.now() - t0;
    tm.textContent = clock(Math.floor(ms / 1000));
    c.run(async () => {
      const tick = c.every(250, () => { if (run === id) tm.textContent = clock(Math.floor(now() / 1000)); });
      try {
        while (done < F.length){
          if (done === LIB && !publish && !skip) return ask();
          swap(d, done === LIB && skip ? `Skipping ${F[done]}` : `Editing ${F[done]} · ${done + 1} of ${F.length}`);
          await c.wait(900); if (run !== id) return;
          done++;
        }
        panel.classList.replace('run', 'ok'); const g = $('.g', panel); g.innerHTML = I('ok'); enter(g);
        swap(a, skip ? `Done. ${F.length - 1} frames edited` : `Done. ${F.length} frames edited`);
        swap(d, skip ? 'On a new page. Plan toggle skipped' : 'On a new page. Plan toggle published');
      } finally { clearInterval(tick); if (run === id) ms = now(); }
    });
  }
  // asks again, for only the scope it now needs
  function ask(){
    const p = panel; panel = null; p.classList.add('leave');
    root.classList.add('esc'); swap(title, 'Also allow Publish library?'); swap(sub, `${F[LIB]}, frame ${LIB + 1} of ${F.length}, is a library component`);
    c.later(300, () => {
      p.remove();
      sc.innerHTML = `<label class="pgx-o" data-k="publish"><input type="checkbox" checked><span class="bx">${I('check','xs')}</span><span class="t">Publish library<span class="d">Updates Checkout, Marketing site, Email kit and Docs</span></span><span class="tag warn">Risky</span></label>`;
      no.textContent = 'Skip this frame'; count(); show(true);
    });
  }
  function reset(){
    run++; granted = done = ms = 0; publish = skip = false;
    root.classList.remove('esc'); sc.innerHTML = first.sc; swap(title, first.title); swap(sub, first.sub); no.textContent = first.no; count();
  }
  function revoke(){ const p = panel; panel = null; run++; p.classList.add('leave'); c.later(300, () => { p.remove(); release(root); reset(); show(true); }); }
  ok.onclick = () => c.run(async () => {
    const picked = boxes().filter(b => b.checked); if (!picked.length) return;
    granted += picked.length; if (picked.some(b => b.closest('.pgx-o').dataset.k === 'publish')) publish = true;
    ok.innerHTML = '<span class="spin"></span>'; await c.wait(500);
    show(false);
    if (root.classList.contains('esc')){ root.classList.remove('esc'); swap(title, first.title); swap(sub, first.sub); }
    // placed inside another component (Agent harness), it reports back without the run
    if (!c.card){ row(); swap($('.pgx-a', panel), `Allowed ${granted} ${granted > 1 ? 'scopes' : 'scope'} ${span()}`); swap($('.pgx-d', panel), 'Applying the pricing fixes now'); $('.pgx-tm', panel).remove();
      c.later(1400, () => { if (!panel) return; panel.classList.replace('run', 'ok'); $('.g', panel).innerHTML = I('ok'); swap($('.pgx-d', panel), 'Done. 6 frames edited on a new page'); }); return; }
    work();
  });
  no.onclick = () => {
    if (root.classList.contains('esc')){ skip = true; show(false); root.classList.remove('esc'); swap(title, first.title); swap(sub, first.sub); work(); return; }
    show(false); const d = html(`<div class="pgx-done" style="background:var(--chip)">${I('info')}<span class="t">The agent will skip the Figma edits<span>It will list the fixes in chat instead</span></span><button class="pill ghost sm">Undo</button></div>`);
    root.appendChild(d); enter(d); $('button', d).onclick = () => { d.remove(); release(root); show(true); };
  };
  // the preview's story: hold back the risky scope, allow the rest, then grant it when the agent needs it for one frame
  c.story(async s => {
    await s.beat(3);
    const pub = $('[data-k="publish"]', sc);
    await s.tap(pub); await s.beat(1);
    await s.point($('.tag', pub)); await s.beat(3);
    await s.tap(pub); await s.beat(2);
    await s.tap(ok); s.away();
    await s.until(() => root.classList.contains('esc') && !sc.hidden);
    await s.beat(4);
    await s.tap(ok); s.away();
    await s.until(() => panel && panel.classList.contains('ok'));
  });
});
06

Questions

What people ask about it.

Can the agent wait for the permission before it continues?
Yes. Make onAllow return a promise that resolves once your agent has the grant. The button spins while it waits, then the card switches to the allowed state.
How do I pre-tick the safe scopes?
Pass their keys in defaultValue, or control the ticks with value and onValueChange. Scopes you leave out start unticked.
What happens when the person presses Revoke?
The allowed message fades out, the card returns to asking with the scopes shown again, and onRevoke is called so you can withdraw the grant.
Can I use my own durations?
Yes. Pass any list of strings as durations. The first one is picked by default unless you set defaultDuration or duration.
07

More in Review and approve

Moments where a person checks, steers or signs off on the agent's work.

Change reviewAccept or reject each change the agent made, file by file.
Plan reviewCheck the agent's plan, edit steps, then let it run.
Hand-off to a personThe agent hands a conversation to a person, with what they need to pick it up.
Decision promptThe agent asks a few quick questions before it starts.
Proposal cardA suggested change with confidence, alternatives and undo.
Design tunerTweak a component by hand or let the agent tune it.
Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
Launch checklistThe agent checks a design before it ships, and fixes what fails.
All 14 in Review and approve →