Optional study-desk extras for the coa plugin: see which data lookups grounded an answer and read a passage beside the conversation. Terminal and desktop app…

coa-desk)Optional extras for the coa plugin. The skills work the same with or without it.
coa skill or agent looks data up, the answer ends with a line such as Grounded in 3 lookups: query_morphology, bible_lookup ×2 · data: opengnt, bible_bsb. When a coa skill answers without any lookup (for example after "no need to look anything up"), the line warns that the answer is unverified instead./evidence opens a pane listing every coa lookup made in the session: the tool, the passage and the datasets it returned./passage <book> <chapter:verse> [translations] shows a passage in parallel translations beside the conversation, with no model call. Example: /passage Rom 8:1-4 BSB,KJV. Translations: BSB, WEB, KJV, ASV, YLT, DBY.1co suggests 1 Corinthians).The guard that keeps coa agents off Haiku is part of coa itself, so every install has it (ADR 0006).
claude plugin install coa-desk --marketplace davebream/claude-of-alexandria
Installing coa-desk also installs coa.
coa-desk is a Claude Code mod: code that runs inside Claude Code. It needs Claude Code 2.1.296 or later.
| Where | Grounding line, panes, autocomplete |
|---|---|
claude in a terminal | yes |
| Claude Desktop, Code tab | yes |
VS Code extension, claude -p, Agent SDK | no; /evidence and /passage print their text instead |
Anthropic can switch mods off for an account or organisation. When that happens, coa-desk does nothing and coa keeps working.
Mods run with your permissions and are not sandboxed. coa-desk:
/passage requests to https://coa.davebream.com/mcp, the same public server the coa plugin uses, and nowhere else;coa tool calls and coa skill text, and changes neither.To check this yourself, run claude plugin validate plugins/coa-desk and read its hooks: and calls: lines.
claude plugin test plugins/coa-desk (needs a signed-in Claude Code with mods available).node --test tests/desk/*.test.mjs.hooks/books.ts is generated from the server's book table by node scripts/generate-desk-books.mjs. A test fails when the two drift.hooks/register.ts 150 lines1// coa-desk: an opt-in companion to the coa plugin. Nothing here is required for
2// the coa skills to work; see docs/adr/0005-companion-mods-plugin.md.
3//
4// - A line under each answer names the coa lookups that grounded it, or warns
5// when a coa skill answered without any.
6// - /evidence lists the session's lookups; /passage <ref> shows parallel text.
7// - Book names autocomplete in the prompt.
8// The guard that keeps coa agents off Haiku lives in coa itself (docs/adr/0006).
9
10import { BOOKS } from './books'
11import { groundingWarning } from './grounding'
12import { createLedger } from './ledger'
13import { MCP_HEADERS, MCP_URL, parseRpcResponse, rpcBody } from './mcp'
14import { renderPassage } from './passage'
15import { datasetIdsOf } from './provenance'
16import { parseReference, suggestBooks, USAGE } from './reference'
17
18const COA_TOOL = /^mcp__plugin_coa_coa__/
19const EVIDENCE_PANE = 'coa-evidence'
20const PASSAGE_PANE = 'coa-passage'
21
22const ledger = createLedger()
23let passageMarkdown = ''
24
25function targetOf(e: Record<string, unknown>): string {
26 if (typeof e.book === 'string') {
27 return typeof e.range === 'string' ? `${e.book} ${e.range}` : e.book
28 }
29 const first = Object.entries(e).find(
30 ([key, value]) => !['tool', 'tool_use_id', 'agentId'].includes(key) && typeof value === 'string',
31 )
32 return first ? String(first[1]) : ''
33}
34
35function evidenceMarkdown(): string {
36 const entries = ledger.sessionEntries()
37 if (entries.length === 0) return 'No coa lookups yet in this session.'
38 return entries
39 .map((entry) => {
40 const where = entry.target ? ` ${entry.target}` : ''
41 const data = entry.datasetIds.length > 0 ? ` (${entry.datasetIds.join(', ')})` : ''
42 const failed = entry.isError ? ' [failed]' : ''
43 const who = entry.agentId ? ' [subagent]' : ''
44 return `- ${entry.tool}${where}${data}${failed}${who}`
45 })
46 .join('\n')
47}
48
49export function register(on) {
50 on('session.start', async ($, e, next) => {
51 try {
52 await $.command.register({ name: 'evidence', description: 'List the coa data lookups made in this session', immediate: true })
53 await $.command.register({ name: 'passage', description: 'Show a Bible passage in parallel translations', argumentHint: '<book> <chapter:verse> [BSB,KJV,…]', immediate: true })
54 } catch (error) {
55 await $.ui.log(`coa-desk could not add its commands: ${String(error)}`)
56 }
57 return next(e)
58 })
59
60 on('session.end', async ($, e, next) => {
61 ledger.reset()
62 return next(e)
63 })
64
65 on('turn.start', async ($, e, next) => {
66 ledger.startTurn()
67 return next(e)
68 })
69
70 on('skill.prompt', { skill: /^coa:/ }, async ($, e, next) => {
71 ledger.noteSkill(e.skill)
72 return next(e)
73 })
74
75 on('tool.call', { tool: /^mcp__plugin_coa_coa__/ }, async ($, e, next) => {
76 const result = await next(e)
77 ledger.record({
78 tool: String(e.tool).replace(COA_TOOL, ''),
79 target: targetOf(e),
80 datasetIds: datasetIdsOf(result),
81 isError: Boolean(result?.isError || result?.deny),
82 agentId: e.agentId,
83 })
84 $.ui.invalidate('ui.render')
85 return result
86 // Observe only: if recording fails, the tool result still reaches Claude.
87 }).catch(async ($, e, next) => next(e))
88
89 on('turn.complete', async ($, e, next) => {
90 const result = await next(e)
91 if (e.agentId) return result
92 const summary =
93 ledger.turnSummary() ??
94 groundingWarning({ skills: ledger.turnSkills(), lookups: ledger.turnLookups(), answer: String(e.answer ?? '') })
95 if (!summary) return result
96 const earlier = result?.text && result.text !== e.answer ? `${result.text}\n` : ''
97 return { ...result, text: `${earlier}${summary}` }
98 })
99
100 on('command.run', { command: 'evidence' }, async ($) => {
101 const opened = await $.ui.open({ id: EVIDENCE_PANE, title: 'coa evidence', closeOnEscape: true })
102 if (opened?.isPlaced) return {}
103 return { text: evidenceMarkdown() }
104 })
105
106 on('command.run', { command: 'passage' }, async ($, e) => {
107 const reference = parseReference(e.args ?? '', BOOKS)
108 if ('error' in reference) {
109 await $.ui.toast(reference.error)
110 return {}
111 }
112 const args: Record<string, unknown> = { book: reference.book, range: reference.range, page_size: 200 }
113 if (reference.translations.length > 0) args.translations = reference.translations
114 let response
115 try {
116 response = await $.http.fetch(MCP_URL, { method: 'POST', headers: MCP_HEADERS, body: rpcBody('parallel_text', args) })
117 } catch (error) {
118 await $.ui.toast(`Could not reach the coa server: ${String(error)}`)
119 return {}
120 }
121 if (!response.ok) {
122 await $.ui.toast(`The coa server answered ${response.status}. ${USAGE}`)
123 return {}
124 }
125 const parsed = parseRpcResponse(response.text)
126 if (parsed.isError) {
127 await $.ui.toast(parsed.message)
128 return {}
129 }
130 passageMarkdown = renderPassage(parsed.payload)
131 const opened = await $.ui.open({ id: PASSAGE_PANE, title: `${reference.book} ${reference.range}`, closeOnEscape: true })
132 $.ui.invalidate('ui.render')
133 if (opened?.isPlaced) return {}
134 return { text: passageMarkdown }
135 })
136
137 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
138 if (e.requestId !== EVIDENCE_PANE && e.requestId !== PASSAGE_PANE) return next(e)
139 const { Markdown } = $.ui.resolve(e)
140 const text = e.requestId === EVIDENCE_PANE ? evidenceMarkdown() : passageMarkdown
141 return Markdown({ key: e.requestId, text })
142 })
143
144 on('prompt.autocomplete', async ($, e, next) => {
145 const suggestions = suggestBooks(String(e.token ?? ''), BOOKS)
146 if (suggestions.length === 0) return next(e)
147 return { suggestions }
148 })
149}
150hooks/books.ts 71 lines1// Generated by scripts/generate-desk-books.mjs from server/src/db/books.ts. Do not edit directly.
2// Aliases are lower case with spaces, hyphens and underscores removed.
3export const BOOKS = [
4 { name: "Genesis", testament: "ot", aliases: ["genesis","gen"] },
5 { name: "Exodus", testament: "ot", aliases: ["exodus","exod","ex"] },
6 { name: "Leviticus", testament: "ot", aliases: ["leviticus","lev"] },
7 { name: "Numbers", testament: "ot", aliases: ["numbers","num"] },
8 { name: "Deuteronomy", testament: "ot", aliases: ["deuteronomy","deut","dt"] },
9 { name: "Joshua", testament: "ot", aliases: ["joshua","josh"] },
10 { name: "Judges", testament: "ot", aliases: ["judges","judg"] },
11 { name: "Ruth", testament: "ot", aliases: ["ruth"] },
12 { name: "1 Samuel", testament: "ot", aliases: ["1samuel","1sam"] },
13 { name: "2 Samuel", testament: "ot", aliases: ["2samuel","2sam"] },
14 { name: "1 Kings", testament: "ot", aliases: ["1kings","1kgs"] },
15 { name: "2 Kings", testament: "ot", aliases: ["2kings","2kgs"] },
16 { name: "1 Chronicles", testament: "ot", aliases: ["1chronicles","1chr"] },
17 { name: "2 Chronicles", testament: "ot", aliases: ["2chronicles","2chr"] },
18 { name: "Ezra", testament: "ot", aliases: ["ezra"] },
19 { name: "Nehemiah", testament: "ot", aliases: ["nehemiah","neh"] },
20 { name: "Esther", testament: "ot", aliases: ["esther","esth"] },
21 { name: "Job", testament: "ot", aliases: ["job"] },
22 { name: "Psalms", testament: "ot", aliases: ["psalms","psalm","ps"] },
23 { name: "Proverbs", testament: "ot", aliases: ["proverbs","prov"] },
24 { name: "Ecclesiastes", testament: "ot", aliases: ["ecclesiastes","eccl","qoh"] },
25 { name: "Song of Songs", testament: "ot", aliases: ["songofsolomon","songofsongs","canticles","ss"] },
26 { name: "Isaiah", testament: "ot", aliases: ["isaiah","isa"] },
27 { name: "Jeremiah", testament: "ot", aliases: ["jeremiah","jer"] },
28 { name: "Lamentations", testament: "ot", aliases: ["lamentations","lam"] },
29 { name: "Ezekiel", testament: "ot", aliases: ["ezekiel","ezek"] },
30 { name: "Daniel", testament: "ot", aliases: ["daniel","dan"] },
31 { name: "Hosea", testament: "ot", aliases: ["hosea","hos"] },
32 { name: "Joel", testament: "ot", aliases: ["joel"] },
33 { name: "Amos", testament: "ot", aliases: ["amos"] },
34 { name: "Obadiah", testament: "ot", aliases: ["obadiah","obad"] },
35 { name: "Jonah", testament: "ot", aliases: ["jonah","jon"] },
36 { name: "Micah", testament: "ot", aliases: ["micah","mic"] },
37 { name: "Nahum", testament: "ot", aliases: ["nahum","nah"] },
38 { name: "Habakkuk", testament: "ot", aliases: ["habakkuk","hab"] },
39 { name: "Zephaniah", testament: "ot", aliases: ["zephaniah","zeph"] },
40 { name: "Haggai", testament: "ot", aliases: ["haggai","hag"] },
41 { name: "Zechariah", testament: "ot", aliases: ["zechariah","zech"] },
42 { name: "Malachi", testament: "ot", aliases: ["malachi","mal"] },
43 { name: "Matthew", testament: "nt", aliases: ["matthew","matt"] },
44 { name: "Mark", testament: "nt", aliases: ["mark"] },
45 { name: "Luke", testament: "nt", aliases: ["luke"] },
46 { name: "John", testament: "nt", aliases: ["john"] },
47 { name: "Acts", testament: "nt", aliases: ["acts"] },
48 { name: "Romans", testament: "nt", aliases: ["romans","rom"] },
49 { name: "1 Corinthians", testament: "nt", aliases: ["1corinthians","1cor"] },
50 { name: "2 Corinthians", testament: "nt", aliases: ["2corinthians","2cor"] },
51 { name: "Galatians", testament: "nt", aliases: ["galatians","gal"] },
52 { name: "Ephesians", testament: "nt", aliases: ["ephesians","eph"] },
53 { name: "Philippians", testament: "nt", aliases: ["philippians","phil"] },
54 { name: "Colossians", testament: "nt", aliases: ["colossians","col"] },
55 { name: "1 Thessalonians", testament: "nt", aliases: ["1thessalonians","1thess","1th"] },
56 { name: "2 Thessalonians", testament: "nt", aliases: ["2thessalonians","2thess","2th"] },
57 { name: "1 Timothy", testament: "nt", aliases: ["1timothy","1tim"] },
58 { name: "2 Timothy", testament: "nt", aliases: ["2timothy","2tim"] },
59 { name: "Titus", testament: "nt", aliases: ["titus","tit"] },
60 { name: "Philemon", testament: "nt", aliases: ["philemon","phlm","phm"] },
61 { name: "Hebrews", testament: "nt", aliases: ["hebrews","heb"] },
62 { name: "James", testament: "nt", aliases: ["james","jas"] },
63 { name: "1 Peter", testament: "nt", aliases: ["1peter","1pet","1pe"] },
64 { name: "2 Peter", testament: "nt", aliases: ["2peter","2pet","2pe"] },
65 { name: "1 John", testament: "nt", aliases: ["1john","1jn"] },
66 { name: "2 John", testament: "nt", aliases: ["2john","2jn"] },
67 { name: "3 John", testament: "nt", aliases: ["3john","3jn"] },
68 { name: "Jude", testament: "nt", aliases: ["jude"] },
69 { name: "Revelation", testament: "nt", aliases: ["revelation","rev"] },
70]
71hooks/grounding.ts 25 lines1// Decides when to flag an answer that a coa skill gave without any coa data
2// lookups. Under "no need to look anything up" pressure, the skills answer from
3// memory despite their MCP-first rule (docs/adr/0005, RED probe).
4
5// Skills whose contract requires MCP evidence, directly or through their agents.
6export const GROUNDED_SKILLS = [
7 'coa:argument-flow',
8 'coa:biblical-segmentation',
9 'coa:consult-biblical-scholar',
10 'coa:exegetical-notes',
11 'coa:passage-glossary',
12 'coa:pericope-delimitation',
13]
14
15// Skills that legitimately run without lookups.
16export const EXEMPT_SKILLS = ['coa:smoke-test', 'coa:study-team']
17
18export function groundingWarning(turn: { skills: readonly string[]; lookups: number; answer: string }): string | null {
19 if (turn.lookups > 0) return null
20 if (turn.answer.trim().endsWith('?')) return null
21 const grounded = turn.skills.filter((skill) => GROUNDED_SKILLS.includes(skill))
22 if (grounded.length === 0) return null
23 return `⚠ ${grounded.join(', ')} answered without any coa data lookups. Treat the answer as unverified, or ask again and let it fetch the data.`
24}
25hooks/ledger.ts 68 lines1// Records the coa lookups made in a session, including the ones subagents
2// make, and summarises the current turn.
3
4export type LedgerEntry = {
5 tool: string
6 target: string
7 datasetIds: readonly string[]
8 isError: boolean
9 agentId?: string
10}
11
12export type Ledger = {
13 startTurn(): void
14 record(entry: LedgerEntry): void
15 noteSkill(skill: string): void
16 turnSkills(): readonly string[]
17 turnLookups(): number
18 turnSummary(): string | null
19 sessionEntries(): readonly LedgerEntry[]
20 reset(): void
21}
22
23export function createLedger(): Ledger {
24 let session: LedgerEntry[] = []
25 let turn: LedgerEntry[] = []
26 let skills: string[] = []
27 return {
28 startTurn() {
29 turn = []
30 skills = []
31 },
32 noteSkill(skill) {
33 if (!skills.includes(skill)) skills.push(skill)
34 },
35 turnSkills() {
36 return skills
37 },
38 turnLookups() {
39 return turn.filter((entry) => !entry.isError).length
40 },
41 record(entry) {
42 session.push(entry)
43 turn.push(entry)
44 },
45 turnSummary() {
46 const ok = turn.filter((entry) => !entry.isError)
47 const failed = turn.length - ok.length
48 if (ok.length === 0) return null
49 const counts = new Map<string, number>()
50 for (const entry of ok) counts.set(entry.tool, (counts.get(entry.tool) ?? 0) + 1)
51 const tools = [...counts].map(([tool, count]) => (count > 1 ? `${tool} ×${count}` : tool)).join(', ')
52 const datasets = [...new Set(ok.flatMap((entry) => entry.datasetIds))]
53 const parts = [`Grounded in ${ok.length} lookup${ok.length === 1 ? '' : 's'}: ${tools}`]
54 if (datasets.length > 0) parts.push(`data: ${datasets.join(', ')}`)
55 if (failed > 0) parts.push(`${failed} failed lookup${failed === 1 ? '' : 's'}`)
56 return parts.join(' · ')
57 },
58 sessionEntries() {
59 return session
60 },
61 reset() {
62 session = []
63 turn = []
64 skills = []
65 },
66 }
67}
68hooks/mcp.ts 55 lines1// Talks to the coa MCP server with plain JSON-RPC over HTTP. The desk does not
2// declare the server itself: that would give Claude a second copy of every
3// tool. The server is stateless, so tools/call needs no session.
4
5export const MCP_URL = 'https://coa.davebream.com/mcp'
6export const MCP_HEADERS = {
7 'content-type': 'application/json',
8 accept: 'application/json, text/event-stream',
9}
10
11export type RpcResponse =
12 | { isError: false; payload: any }
13 | { isError: true; message: string }
14
15export function rpcBody(tool: string, args: Record<string, unknown>, id = 1): string {
16 return JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name: tool, arguments: args } })
17}
18
19function lastMessage(text: string): any {
20 const data = text
21 .split('\n')
22 .filter((line) => line.startsWith('data: '))
23 .map((line) => line.slice(6))
24 return JSON.parse(data.length > 0 ? data[data.length - 1] : text)
25}
26
27function errorText(content: unknown): string {
28 const text = Array.isArray(content) ? content.find((block) => block?.type === 'text')?.text : undefined
29 if (typeof text !== 'string') return 'The server returned an error'
30 try {
31 return JSON.parse(text)?.error?.message ?? text
32 } catch {
33 return text
34 }
35}
36
37export function parseRpcResponse(text: string): RpcResponse {
38 let message: any
39 try {
40 message = lastMessage(text)
41 } catch {
42 return { isError: true, message: 'The server sent a response that is not JSON' }
43 }
44 if (message?.error) return { isError: true, message: String(message.error.message ?? 'Request failed') }
45 const result = message?.result
46 if (!result) return { isError: true, message: 'The server sent no result' }
47 if (result.isError) return { isError: true, message: errorText(result.content) }
48 if (result.structuredContent) return { isError: false, payload: result.structuredContent }
49 try {
50 return { isError: false, payload: JSON.parse(result.content?.[0]?.text ?? '') }
51 } catch {
52 return { isError: true, message: 'The server sent a result without data' }
53 }
54}
55hooks/passage.ts 26 lines1// Renders a parallel_text payload as Markdown for the passage pane.
2
3type Verse = { chapter: number; verse: number; translations: Record<string, string> }
4type Dataset = { id: string; title?: string }
5
6export function renderPassage(payload: any): string {
7 const verses: Verse[] = Array.isArray(payload?.verses) ? payload.verses : []
8 const lines = [`## ${payload?.book ?? ''} ${payload?.range ?? ''}`.trim(), '']
9 for (const verse of verses) {
10 lines.push(`**${verse.chapter}:${verse.verse}**`)
11 for (const [translation, text] of Object.entries(verse.translations ?? {})) {
12 lines.push(`- ${translation}: ${text}`)
13 }
14 lines.push('')
15 }
16 if (Array.isArray(payload?.missing_verses) && payload.missing_verses.length > 0) {
17 lines.push(`Missing in some translations: ${payload.missing_verses.join(', ')}`, '')
18 }
19 if (payload?.versification_note) lines.push(`_${payload.versification_note}_`, '')
20 const datasets: Dataset[] = Array.isArray(payload?.provenance?.datasets) ? payload.provenance.datasets : []
21 if (datasets.length > 0) {
22 lines.push(`Sources: ${datasets.map((dataset) => dataset.title ?? dataset.id).join('; ')}`)
23 }
24 return lines.join('\n').trimEnd()
25}
26hooks/provenance.ts 36 lines1// Pulls dataset ids out of a coa tool result. Every successful coa response
2// carries provenance.datasets[].id; a tool.call result can hold it as an
3// object, a JSON string, an MCP content block, or wrapped in { result, text }.
4
5function fromValue(value: unknown, depth: number): string[] {
6 if (depth > 4 || value === null || value === undefined) return []
7 if (typeof value === 'string') {
8 try {
9 return fromValue(JSON.parse(value), depth + 1)
10 } catch {
11 return []
12 }
13 }
14 if (typeof value !== 'object') return []
15 const record = value as Record<string, any>
16 const datasets = record.provenance?.datasets
17 if (Array.isArray(datasets)) {
18 return datasets.map((dataset) => dataset?.id).filter((id): id is string => typeof id === 'string')
19 }
20 for (const key of ['structuredContent', 'text', 'result']) {
21 const ids = fromValue(record[key], depth + 1)
22 if (ids.length > 0) return ids
23 }
24 if (Array.isArray(record.content)) {
25 for (const block of record.content) {
26 const ids = fromValue(block?.text, depth + 1)
27 if (ids.length > 0) return ids
28 }
29 }
30 return []
31}
32
33export function datasetIdsOf(value: unknown): string[] {
34 return [...new Set(fromValue(value, 0))]
35}
36hooks/reference.ts 51 lines1// Parses "/passage" arguments and suggests book names. A leaf module: the
2// book table is passed in, so tests can import this file on its own.
3
4export type Book = { name: string; testament: string; aliases: readonly string[] }
5export type Reference = { book: string; range: string; translations: string[] }
6export type Suggestion = { text: string; label: string; description: string }
7
8export const TRANSLATIONS = ['BSB', 'WEB', 'KJV', 'ASV', 'YLT', 'DBY']
9export const USAGE = 'Usage: /passage <book> <chapter:verse[-verse]> [translations], for example /passage Rom 8:1-4 BSB,KJV'
10
11const REFERENCE = /^\s*((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s*(\d+:\d+(?:-\d+(?::\d+)?)?)\s*(.*)$/
12
13export function normalizeBook(text: string): string {
14 return text.toLowerCase().replace(/[\s._-]+/g, '')
15}
16
17export function findBook(text: string, books: readonly Book[]): Book | undefined {
18 const key = normalizeBook(text)
19 return books.find((book) => book.aliases.includes(key) || normalizeBook(book.name) === key)
20}
21
22export function parseReference(text: string, books: readonly Book[]): Reference | { error: string } {
23 if (!text.trim()) return { error: USAGE }
24 const match = REFERENCE.exec(text)
25 if (!match) {
26 return { error: `Give a chapter:verse range after the book. ${USAGE}` }
27 }
28 const [, bookText, range, rest] = match
29 const book = findBook(bookText, books)
30 if (!book) return { error: `Unknown book "${bookText.trim()}". ${USAGE}` }
31 const translations = rest.split(/[\s,]+/).filter(Boolean).map((item) => item.toUpperCase())
32 const unknown = translations.filter((item) => !TRANSLATIONS.includes(item))
33 if (unknown.length > 0) {
34 return { error: `Unknown translation ${unknown.join(', ')}. Available: ${TRANSLATIONS.join(', ')}.` }
35 }
36 return { book: book.name, range, translations }
37}
38
39export function suggestBooks(token: string, books: readonly Book[], limit = 5): Suggestion[] {
40 const key = normalizeBook(token)
41 if (key.length < 2 || !/^[1-3]?[a-z]/.test(key)) return []
42 return books
43 .filter((book) => book.aliases.some((alias) => alias.startsWith(key)) || normalizeBook(book.name).startsWith(key))
44 .slice(0, limit)
45 .map((book) => ({
46 text: book.name,
47 label: book.name,
48 description: book.testament === 'ot' ? 'Old Testament' : 'New Testament',
49 }))
50}
51