Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.ivory.finance/llms.txt

Use this file to discover all available pages before exploring further.

** All API endpoints, WebSocket server, smart block resolvers, and AI orchestrator tools are live at https://api.ivory.finance. This guide is the handoff spec for the Next.js frontend.

Stack

LayerTechnologyVersion
Editor engineTiptap^2.x
Real-time CRDTYjs^13.x
WebSocket provider@hocuspocus/provider^2.x
ChartsPlotly.js^2.x
FrameworkNext.js App Router^14
StylingTailwind CSS

1. Install dependencies

npm install \
  @tiptap/core \
  @tiptap/react \
  @tiptap/starter-kit \
  @tiptap/extension-collaboration \
  @tiptap/extension-collaboration-cursor \
  @tiptap/extension-placeholder \
  @tiptap/extension-typography \
  @tiptap/extension-underline \
  @tiptap/extension-text-align \
  @tiptap/extension-table \
  @tiptap/extension-table-row \
  @tiptap/extension-table-cell \
  @tiptap/extension-table-header \
  @hocuspocus/provider \
  yjs \
  plotly.js-dist \
  react-plotly.js

2. File structure

app/
  editor/
    [docId]/
      page.tsx               ← editor page (SSR shell, client editor)
      loading.tsx
components/
  editor/
    IvoryEditor.tsx          ← main editor component
    EditorToolbar.tsx        ← formatting toolbar
    SlashMenu.tsx            ← / command block inserter
    CollabCursors.tsx        ← other users' cursors
    blocks/
      SecCitationBlock.tsx   ← NodeView for sec_citation
      MarketDataBlock.tsx    ← NodeView for market_data
      AiInsightBlock.tsx     ← NodeView for ai_insight
      SqlQueryBlock.tsx      ← NodeView for sql_query
      IvoryChartBlock.tsx    ← NodeView for ivory_chart
    extensions/
      SecCitationExtension.ts
      MarketDataExtension.ts
      AiInsightExtension.ts
      SqlQueryExtension.ts
      IvoryChartExtension.ts
    sidebar/
      DocumentSidebar.tsx    ← visibility toggle, version history, sharing
      OrchestratorPanel.tsx  ← AI chat sidebar
lib/
  api/
    editor.ts                ← API client for document CRUD
    blocks.ts                ← smart block resolver calls
    orchestrator.ts          ← orchestrator SSE consumer
  hooks/
    useDocument.ts
    useBlockResolver.ts
    useOrchestrator.ts

3. Editor page

// app/editor/[docId]/page.tsx
import { IvoryEditor } from '@/components/editor/IvoryEditor'
import { getDocument } from '@/lib/api/editor'

export default async function EditorPage({ params }: { params: { docId: string } }) {
  // Server-side: fetch doc metadata for SEO + initial hydration
  const doc = await getDocument(params.docId)

  return (
    <div className="flex h-screen overflow-hidden">
      <main className="flex-1 overflow-y-auto">
        <IvoryEditor
          docId={params.docId}
          initialTitle={doc.title}
          initialAst={doc.doc_ast}
          visibility={doc.visibility}
          myRole={doc.my_role}
        />
      </main>
    </div>
  )
}

4. Main editor component

// components/editor/IvoryEditor.tsx
'use client'

import { useEffect, useMemo, useRef } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import Placeholder from '@tiptap/extension-placeholder'
import * as Y from 'yjs'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { useSession } from 'next-auth/react'  // or your auth solution

// Custom extensions
import { SecCitationExtension } from './extensions/SecCitationExtension'
import { MarketDataExtension }   from './extensions/MarketDataExtension'
import { AiInsightExtension }    from './extensions/AiInsightExtension'
import { SqlQueryExtension }     from './extensions/SqlQueryExtension'
import { IvoryChartExtension }   from './extensions/IvoryChartExtension'

import { EditorToolbar }    from './EditorToolbar'
import { SlashMenu }        from './SlashMenu'
import { useAutoSave }      from '@/lib/hooks/useDocument'

const COLLAB_WS_URL = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL!  // wss://collab.ivory.finance

interface Props {
  docId:        string
  initialTitle: string
  initialAst:   object
  visibility:   'private' | 'workspace' | 'public'
  myRole:       'owner' | 'editor' | 'commenter' | 'viewer'
}

