Skip to content
Star on GitHub
WorkspaceTools and connections
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

Tools and connections

The tools the agent can reach, and how much it may do with each. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add tool-connections
Tools and connections5 connected
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add tool-connections

React

$npx designme add tool-connections --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.

A list of the tools and services an agent is connected to, each with a three-way permission switch: Off, Ask or Allow. People can test every connection, reconnect one that failed, and connect another tool from a menu. It answers the question "what can this agent touch, and does it need my OK first?"

  • When your agent calls outside tools such as Figma, GitHub or Slack and people should control each one.
  • When some tools should need approval while others run freely.
  • When connections can expire and people need a way to spot and fix them.
  • In an agent's settings or onboarding, where new tools get connected.
03

Usage

Import it and drive it from your own state.

Import

import { ToolConnections } from '@/components/designme/tool-connections/ToolConnections';

Example

The demo above as a React component, 63 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 { useStory, useWait } from '../lib/useScript';
import { ToolConnections, type ToolConnectionsTool } from './ToolConnections';

/* ---------- Tools and connections: five tools with their permissions; Test all finds Slack's token expired, Reconnect fixes it ---------- */
const TOOLS: ToolConnectionsTool[] = [
  { key: 'figma', name: 'Figma', detail: 'Read and edit frames', brand: 'figma', permission: 'ask' },
  { key: 'github', name: 'GitHub', detail: 'designme/web, PRs only', brand: 'github', permission: 'off' },
  { key: 'slack', name: 'Slack', detail: '#design, #launch', brand: 'slack', permission: 'ask' },
  { key: 'analytics', name: 'Analytics', detail: 'Read only, last 90 days', icon: 'db', color: 'var(--dm-pt)', permission: 'ask' },
  { key: 'web', name: 'Web search', detail: 'Any public site', icon: 'globe', color: 'var(--dm-pb)', permission: 'allow' }
];
const MORE: ToolConnectionsTool[] = [
  { key: 'sheets', name: 'Google Sheets', icon: 'table', color: 'var(--dm-ok)' },
  { key: 'linear', name: 'Linear', icon: 'bell', color: 'var(--dm-pp)' },
  { key: 'gmail', name: 'Gmail', icon: 'mail', color: 'var(--dm-pr)' }
];

export default function ToolConnectionsDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [tools, setTools] = useState(TOOLS);
  const [status, setStatus] = useState<string>();
  const [testing, setTesting] = useState(false);
  const wait = useWait();
  const latest = useRef(tools);
  useEffect(() => { latest.current = tools; });
  const patch = (key: string, p: Partial<ToolConnectionsTool>) => setTools(ts => ts.map(t => t.key === key ? { ...t, ...p } : t));

  // every tool the agent may use shows a small spinner at once, and they settle one by one; Slack's token has run out
  const fixed = useRef(new Set<string>());
  const test = async () => {
    const rows = latest.current.filter(t => t.permission !== 'off' && t.status !== 'error');
    setTesting(true); setStatus(`Testing ${rows.length} connections`);
    rows.forEach(t => patch(t.key, { status: 'testing' }));
    for (const t of rows) {
      await wait(600);
      patch(t.key, t.key === 'slack' && !fixed.current.has(t.key) ? { status: 'error', note: 'Token expired. Reconnect to use it' } : { status: 'ok' });
    }
    const bad = rows.some(t => t.key === 'slack' && !fixed.current.has(t.key)) ? 1 : 0;
    setTesting(false); setStatus(`${rows.length - bad} of ${rows.length} healthy`);
  };
  const reconnect = async (key: string) => {
    patch(key, { status: 'reconnecting' });
    await wait(900);
    const on = latest.current.filter(t => t.permission !== 'off').length;
    fixed.current.add(key); patch(key, { status: 'ok', note: undefined }); setStatus(`${on} of ${on} healthy`);
  };

  // the preview's story: test everything, find Slack's token expired, and reconnect it
  useStory(ref, async ({ wait, press }) => {
    await wait(1500); press('.dm-hnx-test');
    await wait(4 * 600 + 1500); press('.dm-hnx-t.dm-err .dm-pill');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ToolConnections tools={tools} onToolsChange={ts => { setTools(ts); setStatus(undefined); }} available={MORE} onTest={test} testing={testing} onReconnect={reconnect} status={status} />
    </div>
  );
}
04

Props

Everything ToolConnections takes.

