Skip to content

Repository files navigation

Daegari

Introduction

Daegari ([tɛ.ɡa.ɾi], roughly “deh-gah-ree”), written 대가리, means “head” in colloquial Korean.

Daegari gives headless tools a browser head, connecting their UI, CLI, Skills, and MCP tools to one live product router.

As an opinionated, local-first application framework, Daegari runs that router through an app-scoped localhost Host. It owns Host lifecycle and opaque Target dispatch, while the product owns the UI and defines what Targets mean and how they map to browser routes. Daegari is not a desktop shell, a generic operation manager, or an RPC schema generator:

CLI / Skills / MCP ─┐
                    ├── opaque Target ── app Host ── product router
browser UI ─────────┘

The CLI is a short-lived dispatcher. A built CLI can reuse or start the Host in the background; a desktop shell is not required.

Requirements and installation

Daegari requires Node.js 22 or newer.

pnpm create daegari-app my-app

Or install the framework into an existing application:

pnpm add daegari
pnpm add -D vite vite-plugin-daegari tsdown tsx

Daegari is agnostic to UI frameworks. The playgrounds use React only as an example.

Public modules

Import Environment Purpose
daegari Host/CLI Server aggregate containing App, Rpc, and Errors
daegari/app Host/CLI App, Host, and opaque Target lifecycle
daegari/rpc Host/CLI oRPC procedure context, event helpers, and EventJournal
daegari/cli Node Incur command tree integrated with App Targets
daegari/errors Host/CLI Daegari BaseError and cause traversal
daegari/schema Universal Browser-safe Schema namespace backed by Zod
daegari/client Browser Target-bound typed product client
daegari/query Browser Router-shaped TanStack Query utilities
vite-plugin-daegari Build/dev Host attachment, distribution assembly, and client import guard

The Vite client graph allows only daegari/schema, daegari/client, and daegari/query. Importing the root, another Daegari subpath, or node:* from browser code fails with an actionable build error. Type-only server imports are safe because TypeScript erases them before Vite resolves the browser graph.

Define the product router

Put schemas used by more than one surface in ordinary product modules. Daegari does not require a shared directory.

// src/greeting.ts
import { Schema } from "daegari/schema"

export const HelloInputSchema = Schema.object({
  name: Schema.string().trim().min(1).max(200),
})

export const GreetingSchema = Schema.object({
  greeting: Schema.string(),
})

Create the conventional App entry at src/daegari.ts:

// src/daegari.ts
import { App, Rpc } from "daegari"

import { GreetingSchema, HelloInputSchema } from "./greeting.js"

const router = {
  hello: Rpc.procedure
    .input(HelloInputSchema)
    .output(GreetingSchema)
    .handler(({ context, input }) => ({
      greeting: `Hello, ${input.name} from ${context.target}!`,
    })),
}

export default App.create({
  id: "my-app",
  router,
})

export type AppRouter = typeof router

The App ID is stable product identity. It scopes Host discovery and recent-Target storage; changing an npm package name should not silently change it. App.create() does not start a process.

Configure Vite

vite-plugin-daegari finds src/daegari.ts without configuration. During development it attaches the Host to Vite's HTTP server. Production builds emit browser assets, a private Host entry, and a relocatable .daegari/manifest.json.

// vite.config.ts
import { defineConfig } from "vite"
import daegari from "vite-plugin-daegari"

export default defineConfig({
  plugins: [daegari()],
})

Use daegari({ srcDir: "app" }) only when the entry is outside the default src directory. The filename remains daegari.ts.

Expose CLI, Skills, and MCP

daegari/cli embeds Incur. Define a command once to receive human CLI behavior, structured output, Skills, and MCP tools.

#!/usr/bin/env node

// src/cli.ts
import * as Cli from "daegari/cli"
import { Schema } from "daegari/schema"

import app, { type AppRouter } from "./daegari.js"

const cli: Cli.Instance<AppRouter> = Cli.create(app, {
  name: "my-app",
  description: "My Daegari app",
})

cli.command("hello", {
  args: Schema.object({
    name: Schema.string(),
    target: Schema.string().optional(),
  }),
  mcp: { annotations: { readOnlyHint: true } },
  async run({ args }) {
    const target = await cli.target(args.target)
    return target.client.hello({ name: args.name })
  },
})

cli.command("status", {
  mcp: { annotations: { readOnlyHint: true } },
  run: () => app.host.status(),
})

cli.command("stop", {
  destructive: true,
  async run() {
    await app.host.stop()
    return { state: "stopped" as const }
  },
})

await cli.serve()

cli.target(ref) validates and persists an explicit Target as recent. cli.target() restores the recent Target for this App. A Target handle is immutable: creating another handle never retargets an existing client or browser page.