export function IvoryEditor({ docId, initialTitle, initialAst, visibility, myRole }: Props) {
  const { data: session } = useSession()
  const ydoc = useMemo(() => new Y.Doc(), [docId])

  // ── Hocuspocus provider ──────────────────────────────────────────────────
  const provider = useMemo(() => new HocuspocusProvider({
    url:      COLLAB_WS_URL,
    name:     docId,           // room = document UUID
    document: ydoc,
    token:    session?.accessToken ?? '',
    onAuthenticationFailed: ({ reason }) => {
      console.error('Collab auth failed:', reason)
    },
  }), [docId, session?.accessToken])

  useEffect(() => () => provider.destroy(), [provider])

  // ── Editor ───────────────────────────────────────────────────────────────
  const isReadOnly = myRole === 'viewer' || myRole === 'commenter'

  const editor = useEditor({
    editable: !isReadOnly,
    extensions: [
      StarterKit.configure({ history: false }),   // history disabled — Yjs handles undo
      Collaboration.configure({ document: ydoc }),
      CollaborationCursor.configure({
        provider,
        user: {
          name:  session?.user?.name ?? 'Anonymous',
          color: '#0F766E',
        },
      }),
      Placeholder.configure({ placeholder: 'Start writing, or press / for commands…' }),

      // Smart block extensions
      SecCitationExtension,
      MarketDataExtension,
      AiInsightExtension,
      SqlQueryExtension,
      IvoryChartExtension,

      // Slash command menu
      SlashMenu,
    ],
    content: initialAst,
  })

  // ── Auto-save doc_ast to API ─────────────────────────────────────────────
  useAutoSave(editor, docId)

  return (
    <div className="max-w-4xl mx-auto px-8 py-12">
      <EditorToolbar editor={editor} />
      <EditorContent editor={editor} className="prose prose-slate max-w-none" />
    </div>
  )
}

5. Auto-save hook

Syncs doc_ast back to the API after the user stops typing. This makes the document readable by AI agents.
// lib/hooks/useDocument.ts
import { useEffect, useRef } from 'react'
import { Editor } from '@tiptap/core'
import { updateDocument } from '@/lib/api/editor'

export function useAutoSave(editor: Editor | null, docId: string, debounceMs = 2000) {
  const timer = useRef<ReturnType<typeof setTimeout>>()

  useEffect(() => {
    if (!editor) return

    const handler = () => {
      clearTimeout(timer.current)
      timer.current = setTimeout(async () => {
        const ast = editor.getJSON()
        await updateDocument(docId, { doc_ast: ast })
      }, debounceMs)
    }

    editor.on('update', handler)
    return () => {
      editor.off('update', handler)
      clearTimeout(timer.current)
    }
  }, [editor, docId, debounceMs])
}

6. API client

// lib/api/editor.ts
const BASE = process.env.NEXT_PUBLIC_API_URL  // https://api.ivory.finance

async function apiFetch(path: string, init?: RequestInit) {
  const token = getToken()  // from your auth provider / cookie
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
      ...init?.headers,
    },
  })
  if (!res.ok) throw new Error(await res.text())
  return res.json()
}

export const getDocument    = (id: string)          => apiFetch(`/v1/editor/documents/${id}`)
export const listDocuments  = (wsId?: string)       => apiFetch(`/v1/editor/documents${wsId ? `?workspace_id=${wsId}` : ''}`)
export const createDocument = (body: object)        => apiFetch('/v1/editor/documents', { method: 'POST', body: JSON.stringify(body) })
export const updateDocument = (id: string, b: object) => apiFetch(`/v1/editor/documents/${id}`, { method: 'PATCH', body: JSON.stringify(b) })
export const deleteDocument = (id: string)          => apiFetch(`/v1/editor/documents/${id}`, { method: 'DELETE' })

export const listWorkspaces    = ()                           => apiFetch('/v1/editor/workspaces')
export const createWorkspace   = (body: object)              => apiFetch('/v1/editor/workspaces', { method: 'POST', body: JSON.stringify(body) })
export const addWorkspaceMember = (wsId: string, body: object) => apiFetch(`/v1/editor/workspaces/${wsId}/members`, { method: 'POST', body: JSON.stringify(body) })

export const listVersions   = (docId: string)         => apiFetch(`/v1/editor/documents/${docId}/versions`)
export const createVersion  = (docId: string, label?: string) => apiFetch(`/v1/editor/documents/${docId}/versions`, { method: 'POST', body: JSON.stringify({ label }) })
export const getVersion     = (docId: string, num: number)    => apiFetch(`/v1/editor/documents/${docId}/versions/${num}`)

7. Smart block extensions

