Building a Multi-Tool Application

Patterns for building multi-tool applications with protokit — shared Y.js documents, workspace-scoped stores, staged navigation, and the produces/consumes data pipeline.

Building a Multi-Tool Application

protokit is designed to scale from a single embedded calculator to a suite of dozens of interconnected tools. This page covers the patterns that make large-scale applications manageable.

Architecture overview

A typical multi-tool application has:

┌─────────────────────────────────────────────────────┐
│  Workspace page: /workspace/:id/:tool               │
├──────────────────────┬──────────────────────────────┤
│  Sidebar             │  Tool area                   │
│  ─ Tool list         │  ─ ProtoTool (or custom UI)  │
│  ─ Status indicators │  ─ Connection indicators     │
│  ─ Stage groupings   │  ─ Status controls           │
└──────────────────────┴──────────────────────────────┘
         │                           │
         ▼                           ▼
  Workspace list store         Workspace store
  ─ workspace CRUD             ─ ONE shared Y.Doc
  ─ tool status tracking       ─ getToolMap(toolKey)
  ─ metadata                   ─ metaMap

One Y.Doc per workspace

The most important architectural decision: all tools within one workspace share a single Y.Doc.

// workspaceStore.ts — factory, one Pinia store per workspace ID
export const useWorkspaceStore = (workspaceId: string) =>
  defineStore(`workspace-${workspaceId}`, () => {
    const { doc, isReady } = useProtoDoc(`workspace-${workspaceId}`)

    // Each tool gets its own Y.Map namespace
    function getToolMap(toolKey: string): Y.Map<any> {
      return doc.getMap(`fields:${toolKey}`)
    }

    // Workspace-level metadata
    const metaMap = doc.getMap('meta')

    function updateMeta(updates: Record<string, any>) {
      doc.transact(() => {
        Object.entries(updates).forEach(([k, v]) => metaMap.set(k, v))
      })
    }

    return { doc, isReady, getToolMap, updateMeta }
  })()

Benefits:

  • produces/consumes work instantly and offline — no cross-document wiring needed
  • One IndexedDB store per workspace instead of one per tool
  • One server sync stream per workspace
  • Corruption recovery covers the entire workspace at once

Each tool's data lives in its own namespace: fields:estimator, fields:tracker, collection:tasks:items, etc.

The workspace list store

A separate store manages the list of all workspaces — names, icons, tool statuses:

// workspaceListStore.ts — singleton
export const useWorkspaceListStore = defineStore('workspace-list', () => {
  const { doc, isReady } = useProtoDoc('workspace-list')
  const workspacesMap = doc.getMap('workspaces')

  function createWorkspace(input: { name: string; icon?: string }): string {
    const id = crypto.randomUUID()
    doc.transact(() => {
      workspacesMap.set(id, { id, name: input.name, icon: input.icon, createdAt: Date.now() })
    })
    return id
  }

  function markToolStatus(workspaceId: string, toolKey: string, status: 'not-started' | 'in-progress' | 'done') {
    const ws = workspacesMap.get(workspaceId)
    if (!ws) return
    const toolsUsed = new Set<string>(ws.toolsUsed ?? [])
    const toolsInProgress = new Set<string>(ws.toolsInProgress ?? [])
    if (status === 'done')        { toolsUsed.add(toolKey); toolsInProgress.delete(toolKey) }
    if (status === 'in-progress') { toolsInProgress.add(toolKey); toolsUsed.delete(toolKey) }
    if (status === 'not-started') { toolsUsed.delete(toolKey); toolsInProgress.delete(toolKey) }
    workspacesMap.set(workspaceId, { ...ws, toolsUsed: [...toolsUsed], toolsInProgress: [...toolsInProgress] })
  }

  const workspaces = computed(() => [...workspacesMap.values()] as Workspace[])

  return { workspaces, isReady, createWorkspace, markToolStatus }
})

This is also a Y.js document — workspace list changes persist offline.

Tool routing

Register pages dynamically in module.ts or nuxt.config.ts:

// In a Nuxt module's setup():
nuxt.hook('pages:extend', (pages) => {
  pages.push({
    name: 'workspace-tool',
    path: '/workspace/:id/:tool',
    file: resolve('./runtime/pages/workspace/[id]/[tool].vue'),
  })
})

In the route page:

// [tool].vue
const route = useRoute()
const workspaceId = route.params.id as string
const toolKey = route.params.tool as string

const schema = getPrototype(toolKey)      // from useProtoRegistry
const workspaceStore = useWorkspaceStore(workspaceId)

