SLOPSHOPPER

mod

Ayda for Claude: connect Claude to your company's own Ayda, with skills for cited answers, daily briefs and open loops.

newpanebandrowsguardcommand
v?MITupdated 2026-10-05Ayda-Knowledge/ayda-plugin/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod
│ ┃ Ayda open loops ✕ › fix the failing auth test and add an audit log call │ ┃ Ayda is not connected (undefined). Use /mcp │ ┃ to sign in. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ayda-loops │ ⎿ mod: Ayda is not connected (undefined). Use /mcp to sign in. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ayda open loops
Ayda is not connected (undefined). Use /mcp to sign in.
README

Ayda for Claude: your company's own knowledge, with a citation on every answer

Ayda for Claude

Licence: MIT Plugin version Skills

Ayda is company memory that your company owns and runs. It reads the sources your company connects, such as Slack, Google Drive, Gmail and GitHub, and answers with a citation for each statement.

This plugin adds skills that make Claude use your company's own Ayda installation well: fewer calls, a citation on each statement, and the correct date for each fact. You connect Claude to your installation once, in the install steps below.

Skills

Every day

SkillWhat it does
ayda-daily-briefStarts your day: what changed, what you owe, what others owe you, and what looks done.
ayda-loop-sweepGoes through your open loops in small batches and records your verdict on each.
ayda-rememberKeeps the decisions and commitments from a conversation as your own Ayda memories.

When you need an answer you can trust

SkillWhat it does
ayda-decision-traceShows how and why a decision was made, and how it changed, with citations.
ayda-fact-checkChecks a plan, document or change against recorded company facts.
ayda-onboardingWrites a cited brief on a role, team or project, with a reading list.
ayda-guideHolds the rules the other skills use: call cost, dates, conflicts between sources, and citations.

Claude selects a skill when your request fits it. In Claude Code you can also start one by name, for example /ayda:ayda-daily-brief. Name Ayda when you want something kept there ("save this to Ayda"): in Claude Code a plain "remember this" goes to Claude's own memory.

Try it

  • "Catch me up on what I missed yesterday."
  • "Which of my open loops look done? Let's clear them."
  • "Why did we change payment providers, and when did that happen?"
  • "Check this proposal against what we agreed with the client."
  • "Save to Ayda that we chose the Q4 pricing on the 12 October call."

How it works

flowchart LR
  you([You]) --> claude[Claude + Ayda skills]
  claude -->|MCP, signed in as you| ayda[Your Ayda installation]
  ayda --> sources[(Slack, Drive, Gmail, GitHub and more)]
  • Ayda runs in your company's own environment. Claude connects to the host you give it, and the skills use that connection and no other.
  • You sign in with your company account. Ayda answers as you and shows only the records you have access to.
  • Each answer comes with its sources, so you can open the record behind each statement.

Install

You need a company Ayda installation with Agent access switched on by an admin, and its host name, for example ayda.example.com. If you do not know the host name, ask your Ayda admin.

Claude Code

/plugin marketplace add Ayda-Knowledge/ayda-plugin
/plugin install ayda@ayda

Then connect your installation, with your own host name in the URL:

claude mcp add --transport http --scope user ayda https://<your Ayda host>/mcp

Claude opens a sign-in page the first time it calls Ayda.

Claude apps (claude.ai, desktop and Cowork)

  1. In Customize > Plugins, find Ayda in the directory and install it.
  2. Connect your installation:
  3. If your organisation already has an Ayda connector, you are done. The skills use it.
  4. Otherwise, in Customize > Connectors, add a custom connector named Ayda with the URL https://<your Ayda host>/mcp, then sign in. On a Team or Enterprise plan, an Owner can add it for the organisation.

Other agents

The skills are plain SKILL.md folders. Copy the folders under plugin/skills/ to your agent's skills directory, then connect your Ayda MCP server in that agent. Ayda's Connect Your Agent page shows how for each agent.

Data and privacy

Claude sends your questions to your company's own Ayda installation, at the host you enter. It sends nothing to Ubundi or to any other service.