Each smart block is a Tiptap Node extension (marks the node as atom: true so it’s treated as a single non-editable unit) paired with a React NodeView for rich rendering.

7a. SecCitation extension

// components/editor/extensions/SecCitationExtension.ts
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer }  from '@tiptap/react'
import { SecCitationBlock }       from '../blocks/SecCitationBlock'

export const SecCitationExtension = Node.create({
  name: 'sec_citation',
  group: 'block',
  atom: true,
  draggable: true,

  addAttributes() {
    return {
      block_id:         { default: null },
      status:           { default: 'pending' },
      error:            { default: null },
      accession_number: { default: null },
      cik:              { default: null },
      company_name:     { default: null },
      form_type:        { default: null },
      filed_date:       { default: null },
      period_end:       { default: null },
      query:            { default: null },
      excerpts:         { default: [] },
      filing_url:       { default: null },
      markdown_url:     { default: null },
      resolved_at:      { default: null },
    }
  },

  parseHTML()  { return [{ tag: 'div[data-type="sec_citation"]' }] },
  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'sec_citation' })]
  },

  addNodeView() { return ReactNodeViewRenderer(SecCitationBlock) },
})

7b. SecCitation NodeView renderer

// components/editor/blocks/SecCitationBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { ExternalLink, FileText, ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'