// Provide the shared doc to child tools
provide('workspace-doc', workspaceStore.doc)

Scoping tools to a shared doc

When using usePrototype with a shared document, pass the scoped Y.Map directly:

// In SomeToolWrapper.vue
const workspaceDoc = inject('workspace-doc') as Y.Doc

// Instead of creating a new Y.Doc, use the shared one
const fieldsMap = workspaceDoc.getMap(`fields:${toolKey}`)
const { state, derived } = useProtoMap(fieldsMap, schema.fields)

// Collections also scope to the shared doc
const tasks = useProtoCollection(workspaceDoc, `collection:${toolKey}:tasks`, schema.collections.tasks)

Schema registry

Register all your tool schemas at startup:

// plugins/register-schemas.ts
import { estimatorSchema }  from '~/schemas/estimator'
import { trackerSchema }    from '~/schemas/tracker'
import { dashboardSchema }  from '~/schemas/dashboard'

registerPrototype(estimatorSchema)
registerPrototype(trackerSchema)
registerPrototype(dashboardSchema)

Then look up schemas by key anywhere:

const schema = getPrototype(toolKey)  // returns PrototypeSchema | undefined

Tool grouping and stages

Organize tools into logical groups (stages, phases, categories) in a config object:

// utils/toolGroups.ts
export const TOOL_GROUPS = [
  {
    key: 'plan',
    label: 'Planning',
    icon: 'i-lucide-map',
    tools: [
      { key: 'estimator',  label: 'Cost Estimator', priority: 'high' },
      { key: 'timeline',   label: 'Timeline',        priority: 'medium' },
      { key: 'risk-matrix', label: 'Risk Matrix',    priority: 'low' },
    ],
  },
  {
    key: 'track',
    label: 'Tracking',
    icon: 'i-lucide-activity',
    tools: [
      { key: 'task-tracker', label: 'Task Tracker',  priority: 'high' },
      { key: 'burn-rate',    label: 'Burn Rate',     priority: 'medium' },
    ],
  },
]

Use this config to render the sidebar and to validate route params.

Status tracking

const workspaceListStore = useWorkspaceListStore()

// Auto-mark in-progress on first visit
onMounted(() => {
  const workspace = workspaceListStore.workspaces.find(w => w.id === workspaceId)
  const alreadyStarted = workspace?.toolsUsed?.includes(toolKey) ||
                         workspace?.toolsInProgress?.includes(toolKey)
  if (!alreadyStarted) {
    workspaceListStore.markToolStatus(workspaceId, toolKey, 'in-progress')
  }
})

// "Mark done" action
function markDone() {
  workspaceListStore.markToolStatus(workspaceId, toolKey, 'done')
}

In the sidebar, read these sets to show completion indicators:

  • toolsUsed.includes(key)i-lucide-check-circle (done)
  • toolsInProgress.includes(key)i-lucide-clock (in progress)
  • neither → default dot

Connection indicators UI

Show the user which tools are feeding data in and which tools need linking:

// useSomeConnections.ts
function useToolConnections(workspaceDoc: Y.Doc, toolKey: string) {
  const schema = getPrototype(toolKey)
  if (!schema?.consumes) return { active: [], missing: [] }

  const active  = ref<string[]>([])
  const missing = ref<string[]>([])

  watchEffect(() => {
    const found: string[] = []
    const needed: string[] = []
    for (const [alias, path] of Object.entries(schema.consumes!)) {
      const [sourceKey] = path.split('.')
      const outputMap = workspaceDoc.getMap(`outputs:${sourceKey}`)
      // Check if source has produced any data
      if (outputMap.size > 0) found.push(sourceKey)
      else needed.push(sourceKey)
    }
    active.value  = found
    missing.value = needed
  })

  return { active, missing }
}

Render "Data flowing in from Cost Estimator" banners for active connections and "Link Timeline tool to unlock schedule data" prompts for missing ones.

Minimal viable implementation checklist

To build a multi-tool workspace app with protokit:

  • useProtoDoc + Pinia store for the shared workspace Y.Doc
  • getToolMap(toolKey) helper for namespaced Y.Map access
  • Workspace list store with createWorkspace, markToolStatus
  • Schema registry (registerPrototype in a plugin)
  • Tool groups config for sidebar rendering
  • Dynamic route /workspace/:id/:tool
  • provide('workspace-doc', doc) in the route page
  • inject('workspace-doc') in each tool wrapper component
  • Connection indicator composable for upstream/downstream status
  • ProtoCorruptionModal mounted globally in app.vue

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.