Connect the browser

A browser page opened by target.open() receives a capability bound to exactly one Target. Create the typed client from the App type and derive Query utilities from it:

// src/web/daegari.ts
import * as Client from "daegari/client"
import * as Query from "daegari/query"

import type app from "../daegari.js"

export const client = Client.create<typeof app>()
export const rpc = Query.from(client)
const hello = useMutation(rpc.hello.mutationOptions())

function submit(name: string) {
  const input = HelloInputSchema.parse({ name })
  hello.mutate(input)
}

The product owns TanStack Router configuration, Target-to-route resolution, Query invalidation, editor state, and durable operation state.

Host and Target lifecycle

const target = await app.target("document:current")

await target.client.hello({ name: "Daegari" })
await target.open({ route: "/documents/current" })

await app.host.status()
await app.host.stop()

The first remote procedure or open() call reuses a live Host or starts the built Host in the background. open() returns the Host outcome, whether a browser request was made, and a capability-bearing URL; treat that URL as sensitive data.

browser: "requested" means Daegari dispatched the URL to the platform launcher. It does not guarantee that a browser tab loaded or rendered. Launcher startup and early handoff failures throw App.Target.BrowserLaunchError.

A Target is an opaque non-empty string of at most 8 KiB in UTF-8. Daegari does not trim, normalize, resolve, or interpret it. The product canonicalizes files, URLs, and domain identifiers and supplies the corresponding same-origin browser route.

The Host begins its idle timer only when there are no browser sessions, active product requests or streams, or context.waitUntil() leases. waitUntil() may keep the Host alive indefinitely, but it is not an operation manager and provides no ID, status, cancellation, journal, or durability.

Errors

Daegari-owned errors extend Errors.BaseError and carry a stable DAEGARI_* code, short message, package version, and optional cause. walk() safely traverses nested causes:

import { Errors } from "daegari"

try {
  await app.target()
} catch (error) {
  if (error instanceof Errors.BaseError) {
    console.error(error.code, error.shortMessage, error.walk())
  }
}

Transport errors such as ORPCError remain their native type and may carry more wire information. Do not wrap them in BaseError merely to normalize the class hierarchy.

Event streams

Rpc.EventJournal is an optional process-local bounded replay ring for transient events. It owns transport cursors, not product event IDs or operation state.

import { Rpc } from "daegari"

type Event = { kind: "document.changed"; revision: string }

const journal = Rpc.EventJournal.create<Event>({ capacity: 100 })

journal.publish({ kind: "document.changed", revision: "2" })

async function consume(lastEventId?: string, signal?: AbortSignal) {
  for await (const event of journal.subscribe({ lastEventId, signal })) {
    const cursor = Rpc.getEventMeta(event)?.id
    console.log(cursor, event.kind)
  }
}

Omitting lastEventId replays retained history. A malformed, future, or evicted cursor raises Rpc.EventJournal.GapError; use a product snapshot procedure to recover. Closing the journal is idempotent and lets subscribers drain retained history.

Development and distribution

A typical application uses:

{
  "scripts": {
    "dev": "vite",
    "cli": "tsx src/cli.ts",
    "mcp": "tsx src/cli.ts --mcp",
    "build": "vite build && tsdown",
    "build:mcpb": "pnpm build && daegari mcpb pack"
  }
}

During development, keep pnpm dev running and invoke pnpm cli -- ... from another terminal. Development uses the foreground Vite process. A built or globally installed CLI needs no foreground session because the first Target call starts its detached Host.

daegari mcpb pack validates <cwd>/dist, generates an MCPB 0.4 manifest, fixes the runtime to node ${__dirname}/bin/cli.js --mcp, and delegates archive creation to the official MCPB packer. It writes <name>-<version>.mcpb in the application root. Use --dir <path> for a non-default distribution directory; optional MCPB fields may be placed in mcpb.json.

Workspace playgrounds

packages/daegari/           App, Host, Target, CLI, browser Client, Query, and MCPB
packages/vite/              vite-plugin-daegari build and development adapter
packages/create-daegari-app/ Minimal React and TanStack Query project generator
playgrounds/greeting/       minimal bidirectional example
playgrounds/mdv-poc/        file, comment, stream, and conflict dogfood
playgrounds/molt-poc/       long-running operation and cleanup-safety dogfood
pnpm install
pnpm build
pnpm start -- open document:demo --no-open
pnpm start -- hello Wonhee --target document:demo
pnpm start:mdv -- open playgrounds/mdv-poc/fixtures/spec.md --no-open
pnpm start:molt -- scan ~/Projects
pnpm --filter greeting-playground build:mcpb

See the architecture contract, domain language, browser schema contract, and roadmap.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages