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.
Daegari requires Node.js 22 or newer.
pnpm create daegari-app my-appOr install the framework into an existing application:
pnpm add daegari
pnpm add -D vite vite-plugin-daegari tsdown tsxDaegari is agnostic to UI frameworks. The playgrounds use React only as an example.
| 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.
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 routerThe 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.
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.
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.
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.
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.
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.
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.
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.
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:mcpbSee the architecture contract, domain language, browser schema contract, and roadmap.