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


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.
Every day
| Skill | What it does |
|---|---|
ayda-daily-brief | Starts your day: what changed, what you owe, what others owe you, and what looks done. |
ayda-loop-sweep | Goes through your open loops in small batches and records your verdict on each. |
ayda-remember | Keeps the decisions and commitments from a conversation as your own Ayda memories. |
When you need an answer you can trust
| Skill | What it does |
|---|---|
ayda-decision-trace | Shows how and why a decision was made, and how it changed, with citations. |
ayda-fact-check | Checks a plan, document or change against recorded company facts. |
ayda-onboarding | Writes a cited brief on a role, team or project, with a reading list. |
ayda-guide | Holds 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.
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)]
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)
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.
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.
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.
plugin/ is the plugin itself: the manifest, icon and skills. It is what Claude installs and what the Claude directory reads.scripts/, CI and this README, supports its development and is not installed.Ideas for new skills and fixes are welcome. Read CONTRIBUTING.md first. To report a security problem, read SECURITY.md.
hooks/register.tsx 439 lines1// 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}
439types/index.d.ts 38 lines1// 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