Ayda can change only two things, and only after you say so:

  • ayda-loop-sweep records your verdict (open, done or dismissed) on one of your own open loops.
  • ayda-remember stores a sentence you approve as your own memory record. Ayda keeps the decisions it reads from that sentence pending until you confirm them in Ayda.

No skill writes to Slack, Google Drive, Gmail, GitHub or another source.

Evals

The evals/ suite checks the behaviour that matters: that the daily brief reads your day in your own time zone, that a sweep writes nothing before you decide, that instructions hidden in a record never cause a write, and that two sources that disagree are both shown.

scripts/eval-compare.sh runs each case against a mocked Ayda, once with the skills and once with the Ayda connection alone, so the difference is what the skills add. The latest scores are in evals/RESULTS.md.

Repository layout

  • plugin/ is the plugin itself: the manifest, icon and skills. It is what Claude installs and what the Claude directory reads.
  • Everything else, such as scripts/, CI and this README, supports its development and is not installed.

Contributing

Ideas for new skills and fixes are welcome. Read CONTRIBUTING.md first. To report a security problem, read SECURITY.md.

Licence

Source 2 files
hooks/register.tsx 439 lines
1// The Ayda mod for Claude Code. Claude Code does not show Ayda's MCP Apps, so
2// this module draws two of them from the same tool results: the open loops
3// list (a band and a pane) and the citation card under an `ask` call.
4//
5// The two verdict buttons call `decide_open_loop` on the member's own press.
6// No model is in that path, so record text can never cause a write.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { Loop, Loops, LoopsState } from '../types'
12
13const PANE = 'ayda-loops'
14const PANE_TITLE = 'Ayda open loops'
15const REFRESH_MS = 15 * 60 * 1000
16// Loops asked for in each group. Claude Code refuses an MCP result above its
17// size limit, and a full list of 20 loops in each group is above it. The
18// counts are for the whole list at any limit. The second value is the retry.
19const GROUP_LIMITS = [5, 2]
20const CITATIONS_SHOWN = 5
21const WARNING = 'yellow'
22
23// The same server can run under several names: the plugin's own `ayda`, a
24// member's own entry, or an organisation connector on claude.ai.
25const TODAY = /^mcp__.*ayda.*__today$/i
26const DECIDE = /^mcp__.*ayda.*__decide_open_loop$/i
27const ANSWERS = /^mcp__.*ayda.*__(ask|research_brief)$/i
28
29const initial: LoopsState = { loops: null, error: null, busy: false }
30const loopsState = atom({ plugin: 'ayda', key: 'loops' } as const, initial)
31
32type Citation = {
33  title: string
34  source: string
35  url?: string
36  started_at?: string | null
37  temporal_assertions?: { conflict_status?: string | null; evidence_status?: string }[]
38}
39
40type Answer = { citations: Citation[]; temporal?: { status?: string } | null }
41
42const isObject = (value: unknown): value is Record<string, unknown> =>
43  typeof value === 'object' && value !== null
44
45// An MCP result reaches a render hook as the structured result, as content
46// blocks, or as the text form (a JSON dump for `ask`). Read all three.
47function readAnswer(output: unknown): Answer | null {
48  let value = output
49  if (isObject(value) && isObject(value.structuredContent)) value = value.structuredContent
50  if (Array.isArray(value)) value = value.find(block => isObject(block) && block.type === 'text')?.text
51  if (typeof value === 'string') {
52    try {
53      value = JSON.parse(value)
54    } catch {
55      return null
56    }
57  }
58
59  return isObject(value) && Array.isArray(value.citations) ? (value as Answer) : null
60}
61
62// Claude Code hands a mod one text block for an MCP result and no
63// `structuredContent`. That block is either the structured result as JSON or
64// the text the server writes for a person, so the mod reads both.
65const GROUPS = { 'Your move': 'your_move', 'Awaiting others': 'waiting', Done: 'done' } as const
66
67function parseLoops(text: string): Loops | null {
68  const counts = /^(\d+) open · (\d+) your move · (\d+) awaiting others · (\d+) done/.exec(text)
69  if (counts === null) return null
70
71  const [open, yourMove, waiting, done] = counts.slice(1).map(Number) as [number, number, number, number]
72  const loops: Loops = { your_move: [], waiting: [], done: [], counts: { open, your_move: yourMove, waiting, done } }
73  let group: Loop[] | null = null
74  let held: string | null = null
75
76  for (const line of text.split('\n')) {
77    const heading = /^(Your move|Awaiting others|Done) \(\d+\)$/.exec(line)?.[1] as keyof typeof GROUPS | undefined
78    if (heading) {
79      group = loops[GROUPS[heading]]
80    } else if (line.startsWith('- ')) {
81      held = line.slice(2)
82    } else if (line.startsWith('  id: ') && group !== null && held !== null) {
83      const [title = '', ...rest] = held.split(' · ')
84      group.push({ id: line.slice(6), title, meta: rest.join(' · '), looksDone: held.includes(' · looks done, confirm?') })
85      held = null
86    }
87  }
88
89  return loops
90}
91
92const day = (iso?: string | null) => (iso ? iso.slice(0, 10) : '')
93
94function age(iso?: string | null): string {
95  if (!iso) return ''
96  const days = Math.floor((Date.now() - Date.parse(iso)) / 86_400_000)
97
98  return days <= 0 ? 'today' : `${days}d ago`
99}
100
101// A `Link` with an `href` outside these bounds refuses the whole tree it is
102// in, so a URL that does not fit is drawn as text.
103function linkable(url?: string | null): string | undefined {
104  try {
105    const { href, protocol } = new URL(url ?? '')
106
107    return protocol === 'https:' && href.length <= 2048 && /^[\x21-\x3f\x41-\x7e]+$/.test(href) ? href : undefined
108  } catch {
109    return undefined
110  }
111}
112
113// A Slack record has its message as its title, with a URL in it.
114function short(title: string): string {
115  const cut = title.split(/\s*https?:\/\//)[0] ?? title
116
117  return cut.length > 60 ? `${cut.slice(0, 59)}…` : cut
118}
119
120// One loop of the structured result: the fields of the server's `TodoItem`
121// that the pane shows.
122type RawLoop = {
123  id: string
124  summary: string
125  direction?: string
126  status?: string
127  counterpart_name?: string | null
128  due_at?: string | null
129  raised_at?: string | null
130  raised?: { title: string; source: string; channel?: string | null; url?: string | null }
131  evidence?: string | null
132  ayda_confident?: { summary?: string } | null
133  resolution?: { summary?: string | null } | null
134}
135
136function toLoop(raw: RawLoop): Loop {
137  const who = raw.counterpart_name
138  const parts = [
139    who ? (raw.direction === 'await' ? `waiting on ${who}` : `for ${who}`) : '',
140    raw.raised ? (raw.raised.channel ?? raw.raised.source) : '',
141    age(raw.raised_at),
142  ]
143
144  return {
145    id: raw.id,
146    title: raw.summary,
147    meta: parts.filter(Boolean).join(' · '),
148    due: day(raw.due_at) || undefined,
149    raised: raw.raised ? { title: short(raw.raised.title), url: linkable(raw.raised.url) } : undefined,
150    looksDone: raw.status === 'looks_done',
151    evidence: raw.evidence ?? undefined,
152    reason: raw.ayda_confident?.summary ?? raw.resolution?.summary ?? undefined,
153  }
154}
155
156function readLoops(text: string): Loops | null {
157  let value: unknown
158  try {
159    value = JSON.parse(text)
160  } catch {
161    return parseLoops(text)
162  }
163  if (!isObject(value) || !isObject(value.counts)) return null
164
165  const group = (list: unknown) => (Array.isArray(list) ? (list as RawLoop[]).map(toLoop) : [])
166
167  return {
168    your_move: group(value.your_move),
169    waiting: group(value.waiting),
170    done: group(value.done),
171    counts: value.counts as Loops['counts'],
172  }
173}
174
175async function findServer($: EngineInterface): Promise<string> {
176  const own = await $.mcp.connect('ayda')
177  if (own.isConnected) return own.server
178
179  const tools = await $.tool.list()
180  const other = tools.map(tool => /^mcp__(.*ayda.*)__open_loops$/i.exec(tool.name)?.[1]).find(Boolean)
181  if (other) return other
182
183  throw new Error(`Ayda is not connected (${own.message}). Use /mcp to sign in.`)
184}
185
186async function callAyda($: EngineInterface, tool: string, args: Record<string, unknown>) {
187  const result = await $.mcp.call(await findServer($), tool, args)
188  const text = result.content.find(block => block.type === 'text')?.text
189  if (result.isError || text === undefined) throw new Error(text ?? `Ayda could not complete ${tool}.`)
190
191  return text
192}
193
194// Auto mode has a verdict only for an action the model asked for. The mod's
195// own call has no model request behind it, so auto mode refuses it until a
196// permission rule allows the tool.
197function explain(error: unknown): string {
198  const message = error instanceof Error ? error.message : String(error)
199
200  return /auto mode classifier/i.test(message)
201    ? 'Auto mode cannot approve a call that the Ayda mod makes itself. Allow the Ayda tools open_loops and decide_open_loop in /permissions, then press Refresh.'
202    : message
203}
204
205async function refresh($: EngineInterface) {
206  await update($, loopsState, held => ({ ...held, busy: true }))
207  try {
208    let text = ''
209    let loops: Loops | null = null
210    for (const limit of GROUP_LIMITS) {
211      text = await callAyda($, 'open_loops', { filter: 'all', limit })
212      loops = readLoops(text)
213      if (loops !== null) break
214    }
215    if (loops === null) throw new Error(`Ayda returned no open loops list. The result starts: ${text.slice(0, 60)}`)
216    await update($, loopsState, () => ({ loops, error: null, busy: false }))
217  } catch (error) {
218    await update($, loopsState, held => ({ ...held, error: explain(error), busy: false }))
219  }
220}
221
222async function decide($: EngineInterface, loop: Loop, status: 'open' | 'done' | 'dismissed') {
223  // The loop leaves the list on the press. The refresh below puts it back if
224  // Ayda refused the verdict.
225  const without = (list: Loop[]) => list.filter(one => one.id !== loop.id)
226  await update($, loopsState, held =>
227    held.loops === null
228      ? held
229      : {
230          ...held,
231          loops: {
232            ...held.loops,
233            your_move: without(held.loops.your_move),
234            waiting: without(held.loops.waiting),
235            done: without(held.loops.done),
236          },
237        },
238  )
239  try {
240    await callAyda($, 'decide_open_loop', { item_id: loop.id, status })
241    $.ui.toast(`Ayda: marked ${status}: ${loop.title}`)
242  } catch (error) {
243    $.ui.toast(explain(error))
244  }
245  await refresh($)
246}
247
248const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: PANE_TITLE, focus: true })
249
250export const register: Register = on => {
251  on('session.start', async ($, e, next) => {
252    await $.command.register({ name: 'ayda-loops', description: 'Show your Ayda open loops in a pane' })
253
254    // A headless run draws nothing, so it must not spend the member's Ayda calls.
255    if ((await $.session.surface()) !== null) {
256      void refresh($)
257      $.clock.every(REFRESH_MS, () => void refresh($))
258    }
259
260    return next(e)
261  })
262
263  on('command.run', { command: 'ayda-loops' }, async $ => {
264    await openPane($)
265    await refresh($)
266    const { loops, error } = await read($, loopsState)
267
268    return { text: error ?? `${loops?.counts.your_move ?? 0} your move · ${loops?.counts.waiting ?? 0} waiting` }
269  })
270
271  // `today` defaults to UTC on the server. The member's day is their own zone.
272  on('tool.call', { tool: TODAY }, ($, e, next) =>
273    'timezone' in e ? next(e) : next({ ...e, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } as typeof e),
274  )
275
276  // A verdict the model recorded changes the list the band and the pane show.
277  on('tool.call', { tool: DECIDE }, async ($, e, next) => {
278    const ran = await next(e)
279    void refresh($)
280
281    return ran
282  })
283
284  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
285    const { loops } = await read($, loopsState)
286    if (e.props.hasSurvey || loops === null || loops.counts.open === 0) return next(e)
287
288    const { Box, Button, Text } = $.ui.resolve(e)
289    const shown = [...loops.your_move, ...loops.waiting]
290    const looksDone = shown.filter(loop => loop.looksDone).length
291    // The mod holds only the first loops of each group, so this count is a floor.
292    const atLeast = shown.length < loops.counts.open ? '+' : ''
293
294    return (
295      <Box>
296        <Text dimColor>
297          Ayda · {loops.counts.your_move} your move · {loops.counts.waiting} waiting
298          {looksDone > 0 ? <Text color={WARNING}>{` · ${looksDone}${atLeast} look done`}</Text> : ''}{' '}
299        </Text>
300        <Button key="open" label="Loops" onPress={() => void openPane($)} />
301      </Box>
302    )
303  })
304
305  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
306    const { Box, Button, Link, Text } = $.ui.resolve(e)
307    const { loops, error, busy } = await read($, loopsState)
308
309    if (loops === null) return <Text dimColor>{error ?? 'Asking Ayda…'}</Text>
310
311    const verdict = (key: string, label: string, loop: Loop, status: 'open' | 'done' | 'dismissed', primary = false) =>
312      primary ? (
313        <Button key={key} label={label} variant="primary" onPress={() => void decide($, loop, status)} />
314      ) : (
315        <Button key={key} label={label} dimColor onPress={() => void decide($, loop, status)} />
316      )
317
318    // The member's own day, as YYYY-MM-DD: a due date before it is overdue.
319    const today = new Date().toLocaleDateString('en-CA')
320
321    const group = (name: string, title: string, list: Loop[], total: number) => (
322      <Box flexDirection="column" marginTop={1}>
323        <Text bold>
324          {title}
325          <Text dimColor> · {total > list.length ? `${list.length} of ${total}` : total}</Text>
326        </Text>
327        {list.length === 0 && <Text dimColor>Nothing here.</Text>}
328        {/* The loops that wait for a verdict come first. The sort is stable. */}
329        {[...list]
330          .sort((a, b) => Number(b.looksDone) - Number(a.looksDone))
331          .map((loop, index) => (
332            <Box flexDirection="column" marginTop={1}>
333              <Text>{loop.title}</Text>
334              <Text dimColor>
335                {loop.due === undefined ? (
336                  ''
337                ) : name !== 'done' && loop.due < today ? (
338                  <Text color={WARNING}>overdue {loop.due}</Text>
339                ) : (
340                  `due ${loop.due}`
341                )}
342                {loop.due && (loop.meta || loop.raised) ? ' · ' : ''}
343                {loop.meta}
344                {loop.raised && loop.meta ? ' · ' : ''}
345                {loop.raised?.url ? (
346                  <Link href={loop.raised.url} label={loop.raised.title} />
347                ) : (
348                  (loop.raised?.title ?? '')
349                )}
350              </Text>
351              {loop.evidence && (
352                <Text dimColor italic>
353                  “{loop.evidence}”
354                </Text>
355              )}
356              {loop.looksDone && name !== 'done' && (
357                <Text color={WARNING}>Looks done: {loop.reason ?? 'Ayda found a later record that closes it.'}</Text>
358              )}
359              {name === 'done' ? (
360                <Box>{verdict(`${name}-${index}-open`, 'Reopen', loop, 'open')}</Box>
361              ) : (
362                <Box columnGap={1}>
363                  {verdict(`${name}-${index}-done`, loop.looksDone ? 'Confirm done' : 'Done', loop, 'done', loop.looksDone)}
364                  {loop.looksDone && verdict(`${name}-${index}-open`, 'Not done', loop, 'open')}
365                  {verdict(`${name}-${index}-dismissed`, 'Dismiss', loop, 'dismissed')}
366                </Box>
367              )}
368            </Box>
369          ))}
370      </Box>
371    )
372
373    const held = loops.your_move.length + loops.waiting.length
374
375    return (
376      <Box flexDirection="column">
377        <Box columnGap={1}>
378          <Text dimColor>
379            {loops.counts.open} open · {loops.counts.done} done
380          </Text>
381          {busy ? (
382            <Text dimColor>Asking Ayda…</Text>
383          ) : (
384            <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => void refresh($)} />
385          )}
386        </Box>
387        {error && <Text color={WARNING}>{error}</Text>}
388        {group('move', 'Your move', loops.your_move, loops.counts.your_move)}
389        {group('waiting', 'Awaiting others', loops.waiting, loops.counts.waiting)}
390        {group('done', 'Done', loops.done, loops.counts.done)}
391        {held < loops.counts.open && (
392          <Box marginTop={1}>
393            <Text dimColor>For the other loops, ask Claude to sweep your open loops.</Text>
394          </Box>
395        )}
396      </Box>
397    )
398  })
399
400  on('ui.render', { component: 'ToolResult', props: { tool: ANSWERS } }, ($, e, next) => {
401    const answer = e.props.isErrored ? null : readAnswer(e.props.output)
402    if (answer === null) return next(e)
403
404    const { Box, Link, Text } = $.ui.resolve(e)
405    const assertions = answer.citations.flatMap(citation => citation.temporal_assertions ?? [])
406    const status = answer.temporal?.status
407    const shown = answer.citations.slice(0, CITATIONS_SHOWN)
408
409    return (
410      <Box flexDirection="column">
411        <Text dimColor>
412          Ayda · {answer.citations.length} sources · {shown.length} shown
413          {status && status !== 'no_temporal_state' ? ` · ${status.replace(/_/g, ' ')}` : ''}
414        </Text>
415        {assertions.some(one => one.conflict_status === 'authority_conflict') && (
416          <Text color={WARNING}>Two sources state different current values. Read both.</Text>
417        )}
418        {assertions.some(one => one.evidence_status === 'source_changed') && (
419          <Text color={WARNING}>A quoted source changed after Ayda verified the quote.</Text>
420        )}
421        {shown.map((citation, index) => (
422          <Box columnGap={1}>
423            <Text dimColor>{index + 1}.</Text>
424            {linkable(citation.url) ? (
425              <Link href={linkable(citation.url)!} label={citation.title} />
426            ) : (
427              <Text>{citation.title}</Text>
428            )}
429            <Text dimColor>
430              {citation.source}
431              {citation.started_at ? ` · ${day(citation.started_at)}` : ''}
432            </Text>
433          </Box>
434        ))}
435      </Box>
436    )
437  })
438}
439
types/index.d.ts 38 lines
1// The open loops list as the mod holds it, read from Ayda's `open_loops` result. The server owns the full shape (`OpenLoopsResult` and
2// `open_loops_text` in the Ayda product repository).
3
4export type Loop = {
5  id: string
6  // The ask itself.
7  title: string
8  // Who it is with, and where and when it was raised.
9  meta: string
10  // The due day, as YYYY-MM-DD. Present only when the result came as
11  // structured data; the text form has it in `meta`.
12  due?: string
13  // The record that raised the loop. Present only when the result came as
14  // structured data; the text form has it in `meta`.
15  raised?: { title: string; url?: string }
16  // Ayda proposes that the loop is done and waits for the member's verdict.
17  looksDone: boolean
18  // Present only when the result came as structured data.
19  evidence?: string
20  reason?: string
21}
22
23export type Loops = {
24  your_move: Loop[]
25  waiting: Loop[]
26  done: Loop[]
27  counts: { open: number; your_move: number; waiting: number; done: number }
28}
29
30// `busy` is true while a call for the list is in flight.
31export type LoopsState = { loops: Loops | null; error: string | null; busy: boolean }
32
33declare module 'claude-code' {
34  interface PluginState {
35    ayda: { loops: LoopsState }
36  }
37}
38