PropTypeWhat it does
toolsToolConnectionsTool[]The connected tools. Leave it out to let the permission switches and Connect a tool change them
defaultToolsToolConnectionsTool[]
onToolsChange(tools: ToolConnectionsTool[]) => void
availableToolConnectionsTool[]Tools that can be connected, listed under Connect a tool. Ones already connected are left out
onConnect(tool: ToolConnectionsTool) => voidCalled with a tool picked from Connect a tool. It joins the list with "Just connected" and Ask
onTest() => voidAdds a Test all button. Report progress through each tool's status and note
testingbooleanA test is running: Test all shows a spinner and waits
onReconnect(key: string) => voidCalled by the Reconnect button of a tool whose status is error
titlestring
statusstringThe line under the title. Defaults to how many tools are connected
classNamestring
05

Code

The full source, as the CLI installs it.

ToolConnections.tsxReact · 146 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { Brand, type BrandName } from '../lib/brands';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './tool-connections.css';

/** off: the agent cannot use it, ask: it asks first, allow: it uses it freely */
export type ToolConnectionsPermission = 'off' | 'ask' | 'allow';

/** ok, testing while a check runs, error when it failed (the row offers Reconnect), reconnecting while that runs */
export type ToolConnectionsStatus = 'ok' | 'testing' | 'error' | 'reconnecting';

/** A tool or connection the agent can reach */
export interface ToolConnectionsTool {
  key: string;
  /** Like "Figma" */
  name: string;
  /** The quiet line under it, like "Read and edit frames" */
  detail?: string;
  /** A service's own logo. Wins over `icon` */
  brand?: BrandName;
  /** An icon on a tinted tile, for tools without a logo */
  icon?: IconName;
  /** The icon's colour, which also tints its tile, like 'var(--dm-pt)' */
  color?: string;
  /** Defaults to ask */
  permission?: ToolConnectionsPermission;
  status?: ToolConnectionsStatus;
  /** Shown instead of the detail while it is set, like "Testing" or "Token expired. Reconnect" */
  note?: string;
}

export interface ToolConnectionsProps {
  /** The connected tools. Leave it out to let the permission switches and Connect a tool change them */
  tools?: ToolConnectionsTool[];
  defaultTools?: ToolConnectionsTool[];
  onToolsChange?: (tools: ToolConnectionsTool[]) => void;
  /** Tools that can be connected, listed under Connect a tool. Ones already connected are left out */
  available?: ToolConnectionsTool[];
  /** Called with a tool picked from Connect a tool. It joins the list with "Just connected" and Ask */
  onConnect?: (tool: ToolConnectionsTool) => void;
  /** Adds a Test all button. Report progress through each tool's status and note */
  onTest?: () => void;
  /** A test is running: Test all shows a spinner and waits */
  testing?: boolean;
  /** Called by the Reconnect button of a tool whose status is error */
  onReconnect?: (key: string) => void;
  title?: string;
  /** The line under the title. Defaults to how many tools are connected */
  status?: string;
  className?: string;
}

const PERMS: { value: ToolConnectionsPermission; label: string; tip: string }[] = [
  { value: 'off', label: 'Off', tip: 'Agent cannot use it' },
  { value: 'ask', label: 'Ask', tip: 'Agent asks first' },
  { value: 'allow', label: 'Allow', tip: 'Agent uses it freely' }
];

/** The three-way permission switch, with a knob that slides to the pick */
function Permission({ name, value, onChange }: { name: string; value: ToolConnectionsPermission; onChange: (p: ToolConnectionsPermission) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  const [knob, setKnob] = useState<CSSProperties>({});
  // the knob slides when the choice changes; when the row first appears it is simply in place
  const placed = useRef(false);
  useIsoLayoutEffect(() => {
    const el = ref.current; if (!el) return;
    const place = () => { const b = el.querySelector<HTMLElement>('[aria-pressed="true"]'); if (b){ setKnob({ left: b.offsetLeft, width: b.offsetWidth, transition: placed.current ? undefined : 'none' }); placed.current = true; } };
    place();
    const ro = new ResizeObserver(place); ro.observe(el);
    return () => ro.disconnect();
  }, [value]);
  return (
    <div className="dm-hnx-pm" role="group" aria-label={name + ' permission'} ref={ref}>
      <span className="dm-kn" style={knob} />
      {PERMS.map(p => <Tip key={p.value} label={p.tip}><button type="button" aria-pressed={p.value === value} onClick={() => onChange(p.value)} onMouseDown={e => e.preventDefault()}>{p.label}</button></Tip>)}
    </div>
  );
}

/** The logo, or the icon on its tinted tile, with a dot for its state */
const Mark = ({ tool }: { tool: ToolConnectionsTool }) => tool.brand
  ? <span className="dm-ic dm-brandic"><Brand name={tool.brand} /></span>
  : <span className="dm-ic" style={tool.color ? { color: tool.color, background: `color-mix(in srgb,${tool.color} 12%,transparent)` } : undefined}>{tool.icon && <Icon name={tool.icon} size="xs" />}</span>;

/** The tools the agent can reach, and how much it may do with each. */
export function ToolConnections({ tools, defaultTools = [], onToolsChange, available = [], onConnect, onTest, testing = false, onReconnect, title = 'Tools and connections', status, className }: ToolConnectionsProps) {
  const [list, setList] = useControllable(tools, defaultTools, onToolsChange);
  const menu = usePopover({ side: 'up' });
  const perm = (t: ToolConnectionsTool) => t.permission ?? 'ask';
  const connected = list.filter(t => perm(t) !== 'off').length;
  const left = available.filter(a => !list.some(t => t.key === a.key));

  // rows there from the start come in one after another; a row connected later, or back from Reconnect, comes in on its own
  const [first] = useState(() => list.map(t => t.key));
  const [epoch, setEpoch] = useState<Record<string, number>>({});
  const was = useRef<Record<string, ToolConnectionsStatus | undefined>>({});
  useEffect(() => {
    const back = list.filter(t => was.current[t.key] === 'reconnecting' && t.status !== 'reconnecting' && t.status !== 'error').map(t => t.key);
    if (back.length) setEpoch(e => ({ ...e, ...Object.fromEntries(back.map(k => [k, (e[k] ?? 0) + 1])) }));
    was.current = Object.fromEntries(list.map(t => [t.key, t.status]));
  }, [list]);

  const change = (key: string, p: ToolConnectionsPermission) => setList(list.map(t => t.key === key ? { ...t, permission: p } : t));
  const connect = (tool: ToolConnectionsTool) => {
    menu.closeSoon();
    setList([...list, { ...tool, detail: 'Just connected', permission: 'ask' }]);
    onConnect?.(tool);
  };

  return (
    <div data-dm="" className={cx('dm-hnx dm-panel', className)}>
      <div className="dm-hnx-h">
        <div><b>{title}</b><Swap className="dm-muted dm-hnx-st">{status ?? connected + ' connected'}</Swap></div>
        {onTest && <button type="button" className="dm-pill dm-ghost dm-sm dm-hnx-test" onClick={onTest} disabled={testing}>{testing ? <><Spin />Testing</> : <><Icon name="refresh" size="xs" />Test all</>}</button>}
      </div>
      <div className="dm-hnx-list">
        {list.map(t => {
          const at = first.indexOf(t.key), again = epoch[t.key] ?? 0;
          const motion = at >= 0 && !again ? enter(at * 50) : enter(0);
          return (
            <div key={t.key + ':' + again} className={cx('dm-hnx-t', motion.className, perm(t) === 'off' && 'dm-off', t.status === 'testing' && 'dm-chk', (t.status === 'error' || t.status === 'reconnecting') && 'dm-err')} style={motion.style}>
              <Mark tool={t} />
              <span className="dm-t">{t.name}<Swap>{t.note ?? t.detail ?? ''}</Swap></span>
              {t.status === 'error' || t.status === 'reconnecting'
                ? <button type="button" className="dm-pill dm-accent dm-sm dm-enter" onClick={() => t.status === 'error' && onReconnect?.(t.key)}>{t.status === 'reconnecting' ? <Spin /> : 'Reconnect'}</button>
                : <Permission name={t.name} value={perm(t)} onChange={p => change(t.key, p)} />}
            </div>
          );
        })}
      </div>
      <button type="button" className="dm-hnx-add" {...menu.trigger}><Icon name="plus" size="xs" />Connect a tool</button>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-hnx-pop')} role="menu">
        {left.length
          ? left.map(a => <button type="button" key={a.key} className="dm-mi" onClick={() => connect(a)}>{a.brand ? <Brand name={a.brand} /> : a.icon && <Icon name={a.icon} size="xs" />}<span>{a.name}</span></button>)
          : <div className="dm-ph" style={{ padding: 8, fontSize: 12.5, color: 'var(--dm-muted)' }}>All tools connected</div>}
      </div>
    </div>
  );
}
tool-connections.cssReact · 32 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Tools and connections ---------- */
.dm-hnx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:6px}
.dm-hnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 10px var(--dm-s-2)}
.dm-hnx-h>div{display:flex;flex-direction:column}
.dm-hnx-h b{font-weight:500;font-size:13.5px}
.dm-hnx-st{font-size:12px}
.dm-hnx-list{display:flex;flex-direction:column}
.dm-hnx-t{display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-t:hover{background:var(--dm-row)}}
.dm-hnx-t .dm-ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;flex:none;position:relative}
.dm-hnx-t .dm-ic::after{content:"";position:absolute;right:-2px;bottom:-2px;width:8px;height:8px;border-radius:50%;background:var(--dm-ok);box-shadow:0 0 0 2px var(--dm-surface);transition:background .3s}
.dm-hnx-t.dm-off .dm-ic::after{background:var(--dm-faint)}
.dm-hnx-t .dm-ic.dm-brandic{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-fg)}
.dm-hnx-t .dm-ic .dm-brand{width:15px;height:15px}
.dm-hnx-t.dm-err .dm-ic::after{background:var(--dm-bad)}
.dm-hnx-t.dm-chk .dm-ic::after{width:10px;height:10px;right:-3px;bottom:-3px;box-sizing:border-box;background:var(--dm-surface);border:1.5px solid var(--dm-accent-mark,var(--dm-accent));border-right-color:transparent;box-shadow:0 0 0 2px var(--dm-surface);animation:dm-rot .8s linear infinite}
.dm-hnx-t .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-hnx-t .dm-t span{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-hnx-t.dm-err .dm-t span{color:var(--dm-bad)}
/* the same pill switch as every other segmented switch; its track darkens with a hovered row so it never melts into it */
.dm-hnx-pm{display:grid;grid-template-columns:repeat(3,auto);padding:2px;border-radius:var(--dm-r-pill);background:var(--dm-chip);position:relative;transition:background-color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-t:hover .dm-hnx-pm{background:color-mix(in srgb,var(--dm-fg) 8%,transparent)}}
.dm-hnx-pm button{position:relative;z-index:1;height:24px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:11.5px;color:var(--dm-muted);transition:color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-pm button:hover{color:var(--dm-fg2)}}
.dm-hnx-pm button[aria-pressed="true"]{color:var(--dm-fg)}
.dm-hnx-pm button:focus-visible{outline-offset:-2px}
.dm-hnx-pm .dm-kn{position:absolute;top:2px;bottom:2px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .4s var(--dm-ease),width .4s var(--dm-ease)}
.dm-hnx-add{display:flex;align-items:center;gap:var(--dm-s-2);height:30px;padding:0 var(--dm-s-3) 0 10px;font-size:12.5px;color:var(--dm-fg2);border-radius:var(--dm-r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-add:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-hnx-pop{min-width:220px}
tool-connections.htmlHTML · 11 lines
<div class="card" data-c="tool-connections">
  <div class="stage-in">
    <div class="hnx panel" data-reveal="flat">
      <div class="hnx-h"><div><b>Tools and connections</b><span class="muted hnx-st">5 connected</span></div><button class="pill ghost sm hnx-test"><svg class="i xs"><use href="#i-refresh"/></svg>Test all</button></div>
      <div class="hnx-list"></div>
      <button class="hnx-add" data-pop=".hnx-pop" data-pop-pos="up"><svg class="i xs"><use href="#i-plus"/></svg>Connect a tool</button>
      <div class="pop hnx-pop" role="menu"></div>
    </div>
  </div>
</div>
tool-connections.cssHTML · 31 lines
/* ---------- Tools and connections ---------- */
.hnx{padding:var(--s-3);display:flex;flex-direction:column;gap:6px}
.hnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 10px var(--s-2)}
.hnx-h>div{display:flex;flex-direction:column}
.hnx-h b{font-weight:500;font-size:13.5px}
.hnx-st{font-size:12px}
.hnx-list{display:flex;flex-direction:column}
.hnx-t{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-2) 10px;border-radius:var(--r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.hnx-t:hover{background:var(--row)}}
.hnx-t .ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;flex:none;position:relative}
.hnx-t .ic::after{content:"";position:absolute;right:-2px;bottom:-2px;width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 2px var(--surface);transition:background .3s}
.hnx-t.off .ic::after{background:var(--faint)}
.hnx-t .ic.brandic{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);color:var(--fg)}
.hnx-t .ic .brand{width:15px;height:15px}
.hnx-t.err .ic::after{background:var(--bad)}
.hnx-t.chk .ic::after{width:10px;height:10px;right:-3px;bottom:-3px;box-sizing:border-box;background:var(--surface);border:1.5px solid var(--accent-mark,var(--accent));border-right-color:transparent;box-shadow:0 0 0 2px var(--surface);animation:rot .8s linear infinite}
.hnx-t .t{flex:1;min-width:0;display:flex;flex-direction:column}
.hnx-t .t span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hnx-t.err .t span{color:var(--bad)}
/* the same pill switch as every other segmented switch; its track darkens with a hovered row so it never melts into it */
.hnx-pm{display:grid;grid-template-columns:repeat(3,auto);padding:2px;border-radius:var(--r-pill);background:var(--chip);position:relative;transition:background-color .2s}
@media (hover:hover) and (pointer:fine){.hnx-t:hover .hnx-pm{background:color-mix(in srgb,var(--fg) 8%,transparent)}}
.hnx-pm button{position:relative;z-index:1;height:24px;padding:0 9px;border-radius:var(--r-pill);font-size:11.5px;color:var(--muted);transition:color .2s}
@media (hover:hover) and (pointer:fine){.hnx-pm button:hover{color:var(--fg2)}}
.hnx-pm button[aria-pressed="true"]{color:var(--fg)}
.hnx-pm button:focus-visible{outline-offset:-2px}
.hnx-pm .kn{position:absolute;top:2px;bottom:2px;border-radius:var(--r-pill);background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .4s var(--ease),width .4s var(--ease)}
.hnx-add{display:flex;align-items:center;gap:var(--s-2);height:30px;padding:0 var(--s-3) 0 10px;font-size:12.5px;color:var(--fg2);border-radius:var(--r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.hnx-add:hover{color:var(--fg);background:var(--row)}}
.hnx-pop{min-width:220px}
tool-connections.jsHTML · 56 lines
DM.def('tool-connections', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{refresh}', I('refresh','xs')).replace('{plus}', I('plus','xs'));
  let T = [
    ['@figma','Figma','Read and edit frames','var(--pl)',1],
    ['@github','GitHub','designme/web, PRs only','var(--fg)',0],
    ['@slack','Slack','#design, #launch','var(--pm)',1],
    ['db','Analytics','Read only, last 90 days','var(--pt)',1],
    ['globe','Web search','Any public site','var(--pb)',2]
  ];
  const ADD = [['table','Google Sheets','var(--ok)'],['bell','Linear','var(--pp)'],['mail','Gmail','var(--pr)']];
  const list = $('.hnx-list', r), st = $('.hnx-st', r), pop = $('.hnx-pop', r);
  const PM = ['Off','Ask','Allow'];
  function row(t){
    const el = html(`<div class="hnx-t${t[4] === 0 ? ' off' : ''}">${t[0][0] === '@' ? `<span class="ic brandic">${BRAND[t[0].slice(1)]}</span>` : `<span class="ic" style="color:${t[3]};background:color-mix(in srgb,${t[3]} 12%,transparent)">${I(t[0],'xs')}</span>`}<span class="t">${t[1]}<span>${t[2]}</span></span><div class="hnx-pm" role="group" aria-label="${t[1]} permission"><span class="kn"></span>${PM.map((p, i) => `<button aria-pressed="${i === t[4]}" data-tip="${['Agent cannot use it','Agent asks first','Agent uses it freely'][i]}">${p}</button>`).join('')}</div></div>`);
    const g = $('.hnx-pm', el), kn = $('.kn', g), bs = $$('button', g);
    // the knob slides when a choice changes; when the row first appears it is simply in place
    const place = now => { const b = bs[t[4]]; if (now) kn.style.transition = 'none'; kn.style.left = b.offsetLeft + 'px'; kn.style.width = b.offsetWidth + 'px'; if (now){ void kn.offsetWidth; kn.style.transition = ''; } };
    bs.forEach((b, i) => b.onclick = () => { t[4] = i; bs.forEach((x, j) => x.setAttribute('aria-pressed', j === i)); place(); el.classList.toggle('off', i === 0); count(); });
    // a click with the mouse doesn't take focus, so it never leaves a focus ring; the keyboard still reaches every option
    bs.forEach(b => b.onmousedown = e => e.preventDefault());
    requestAnimationFrame(() => place(true)); new ResizeObserver(() => place(true)).observe(g);
    el._t = t; return el;
  }
  // the first count is written straight in, so the header doesn't crossfade from the template's number on load
  const count = now => { const n = T.filter(t => t[4] > 0).length + ' connected'; if (now) st.textContent = n; else swap(st, n); };
  T.forEach(t => list.appendChild(row(t))); stagger([...list.children], 50); count(true);
  function menu(){ pop.innerHTML = ADD.length ? ADD.map((a, i) => `<button class="mi" data-close data-i="${i}">${I(a[0],'xs')}<span>${a[1]}</span></button>`).join('') : '<div class="ph" style="padding:8px;font-size:12.5px;color:var(--muted)">All tools connected</div>';
    $$('.mi', pop).forEach(m => m.addEventListener('click', () => { const a = ADD.splice(+m.dataset.i, 1)[0]; const t = [a[0], a[1], 'Just connected', a[2], 1]; T.push(t); const el = row(t); list.appendChild(el); enter(el); count(); menu(); })); }
  menu();
  // Test all: every connected tool shows a small spinner at once, and they settle one by one; only a failure changes its line
  const test = $('.hnx-test', r), testWas = test.innerHTML;
  test.onclick = () => c.run(async () => {
    const rows = [...list.children].filter(el => el._t[4] > 0);
    test.disabled = true; test.innerHTML = '<span class="spin"></span>Testing'; swap(st, 'Testing ' + rows.length + ' connections');
    rows.forEach(el => el.classList.add('chk'));
    for (const el of rows){ await c.wait(600); el.classList.remove('chk');
      if (el._t[1] === 'Slack' && !el._t.ok){ el.classList.add('err'); swap($('.t span', el), 'Token expired. Reconnect to use it'); }
    }
    test.disabled = false; test.innerHTML = testWas;
    swap(st, rows.filter(el => !el.classList.contains('err')).length + ' of ' + rows.length + ' healthy');
    const bad = [...list.children].find(el => el.classList.contains('err'));
    // a second test finds the row already offering Reconnect, so it keeps that button
    const pm = bad && $('.hnx-pm', bad);
    if (pm){ const b = html(`<button class="pill accent sm">Reconnect</button>`); pm.replaceWith(b); enter(b);
      b.onclick = () => c.run(async () => { b.innerHTML = '<span class="spin"></span>'; await c.wait(900); bad.classList.remove('err'); bad._t.ok = true; const n = row(bad._t); bad.replaceWith(n); swap(st, [...list.children].filter(el => el._t[4] > 0).length + ' of ' + [...list.children].filter(el => el._t[4] > 0).length + ' healthy'); }); }
  });
  // the preview's story: test everything, Slack's token has expired, reconnect it, and every connection is healthy
  c.story(async s => {
    await s.beat(3);
    await s.tap(test); s.away();
    await s.until(() => $('.hnx-t.err .pill', list)); await s.beat(3);
    await s.tap($('.hnx-t.err .pill', list)); s.away();
    await s.until(() => !$('.hnx-t.err', list)); await s.beat(1);
  });
});
06