export function SecCitationBlock({ node, selected }: any) {
  const a = node.attrs
  const [expanded, setExpanded] = useState(false)

  if (a.status === 'pending' || a.status === 'loading') {
    return (
      <NodeViewWrapper>
        <div className="rounded-lg border border-slate-200 p-4 animate-pulse bg-slate-50">
          <div className="h-4 bg-slate-200 rounded w-1/3 mb-2" />
          <div className="h-3 bg-slate-200 rounded w-2/3" />
        </div>
      </NodeViewWrapper>
    )
  }

  if (a.status === 'error') {
    return (
      <NodeViewWrapper>
        <div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700 text-sm">
          ⚠️ {a.error || 'Failed to load citation'}
        </div>
      </NodeViewWrapper>
    )
  }

  return (
    <NodeViewWrapper>
      <div className={`rounded-lg border p-4 my-2 transition-shadow ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200 hover:border-slate-300'}`}>

        {/* Header */}
        <div className="flex items-start justify-between gap-3 mb-3">
          <div className="flex items-center gap-2">
            <FileText className="w-4 h-4 text-teal-600 shrink-0" />
            <div>
              <span className="font-semibold text-sm text-slate-800">{a.company_name}</span>
              <span className="ml-2 text-xs font-medium bg-teal-100 text-teal-700 px-2 py-0.5 rounded">
                {a.form_type}
              </span>
            </div>
          </div>
          <a
            href={a.filing_url}
            target="_blank"
            rel="noopener noreferrer"
            className="text-slate-400 hover:text-teal-600 transition-colors"
          >
            <ExternalLink className="w-4 h-4" />
          </a>
        </div>

        {/* Meta */}
        <div className="text-xs text-slate-500 mb-3">
          Filed {a.filed_date} · Period ending {a.period_end}
          {a.query && <span> · Query: <em>{a.query}</em></span>}
        </div>

        {/* Excerpts */}
        {a.excerpts?.length > 0 && (
          <div>
            <blockquote className="border-l-4 border-teal-400 pl-3 text-sm text-slate-700 italic leading-relaxed">
              {a.excerpts[0].heading && (
                <div className="not-italic font-medium text-xs text-slate-500 mb-1 uppercase tracking-wide">
                  {a.excerpts[0].heading}
                </div>
              )}
              {a.excerpts[0].text}
            </blockquote>

            {a.excerpts.length > 1 && (
              <button
                onClick={() => setExpanded(!expanded)}
                className="mt-2 flex items-center gap-1 text-xs text-teal-600 hover:text-teal-800"
              >
                {expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
                {expanded ? 'Show less' : `+${a.excerpts.length - 1} more excerpts`}
              </button>
            )}

            {expanded && a.excerpts.slice(1).map((ex: any, i: number) => (
              <blockquote key={i} className="mt-3 border-l-4 border-slate-200 pl-3 text-sm text-slate-600 italic leading-relaxed">
                {ex.heading && <div className="not-italic font-medium text-xs text-slate-400 mb-1 uppercase">{ex.heading}</div>}
                {ex.text}
              </blockquote>
            ))}
          </div>
        )}

        {/* Score badge on last excerpt */}
        {a.excerpts?.[0]?.score && (
          <div className="mt-2 text-right">
            <span className="text-xs text-slate-400">
              Relevance {(a.excerpts[0].score * 100).toFixed(0)}%
            </span>
          </div>
        )}
      </div>
    </NodeViewWrapper>
  )
}

7c. MarketData NodeView

// components/editor/blocks/MarketDataBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { TrendingUp } from 'lucide-react'

const METRIC_LABELS: Record<string, string> = {
  revenue_ttm_usd_millions:          'Revenue TTM',
  net_income_ttm_usd_millions:       'Net Income TTM',
  total_assets_usd_millions:         'Total Assets',
  total_liabilities_usd_millions:    'Total Liabilities',
  cash_and_equivalents_usd_millions: 'Cash & Equivalents',
  operating_income_ttm_usd_millions: 'Operating Income TTM',
  eps_ttm:                           'EPS (TTM)',
  net_margin:                        'Net Margin',
}

export function MarketDataBlock({ node, selected }: any) {
  const a = node.attrs
  const metrics = a.metrics || {}

  return (
    <NodeViewWrapper>
      <div className={`rounded-lg border p-4 my-2 ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
        <div className="flex items-center gap-2 mb-3">
          <TrendingUp className="w-4 h-4 text-teal-600" />
          <span className="font-semibold text-sm text-slate-800">{a.company_name}</span>
          {a.ticker && <span className="text-xs font-mono bg-slate-100 px-2 py-0.5 rounded">{a.ticker}</span>}
        </div>
        <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
          {Object.entries(metrics).map(([key, value]: [string, any]) =>
            value != null && (
              <div key={key} className="bg-slate-50 rounded p-2">
                <div className="text-xs text-slate-500 mb-0.5">{METRIC_LABELS[key] ?? key}</div>
                <div className="text-sm font-semibold text-slate-800">
                  {key === 'net_margin' ? `${value}%`
                    : key === 'eps_ttm'  ? `$${value}`
                    : `$${(value as number).toLocaleString()}M`}
                </div>
              </div>
            )
          )}
        </div>
        <div className="mt-2 text-right text-xs text-slate-400">
          Refreshed {new Date(a.refreshed_at).toLocaleDateString()}
        </div>
      </div>
    </NodeViewWrapper>
  )
}

7d. AiInsight NodeView

// components/editor/blocks/AiInsightBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { Sparkles } from 'lucide-react'
import ReactMarkdown from 'react-markdown'  // npm install react-markdown

export function AiInsightBlock({ node, selected }: any) {
  const a = node.attrs

  return (
    <NodeViewWrapper>
      <div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-violet-400 shadow-md' : 'border-slate-200'}`}>
        <div className="bg-violet-50 border-b border-violet-100 px-4 py-2 flex items-center gap-2">
          <Sparkles className="w-4 h-4 text-violet-600" />
          <span className="text-xs font-medium text-violet-700">AI Insight</span>
          <span className="ml-auto text-xs text-violet-400">{a.model}</span>
        </div>
        <div className="p-4 prose prose-sm prose-slate max-w-none">
          <ReactMarkdown>{a.output || ''}</ReactMarkdown>
        </div>
        {a.context_summary && (
          <div className="border-t border-slate-100 px-4 py-2 text-xs text-slate-400 italic truncate">
            Context: {a.context_summary}
          </div>
        )}
      </div>
    </NodeViewWrapper>
  )
}

7e. SqlQuery NodeView

// components/editor/blocks/SqlQueryBlock.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { Table, Code } from 'lucide-react'
import { useState } from 'react'

export function SqlQueryBlock({ node, selected }: any) {
  const a = node.attrs
  const [showSql, setShowSql] = useState(false)

  if (a.status === 'error') {
    return (
      <NodeViewWrapper>
        <div className="rounded-lg border border-red-200 bg-red-50 p-4">
          <div className="flex items-center gap-2 text-red-700 text-sm font-medium mb-2">
            <Table className="w-4 h-4" /> SQL Query Error
          </div>
          <pre className="text-xs text-red-600 font-mono">{a.error}</pre>
        </div>
      </NodeViewWrapper>
    )
  }

  return (
    <NodeViewWrapper>
      <div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
        <div className="bg-slate-50 border-b px-4 py-2 flex items-center gap-2">
          <Table className="w-4 h-4 text-slate-500" />
          <span className="text-xs font-medium text-slate-600">{a.row_count} rows</span>
          <button
            onClick={() => setShowSql(!showSql)}
            className="ml-auto flex items-center gap-1 text-xs text-slate-400 hover:text-slate-600"
          >
            <Code className="w-3 h-3" />
            {showSql ? 'Hide SQL' : 'View SQL'}
          </button>
        </div>

        {showSql && (
          <pre className="bg-slate-800 text-green-300 text-xs p-3 font-mono overflow-x-auto">
            {a.sql}
          </pre>
        )}

        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead className="bg-slate-50 border-b">
              <tr>
                {a.columns?.map((col: string) => (
                  <th key={col} className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">
                    {col}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody className="divide-y divide-slate-100">
              {a.rows?.map((row: string[], i: number) => (
                <tr key={i} className="hover:bg-slate-50">
                  {row.map((cell, j) => (
                    <td key={j} className="px-4 py-2 text-slate-700 font-mono text-xs">
                      {cell ?? '—'}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </NodeViewWrapper>
  )
}

7f. IvoryChart NodeView

// components/editor/blocks/IvoryChartBlock.tsx
'use client'
import { NodeViewWrapper }  from '@tiptap/react'
import dynamic             from 'next/dynamic'

// Dynamic import avoids SSR issues with Plotly
const Plot = dynamic(() => import('react-plotly.js'), { ssr: false })

export function IvoryChartBlock({ node, selected }: any) {
  const a = node.attrs

  return (
    <NodeViewWrapper>
      <div className={`rounded-lg border my-2 overflow-hidden ${selected ? 'border-teal-500 shadow-md' : 'border-slate-200'}`}>
        <div className="bg-slate-50 border-b px-4 py-2 flex items-center justify-between">
          <span className="text-sm font-medium text-slate-700">{a.title}</span>
          <span className="text-xs bg-slate-200 text-slate-500 px-2 py-0.5 rounded">{a.chart_type}</span>
        </div>
        {a.plotly_spec ? (
          <div className="p-2">
            <Plot
              data={a.plotly_spec.data}
              layout={{
                ...a.plotly_spec.layout,
                autosize: true,
                margin: { l: 50, r: 20, t: 30, b: 50 },
              }}
              useResizeHandler
              style={{ width: '100%', height: '320px' }}
              config={{ responsive: true, displayModeBar: false }}
            />
          </div>
        ) : (
          <div className="p-8 text-center text-slate-400 text-sm">Chart loading…</div>
        )}
      </div>
    </NodeViewWrapper>
  )
}

8. Slash command menu

The / menu lets users insert smart blocks, headings, lists, and other content. Use Tiptap’s Extension + a floating React component triggered by the / keystroke.
// components/editor/SlashMenu.ts
import { Extension } from '@tiptap/core'
import Suggestion, { SuggestionOptions } from '@tiptap/suggestion'
import { ReactRenderer } from '@tiptap/react'
import tippy from 'tippy.js'
import { SlashMenuList } from './SlashMenuList'

export const SlashMenu = Extension.create({
  name: 'slashMenu',
  addOptions(): { suggestion: Partial<SuggestionOptions> } {
    return {
      suggestion: {
        char: '/',
        command: ({ editor, range, props }) => {
          props.command({ editor, range })
        },
      },
    }
  },
  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        ...this.options.suggestion,
      }),
    ]
  },
})
// components/editor/SlashMenuList.tsx
// Command list shown after '/' is typed
const COMMANDS = [
  { label: 'Heading 1',     icon: 'H1', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run() },
  { label: 'Heading 2',     icon: 'H2', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run() },
  { label: 'Bullet List',   icon: '•',  command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).toggleBulletList().run() },
  { label: 'Ordered List',  icon: '1.', command: ({ editor, range }: any) => editor.chain().focus().deleteRange(range).toggleOrderedList().run() },
  // ── Smart blocks ──
  { label: 'SEC Citation',  icon: '📄', command: ({ editor, range }: any) => openBlockModal(editor, range, 'sec_citation') },
  { label: 'Market Data',   icon: '📊', command: ({ editor, range }: any) => openBlockModal(editor, range, 'market_data') },
  { label: 'AI Insight',    icon: '✨', command: ({ editor, range }: any) => openBlockModal(editor, range, 'ai_insight') },
  { label: 'SQL Query',     icon: '🗃️', command: ({ editor, range }: any) => openBlockModal(editor, range, 'sql_query') },
  { label: 'Chart',         icon: '📈', command: ({ editor, range }: any) => openBlockModal(editor, range, 'ivory_chart') },
]
When a smart block command is selected, openBlockModal opens a small form that collects the required inputs (accession number, ticker, prompt, SQL, etc.) and calls the resolver.

9. Block resolver integration

// lib/api/blocks.ts
const BASE = process.env.NEXT_PUBLIC_API_URL

export async function resolveSecCitation(params: {
  accession_number: string
  query: string
  chunk_limit?: number
  doc_id?: string
}) {
  const res = await apiFetch('/v1/editor/blocks/resolve/sec_citation', {
    method: 'POST',
    body: JSON.stringify(params),
  })
  return res.block  // ready to insert into editor
}

export async function resolveMarketData(params: {
  cik?: string
  ticker?: string
  metrics?: string[]
}) {
  const res = await apiFetch('/v1/editor/blocks/resolve/market_data', {
    method: 'POST',
    body: JSON.stringify(params),
  })
  return res.block
}

export async function resolveAiInsight(params: {
  prompt: string
  doc_id?: string
  llm_model?: string
}) {
  const res = await apiFetch('/v1/editor/blocks/resolve/ai_insight', {
    method: 'POST',
    body: JSON.stringify(params),
  })
  return res.block
}

export async function resolveSqlQuery(params: { sql: string; params?: any[] }) {
  const res = await apiFetch('/v1/editor/blocks/resolve/sql_query', {
    method: 'POST',
    body: JSON.stringify(params),
  })
  return res.block
}

export async function resolveChart(params: {
  chart_type: string
  title: string
  spec: object
}) {
  const res = await apiFetch('/v1/editor/blocks/resolve/chart', {
    method: 'POST',
    body: JSON.stringify(params),
  })
  return res.block
}
Inserting a resolved block into the editor:
async function insertBlock(editor: Editor, range: Range, blockNode: object) {
  editor.chain().focus().deleteRange(range).insertContent(blockNode).run()
}

10. Orchestrator sidebar

A slide-in chat panel where the user can ask the AI agent to research and insert content into the document.
// components/editor/sidebar/OrchestratorPanel.tsx
'use client'
import { useState, useRef } from 'react'

export function OrchestratorPanel({ docId }: { docId: string }) {
  const [query, setQuery]       = useState('')
  const [plan, setPlan]         = useState<any[]>([])
  const [steps, setSteps]       = useState<any[]>([])
  const [answer, setAnswer]     = useState('')
  const [loading, setLoading]   = useState(false)
  const abortRef                = useRef<AbortController>()

  const run = async () => {
    if (!query.trim() || loading) return
    setLoading(true); setPlan([]); setSteps([]); setAnswer('')
    abortRef.current = new AbortController()

    const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/rag/orchestrate`, {
      method:  'POST',
      signal:  abortRef.current.signal,
      headers: {
        'Content-Type':  'application/json',
        'Authorization': `Bearer ${getToken()}`,
        'Accept':        'text/event-stream',
      },
      body: JSON.stringify({
        query,
        // Tell the orchestrator which document it's working on
        // so write_to_editor_document knows the target
        chat_id: `doc_${docId}`,
      }),
    })

    const reader  = res.body!.getReader()
    const decoder = new TextDecoder()
    let   buffer  = ''

    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n\n')
      buffer = lines.pop() ?? ''

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue
        const event = JSON.parse(line.slice(6))

        switch (event.type) {
          case 'plan':        setPlan(event.steps);                         break
          case 'agent_step':  setSteps(s => [...s.filter(x => x.tool !== event.tool || x.status === 'done'), event]); break
          case 'token':       setAnswer(a => a + event.token);              break
          case 'done':        setLoading(false);                            break
          case 'error':       setAnswer(`Error: ${event.detail}`); setLoading(false); break
        }
      }
    }
  }

  return (
    <aside className="w-96 border-l flex flex-col h-screen bg-white">
      <div className="p-4 border-b font-semibold text-slate-800 flex items-center gap-2">
        ✨ AI Research Assistant
      </div>

      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {/* Research plan */}
        {plan.length > 0 && (
          <div className="bg-teal-50 rounded-lg p-3">
            <div className="text-xs font-semibold text-teal-700 mb-2 uppercase tracking-wide">Research plan</div>
            {plan.map((step, i) => (
              <div key={i} className="text-xs text-teal-800 flex gap-2">
                <span className="text-teal-400">{i + 1}.</span> {step.task}
              </div>
            ))}
          </div>
        )}

        {/* Live tool steps */}
        {steps.map((step, i) => (
          <div key={i} className={`text-xs rounded px-3 py-2 flex items-center gap-2 ${step.status === 'running' ? 'bg-amber-50 text-amber-700' : 'bg-slate-50 text-slate-600'}`}>
            {step.status === 'running' ? '⟳' : '✓'} {step.tool}
            {step.status === 'done' && step.summary && (
              <span className="text-slate-400 truncate">{step.summary.slice(0, 80)}</span>
            )}
          </div>
        ))}

        {/* Answer */}
        {answer && (
          <div className="prose prose-sm prose-slate max-w-none text-sm whitespace-pre-wrap">
            {answer}
          </div>
        )}
      </div>

      {/* Input */}
      <div className="p-4 border-t">
        <textarea
          value={query}
          onChange={e => setQuery(e.target.value)}
          placeholder="Ask AI to research, analyse, or write into this document…"
          className="w-full resize-none border rounded-lg p-3 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
          rows={3}
          onKeyDown={e => e.key === 'Enter' && e.metaKey && run()}
        />
        <button
          onClick={run}
          disabled={loading}
          className="mt-2 w-full bg-teal-600 text-white text-sm font-medium rounded-lg py-2 hover:bg-teal-700 disabled:opacity-50"
        >
          {loading ? 'Researching…' : 'Research  ⌘↵'}
        </button>
      </div>
    </aside>
  )
}

11. Document sidebar

Settings panel for visibility, sharing, and version history.
// components/editor/sidebar/DocumentSidebar.tsx
'use client'
import { useState } from 'react'
import { updateDocument, listVersions, createVersion, getVersion } from '@/lib/api/editor'

export function DocumentSidebar({ docId, visibility, myRole, editor }: any) {
  const [vis, setVis]         = useState(visibility)
  const [versions, setVersions] = useState<any[]>([])

  const changeVisibility = async (newVis: string) => {
    await updateDocument(docId, { visibility: newVis })
    setVis(newVis)
  }

  const saveVersion = async () => {
    await createVersion(docId, `Snapshot ${new Date().toLocaleString()}`)
  }

  const loadVersions = async () => {
    const v = await listVersions(docId)
    setVersions(v)
  }

  const restoreVersion = async (versionNum: number) => {
    const v = await getVersion(docId, versionNum)
    editor.commands.setContent(v.doc_ast)
    await updateDocument(docId, { doc_ast: v.doc_ast })
  }

  return (
    <aside className="w-64 border-l p-4 space-y-6 bg-white h-screen overflow-y-auto">

      {/* Visibility */}
      {myRole === 'owner' && (
        <section>
          <div className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Visibility</div>
          {(['private', 'workspace', 'public'] as const).map(v => (
            <button
              key={v}
              onClick={() => changeVisibility(v)}
              className={`w-full text-left text-sm px-3 py-2 rounded-lg mb-1 transition-colors ${vis === v ? 'bg-teal-50 text-teal-700 font-medium' : 'text-slate-600 hover:bg-slate-50'}`}
            >
              {v === 'private'   ? '🔒 Private'   : ''}
              {v === 'workspace' ? '👥 Workspace'  : ''}
              {v === 'public'    ? '🌐 Public'     : ''}
            </button>
          ))}
          {vis === 'public' && (
            <p className="text-xs text-amber-600 mt-1">
              Anyone can discover and read this document via AI search.
            </p>
          )}
        </section>
      )}

      {/* Version history */}
      <section>
        <div className="flex items-center justify-between mb-2">
          <div className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Versions</div>
          {myRole !== 'viewer' && (
            <button onClick={saveVersion} className="text-xs text-teal-600 hover:text-teal-800">Save now</button>
          )}
        </div>
        <button onClick={loadVersions} className="text-xs text-slate-500 hover:text-slate-700 mb-2">
          Load history
        </button>
        {versions.map(v => (
          <div key={v.id} className="flex items-center justify-between text-xs text-slate-600 py-1 border-b border-slate-100">
            <div>
              <span className="font-medium">v{v.version_num}</span>
              {v.label && <span className="ml-1 text-slate-400">{v.label}</span>}
              <div className="text-slate-400">{v.created_by_type} · {new Date(v.created_at).toLocaleDateString()}</div>
            </div>
            {myRole === 'owner' && (
              <button onClick={() => restoreVersion(v.version_num)} className="text-teal-600 hover:text-teal-800">
                Restore
              </button>
            )}
          </div>
        ))}
      </section>

    </aside>
  )
}

12. Environment variables

Add to .env.local:
NEXT_PUBLIC_API_URL=https://api.ivory.finance
NEXT_PUBLIC_HOCUSPOCUS_URL=wss://collab.ivory.finance

13. Complete layout

Putting it all together:
// app/editor/[docId]/page.tsx  (final)
'use client'
import { IvoryEditor }         from '@/components/editor/IvoryEditor'
import { OrchestratorPanel }   from '@/components/editor/sidebar/OrchestratorPanel'
import { DocumentSidebar }     from '@/components/editor/sidebar/DocumentSidebar'
import { useState }            from 'react'

export default function EditorPage({ params }: { params: { docId: string } }) {
  const [showAI, setShowAI]   = useState(false)
  const [showDoc, setShowDoc] = useState(true)

  return (
    <div className="flex h-screen overflow-hidden bg-white">

      {/* Main editor */}
      <div className="flex-1 overflow-y-auto">
        <IvoryEditor docId={params.docId} />
      </div>

      {/* Document settings sidebar */}
      {showDoc && <DocumentSidebar docId={params.docId} />}

      {/* AI orchestrator panel */}
      {showAI && <OrchestratorPanel docId={params.docId} />}

      {/* Toggle buttons — top right */}
      <div className="absolute top-4 right-4 flex gap-2">
        <button onClick={() => setShowAI(!showAI)} className="text-xs bg-violet-100 text-violet-700 px-3 py-1.5 rounded-full font-medium">
          ✨ AI
        </button>
        <button onClick={() => setShowDoc(!showDoc)} className="text-xs bg-slate-100 text-slate-600 px-3 py-1.5 rounded-full font-medium">
          ⚙️ Settings
        </button>
      </div>

    </div>
  )
}

14. Quick checklist for the frontend dev

1

Install packages

Run the npm install command from Step 1.
2

Set env vars

Add NEXT_PUBLIC_API_URL and NEXT_PUBLIC_HOCUSPOCUS_URL to .env.local.
3

Implement 5 Tiptap extensions

One Node.create() per block type (sec_citation, market_data, ai_insight, sql_query, ivory_chart). Refer to the extension skeleton in Smart Blocks or use GET /v1/editor/blocks/schemas for the exact attrs list.
4

Implement 5 NodeView renderers

React components rendering each block type. Skeletons above — style to match Ivory design system.
5

Wire Hocuspocus provider

Pass the document UUID as the room name and the JWT as the token. Handle onAuthenticationFailed.
6

Implement auto-save

Use the useAutoSave hook — debounce 2s, PATCH /v1/editor/documents/{id} with doc_ast.
7

Build slash menu

/ keystroke → command list → modal for smart block inputs → call resolver → insert resolved block node.
8

Build orchestrator panel

SSE consumer for /v1/rag/orchestrate — render plan, tool steps, streaming answer.
9

Build document sidebar

Visibility toggle (owner only), version save/restore.
10

Test agent writes

Ask the AI “add Apple revenue data to this document” with a valid doc_id — the orchestrator should call create_market_data_block and the block should appear live.

API endpoints summary

WhatMethod + PathAuth
List documentsGET /v1/editor/documentsJWT
Create documentPOST /v1/editor/documentsJWT
Get documentGET /v1/editor/documents/{id}JWT (public = optional)
Update documentPATCH /v1/editor/documents/{id}JWT, editor+
Delete documentDELETE /v1/editor/documents/{id}JWT, owner
Share documentPOST /v1/editor/documents/{id}/membersJWT, owner
List versionsGET /v1/editor/documents/{id}/versionsJWT
Create versionPOST /v1/editor/documents/{id}/versionsJWT, editor+
Restore versionGET version + PATCH documentJWT, editor+
Resolve sec_citationPOST /v1/editor/blocks/resolve/sec_citationOptional JWT
Resolve market_dataPOST /v1/editor/blocks/resolve/market_dataOptional JWT
Resolve ai_insightPOST /v1/editor/blocks/resolve/ai_insightJWT required
Resolve sql_queryPOST /v1/editor/blocks/resolve/sql_queryJWT required
Resolve chartPOST /v1/editor/blocks/resolve/chartOptional JWT
Block schemasGET /v1/editor/blocks/schemasNone
Orchestrate (SSE)POST /v1/rag/orchestrateJWT
Workspaces CRUDGET/POST /v1/editor/workspacesJWT
Hocuspocus authPOST /v1/editor/hocuspocus/authInternal