Questions

What people ask about it.

How do I show a connection test in progress?
Handle onTest and update tools: set testing, and each tool's status to 'testing' (a small spinner on its dot), then to 'ok' or 'error'. A note, like "Token expired. Reconnect to use it", replaces the detail line while it is set.
What does Reconnect do?
It calls onReconnect with the tool's key. Set that tool's status to 'reconnecting' while you refresh the token, then back to 'ok' and the row comes back with its permission switch.
How do I save permission changes?
Pass tools and onToolsChange. Every switch change and newly connected tool comes back as the full updated list.
Can I use a tool that has no logo?
Yes. Leave out brand and set icon and color; the icon sits on a tile tinted with that colour.
07

More in Workspace

Navigation, search, settings, models and automations around the agent.

Automation canvasA trigger, a few agent steps, and a run you can watch flow through.
Canvas toolbarA floating toolbar for a workflow canvas: run, pick a tool, comment, fit and zoom.
Command paletteJump anywhere or hand a task to the agent from one box.
Memory panelWhat the agent remembers about your team, editable and removable.
Model compareRun one prompt on three models side by side and keep the best.
Agent settingsHow much the agent may do alone, with a save bar for changes.
Knowledge sourcesThe files, sites and folders the agent knows, and how far each is indexed.
Scheduled runsTasks the agent runs on a schedule, with their last result and a switch.
All 11 in Workspace →