Restructure plugin API, implement basic minimal shell prototype
diff --git a/packages/webui/.gitignore b/packages/webui/.gitignore
index 36cd0db..7bd7f37 100644
--- a/packages/webui/.gitignore
+++ b/packages/webui/.gitignore
@@ -11,3 +11,4 @@
 .wrangler
 .dev.vars*
 
+src/IRIS_SITE.ts
\ No newline at end of file
diff --git a/packages/webui/package.json b/packages/webui/package.json
index b7f06b6..74f6b9e 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -13,7 +13,6 @@
     "validate": "tsc --noEmit"
   },
   "dependencies": {
-    "@jottocraft/iris-sdk": "workspace:*",
-    "iris-plugins": "workspace:*"
+    "@jottocraft/iris-sdk": "workspace:*"
   }
 }
diff --git a/packages/webui/src/app/components/Form/Field.tsx b/packages/webui/src/app/components/Form/Field.tsx
new file mode 100644
index 0000000..122654c
--- /dev/null
+++ b/packages/webui/src/app/components/Form/Field.tsx
@@ -0,0 +1,162 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Dropdown, DropdownItem, Icon, Switch, TextInput } from "@ffxp/react-dom";
+import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+
+export function createDefaultFieldData(spec: IrisPluginConduitField, refData?: any, forceRequired?: boolean) {
+    if (spec.optional && !forceRequired) return undefined;
+
+    if (spec.type === "string") {
+        return refData ?? "";
+    } else if (spec.type === "number") {
+        return refData ?? 0;
+    } else if (spec.type === "date") {
+        return refData ?? new Date().toISOString().split("T")[0];
+    } else if (spec.type === "datetime") {
+        return refData ?? new Date().toISOString();
+    } else if (spec.type === "boolean") {
+        return refData ?? false;
+    } else if (spec.type === "dropdown") {
+        return refData ?? spec.options[0].value;
+    } else if (spec.type === "array") {
+        return refData ? refData.map((refItem: any) => createDefaultFieldData(spec.of, refItem)) : [];
+    } else if (spec.type === "object") {
+        const defaultData: any = {};
+        for (const key in spec.struct) {
+            defaultData[key] = createDefaultFieldData(spec.struct[key], refData ? refData[key] : undefined);
+        }
+        return defaultData;
+    }
+}
+
+export default function Field({ path, data, refData, spec, patchFormData, controls }: { path: string; data: any; refData: any; spec: IrisPluginConduitField; patchFormData: (path: string, value: unknown) => void; controls?: React.ReactNode; }) {
+    let fieldInput: React.ReactNode = null;
+
+    if ((refData !== undefined) && (spec.type !== "object")) return null;
+
+    if (data === undefined) {
+        fieldInput = (
+            <Button onClick={() => patchFormData(path, createDefaultFieldData(spec, undefined, true))} iconLeading="add">Set value</Button>
+        );
+    } else if ((spec.type === "string") || (spec.type === "number") || (spec.type === "date") || (spec.type === "datetime")) {
+        fieldInput = (
+            <TextInput
+                onChange={e => {
+                    if (spec.type === "string") {
+                        patchFormData(path, e.target.value);
+                    } else if (spec.type === "number") {
+                        patchFormData(path, Number(e.target.value));
+                    } else if (spec.type === "date") {
+                        patchFormData(path, e.target.value);
+                    } else if (spec.type === "datetime") {
+                        patchFormData(path, e.target.value + "Z");
+                    }
+                }}
+                value={spec.type === "datetime" ? new Date(data).toISOString().split(":").slice(0, -1).join(":") : data}
+                type={spec.type === "string" ? (
+                    "text"
+                ) : spec.type === "number" ? (
+                    "number"
+                ) : spec.type === "date" ? (
+                    "date"
+                ) : spec.type === "datetime" ? (
+                    "datetime-local"
+                ) : undefined}
+            />
+        );
+    } else if (spec.type === "boolean") {
+        fieldInput = (
+            <Switch onClick={() => patchFormData(path, !data)} active={data} />
+        );
+    } else if (spec.type === "dropdown") {
+        const selectedOption = spec.options.find(option => option.value === data)!;
+        fieldInput = (
+            <Dropdown menu={(
+                <>
+                    {spec.options.map(option => (
+                        <DropdownItem onClick={() => patchFormData(path, option.value)} icon={option.icon}>{option.label}</DropdownItem>
+                    ))}
+                </>
+            )}>
+                <Button variant="outline" iconLeading={selectedOption.icon} iconTrailing="arrow_drop_down">{selectedOption.label}</Button>
+            </Dropdown>
+        );
+    } else if (spec.type === "array") {
+        fieldInput = (
+            <div className="space-y-6 border border-ui-20 p-4 roundness-xl">
+                {data.map((value: any, i: number) => {
+                    return (
+                        <Field controls={(
+                            <div className="space-x-2">
+                                <Button onClick={() => {
+                                    if (i === 0) return;
+                                    const newValue = [...data];
+                                    newValue.splice(i, 1);
+                                    newValue.splice(i - 1, 0, value);
+                                    patchFormData(path, newValue);
+                                }} iconLeading="arrow_upward" size="small" />
+                                <Button onClick={() => {
+                                    if (i === (data.length - 1)) return;
+                                    const newValue = [...data];
+                                    newValue.splice(i, 1);
+                                    newValue.splice(i + 1, 0, value);
+                                    patchFormData(path, newValue);
+                                }} iconLeading="arrow_downward" size="small" />
+                                <Button onClick={() => {
+                                    const newValue = [...data];
+                                    newValue.splice(i, 1);
+                                    patchFormData(path, newValue);
+                                }} iconLeading="delete" size="small" />
+                            </div>
+                        )} path={`${path}[${i}]`} data={value} refData={refData !== undefined ? refData[i] : undefined} spec={spec.of} patchFormData={patchFormData} />
+                    );
+                })}
+                <Button iconLeading="add" onClick={() => patchFormData(path, [...data, createDefaultFieldData(spec.of)])}>Add item</Button>
+            </div>
+        );
+    } else if (spec.type === "object") {
+        fieldInput = (
+            <div className="space-y-6 border border-ui-20 p-4 roundness-xl">
+                {Object.keys(spec.struct).map(key => {
+                    return (
+                        <Field path={`${path}.${key}`} data={data[key]} refData={refData !== undefined ? refData[key] : undefined} spec={spec.struct[key]} patchFormData={patchFormData} />
+                    );
+                })}
+            </div>
+        );
+    } else {
+        fieldInput = (
+            <div>
+                <p>Unrecognized field type {spec.type}</p>
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-2">
+            {spec.icon || spec.displayName || spec.optional || controls ? (
+                <div className="flex flex-row items-center justify-between">
+                    <div className="flex flex-row items-center space-x-2">
+                        {spec.icon ? <Icon className="text-lg">{spec.icon}</Icon> : null}
+                        {spec.displayName ? <div>{spec.displayName}</div> : null}
+                    </div>
+                    <div className="flex flex-row items-center space-x-8">
+                        {spec.optional && (data !== undefined) ? (
+                            <Button onClick={() => patchFormData(path, undefined)} iconLeading="delete" size="small">Clear value</Button>
+                        ) : spec.optional ? (
+                            <div className="text-xs px-2 py-1 bg-ui-10 roundness">
+                                Optional
+                            </div>
+                        ) : null}
+                        {controls}
+                    </div>
+                </div>
+            ) : null}
+            {fieldInput}
+            {spec.helpText ? (
+                <div className="text-type-30 text-xs">{spec.helpText}</div>
+            ) : null}
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Form/index.tsx b/packages/webui/src/app/components/Form/index.tsx
new file mode 100644
index 0000000..b323de0
--- /dev/null
+++ b/packages/webui/src/app/components/Form/index.tsx
@@ -0,0 +1,81 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+import { Button, Icon } from "@ffxp/react-dom";
+import { useCallback, useMemo, useState } from "react";
+import { createPortal } from "react-dom";
+import set from "lodash/set";
+import clone from "lodash/clone";
+
+import Field, { createDefaultFieldData } from "~/app/components/Form/Field";
+import { usePanelNavigation } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
+
+export default function Form({ ref, onFinished }: { ref: IrisWebuiFormRef; onFinished: () => void; }) {
+    const plugins = usePlugins()!;
+    const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+    const form = plugins[panelNavigation.pluginId].plugin.forms[ref.id];
+
+    const defaultData = useMemo(() => form.data ? createDefaultFieldData(form.data, ref.data) : undefined, [form, ref]);
+
+    const [formData, setFormData] = useState<{ data: unknown; }>({
+        data: clone(defaultData)
+    });
+
+    const patchFormData = useCallback((path: string, value: unknown) => {
+        set(formData, path, value);
+
+        setFormData({
+            data: formData.data
+        });
+    }, [formData]);
+
+    const [formResult, setFormResult] = useState<YggdrasilStatus<unknown> | string | null>(null);
+    const submitForm = useCallback(() => {
+        (async () => {
+            try {
+                const data = await plugins[panelNavigation.pluginId].client().forms[ref.id](formData.data as any);
+                setFormResult(data);
+            } catch(e) {
+                if (!(e instanceof YggdrasilStatus)) throw e;
+                setFormResult(e);
+            }
+        })();
+    }, [panelNavigation, ref, formData]);
+
+    return createPortal((
+        <div className="w-full h-full py-10 absolute top-0 left-0 z-99 bg-black/90 flex items-center justify-center">
+            <div className="max-h-full overflow-auto w-2xl bg-ui-0 p-4 roundness-xl shadow-xl space-y-8">
+                <div className="flex flex-row items-center justify-between">
+                    <div className="flex flex-row items-center text-xl space-x-2 font-medium">
+                        {form.icon ? <Icon className="text-2xl">{form.icon}</Icon> : null}
+                        <h1>{form.displayName}</h1>
+                    </div>
+                    <div>
+                        <Button variant="borderless" onClick={() => onFinished()} iconLeading="close" size="small" />
+                    </div>
+                </div>
+                {formResult ? (
+                    <div>
+                        <p>{formResult instanceof YggdrasilStatus ? formResult.statusCode : formResult}</p>
+                    </div>
+                ) : (
+                    <>
+                        <div>
+                            {form.data ? (
+                                <Field path="data" data={formData.data} refData={ref.data} spec={form.data} patchFormData={patchFormData} />
+                            ) : (
+                                <p>This form doesn't take any input.</p>
+                            )}
+                        </div>
+                        <div>
+                            <Button onClick={submitForm} variant="primary">Submit</Button>
+                        </div>
+                    </>
+                )}
+            </div >
+        </div >
+    ), document.body);
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Panel/index.tsx b/packages/webui/src/app/components/Panel/index.tsx
new file mode 100644
index 0000000..bafc717
--- /dev/null
+++ b/packages/webui/src/app/components/Panel/index.tsx
@@ -0,0 +1,82 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon } from "@ffxp/react-dom";
+import type { IrisPluginConduit, IrisPluginConduitPanelArgs, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router";
+
+import { usePlugins } from "~/app/data/root";
+import PanelSection from "~/app/components/PanelSection";
+import { usePanelNavigation } from "~/app/data/panels";
+import Form from "~/app/components/Form";
+
+export default function Panel() {
+    const panelNavigation = usePanelNavigation()!;
+    const plugins = usePlugins()!;
+    const [activeForm, setActiveForm] = useState<IrisWebuiFormRef | null>(null);
+
+    if (!panelNavigation.activeRef) {
+        return (
+            <p>Please select a panel to open from the sidebar.</p>
+        );
+    }
+
+    if (!panelNavigation.layout) {
+        return (
+            <p>Loading panel {panelNavigation.activeRef.id}...</p>
+        );
+    }
+
+    const plugin = plugins[panelNavigation.pluginId];
+    const layout = panelNavigation.layout;
+
+    return (
+        <>
+            {activeForm ? (
+                <Form ref={activeForm} onFinished={() => setActiveForm(null)} />
+            ) : null}
+            <div className="space-y-8">
+                <div className="flex flex-row items-center space-x-3 text-4xl font-semibold">
+                    {layout.icon ? <Icon>{layout.icon}</Icon> : null}
+                    <h1>{layout.displayName}</h1>
+                </div>
+                {layout.forms ? (
+                    <div>
+                        {layout.forms?.map((ref) => {
+                            const form = plugin.plugin.forms[ref.id];
+
+                            return (
+                                <div className="inline-block m-1">
+                                    <Button onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
+                                </div>
+                            );
+                        })}
+                    </div>
+                ) : null}
+                {layout.main ? (
+                    <div>
+                        {layout.main.map(item => (
+                            <div className="inline-block space-y-2 m-1 p-4 bg-ui-5 roundness-xl">
+                                <div className="flex flex-row items-center space-x-2 text-xl font-medium">
+                                    {item.icon ? <Icon>{item.icon}</Icon> : null}
+                                    <div>{item.displayName}</div>
+                                </div>
+                                <div>
+                                    <div>{item.value}</div>
+                                </div>
+                            </div>
+                        ))}
+                    </div>
+                ) : null}
+                {layout.sections ? (
+                    <div>
+                        {layout.sections.map(section => (
+                            <PanelSection section={section} />
+                        ))}
+                    </div>
+                ) : null}
+            </div>
+        </>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelBreadcrumbs.tsx b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
new file mode 100644
index 0000000..1e824a2
--- /dev/null
+++ b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
@@ -0,0 +1,38 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon, Link } from "@ffxp/react-dom";
+import { Link as RouterLink } from "react-router";
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelBreadcrumbs() {
+    const panelNavigation = usePanelNavigation()!;
+
+    if (!panelNavigation.activeRef) return;
+
+    return (
+        <div className="flex-grow flex flex-row items-center border-ui-20 border-2 roundness-lg p-1">
+            <div className="mr-4 space-x-1">
+                <Button onClick={() => panelNavigation.back()} iconLeading="arrow_back" size="small" />
+                <Button onClick={() => panelNavigation.refreshLayout()} iconLeading="refresh" size="small" />
+            </div>
+            {panelNavigation.breadcrumbs.map((breadcrumb, i) => (
+                <div className="flex flex-row items-center space-x-1 text-sm text-type-20">
+                    {breadcrumb.icon ? <Icon className="text-base">{breadcrumb.icon}</Icon> : null}
+                    <div>
+                        <RouterLink to={panelRefHref(panelNavigation.pluginId, breadcrumb.ref)} onClick={(e) => panelNavigation.goToBreadcrumb(i, e)}>
+                            <Link>{breadcrumb.displayName}</Link>
+                        </RouterLink>
+                    </div>
+                    <Icon className="text-xl mx-1">keyboard_arrow_right</Icon>
+                </div>
+            ))}
+            {panelNavigation.layout ? (
+                <div className="flex flex-row items-center space-x-1 text-base text-type-0 mr-2">
+                    {panelNavigation.layout.icon ? <Icon className="text-lg">{panelNavigation.layout.icon}</Icon> : null}
+                    <div className="font-medium">{panelNavigation.layout.displayName}</div>
+                </div>
+            ) : null}
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelNavigator.tsx b/packages/webui/src/app/components/PanelNavigator.tsx
new file mode 100644
index 0000000..16bcb25
--- /dev/null
+++ b/packages/webui/src/app/components/PanelNavigator.tsx
@@ -0,0 +1,94 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useCallback, useEffect, useState } from "react";
+import { useNavigate } from "react-router";
+
+import { panelNavigationCtx, panelRefHref } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+
+export default function PanelNavigator({ children }: { children?: React.ReactNode; }) {
+    const routerNavigate = useNavigate();
+    const plugins = usePlugins()!;
+
+    const [pluginId, setPluginId] = useState<string | null>(null);
+    const [activeRef, setActiveRef] = useState<IrisWebuiPanelRef | null>(null);
+    const [layout, setLayout] = useState<IrisPluginConduitPanelLayout<IrisPluginConduit> | null>(null);
+    const [breadcrumbs, setBreadcrumbs] = useState<IrisWebuiPanelBreadcrumb[]>([]);
+
+    useEffect(() => {
+        if (!activeRef) return;
+        routerNavigate(panelRefHref(pluginId!, activeRef));
+    }, [pluginId, activeRef, routerNavigate]);
+
+    const fetchLayout = useCallback(() => {
+        (async () => {
+            const data = await plugins[pluginId!].client().panels[activeRef!.id]((activeRef!.args ?? {}) as { [key: string]: string; });
+            setLayout(data);
+        })();
+    }, [plugins, pluginId, activeRef]);
+
+    useEffect(() => {
+        if (!activeRef) return;
+
+        fetchLayout();
+    }, [activeRef, fetchLayout]);
+
+    const navigate = useCallback((ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+        e?.preventDefault();
+
+        setBreadcrumbs([...breadcrumbs, {
+            displayName: layout?.displayName ?? activeRef!.id,
+            icon: layout?.icon ?? undefined,
+            ref: activeRef!
+        }]);
+        setActiveRef(ref);
+    }, [activeRef, layout, breadcrumbs]);
+
+    const back = useCallback((e?: React.MouseEvent) => {
+        e?.preventDefault();
+        if (!breadcrumbs.length) return;
+
+        setActiveRef(breadcrumbs[breadcrumbs.length - 1].ref);
+        setBreadcrumbs(breadcrumbs.slice(0, -1));
+    }, [breadcrumbs]);
+
+    const goToBreadcrumb = useCallback((index: number, e?: React.MouseEvent) => {
+        e?.preventDefault();
+        if (!breadcrumbs.length) return;
+
+        setActiveRef(breadcrumbs[index].ref);
+        setBreadcrumbs(breadcrumbs.slice(0, index));
+    }, [breadcrumbs]);
+
+    const startNavigation = useCallback((pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+        setPluginId(pluginId);
+        setActiveRef(ref);
+        setBreadcrumbs([]);
+    }, []);
+
+    const refreshLayout = useCallback(() => {
+        setLayout(null);
+        fetchLayout();
+    }, [fetchLayout]);
+
+    return (
+        <panelNavigationCtx.Provider value={activeRef ? {
+            activeRef,
+            layout,
+            pluginId: pluginId!,
+            breadcrumbs,
+            navigate,
+            back,
+            goToBreadcrumb,
+            startNavigation,
+            refreshLayout
+        } : {
+            activeRef,
+            startNavigation
+        }}>
+            {children}
+        </panelNavigationCtx.Provider>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
new file mode 100644
index 0000000..1668d1c
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionProperties } from "@jottocraft/iris-sdk";
+
+export default function PanelSectionProperties({ section }: { section: IrisPluginConduitPanelLayoutSectionProperties<IrisPluginConduit>; }) {
+    return (
+        <p>TODO</p>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
new file mode 100644
index 0000000..c889178
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionRef } from "@jottocraft/iris-sdk";
+
+export default function PanelSectionRef({ section }: { section: IrisPluginConduitPanelLayoutSectionRef<IrisPluginConduit>; }) {
+    return (
+        <p>TODO</p>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
new file mode 100644
index 0000000..26229e4
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
@@ -0,0 +1,46 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionTable } from "@jottocraft/iris-sdk";
+import { Link, useParams } from "react-router";
+
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelSectionTable({ section }: { section: IrisPluginConduitPanelLayoutSectionTable<IrisPluginConduit>; }) {
+    const params = useParams();
+    const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+
+    return (
+        <div>
+            <table>
+                <thead>
+                    <tr>
+                        {section.columns.map(column => (
+                            <th>
+                                {column.displayName}
+                            </th>
+                        ))}
+                    </tr>
+                </thead>
+                <tbody>
+                    {section.rows.map(row => (
+                        <tr>
+                            {section.columns.map(column => {
+                                const link = row.links?.[column.key];
+                                return (
+                                    <td>
+                                        {link ? (
+                                            <Link to={panelRefHref(panelNavigation.pluginId, link)} onClick={(e) => panelNavigation.navigate(link, e)}>{row.vals[column.key] ?? null}</Link>
+                                        ) : (
+                                            row.vals[column.key] ?? null
+                                        )}
+                                    </td>
+                                );
+                            })}
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/index.tsx b/packages/webui/src/app/components/PanelSection/index.tsx
new file mode 100644
index 0000000..af483f0
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/index.tsx
@@ -0,0 +1,26 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSection } from "@jottocraft/iris-sdk";
+
+import PanelSectionProperties from "~/app/components/PanelSection/PanelSectionProperties";
+import PanelSectionTable from "~/app/components/PanelSection/PanelSectionTable";
+import PanelSectionRef from "~/app/components/PanelSection/PanelSectionRef";
+
+export default function PanelSection({ section }: { section: IrisPluginConduitPanelLayoutSection<IrisPluginConduit>; }) {
+    if (section.type === "properties") {
+        return (
+            <PanelSectionProperties section={section} />
+        );
+    } else if (section.type === "table") {
+        return (
+            <PanelSectionTable section={section} />
+        );
+    } else if (section.type === "ref") {
+        return (
+            <PanelSectionRef section={section} />
+        );
+    } else {
+        throw new Error("Unrecognized panel layout section type " + (section as any).type);
+    }
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Shell.tsx b/packages/webui/src/app/components/Shell.tsx
new file mode 100644
index 0000000..62267db
--- /dev/null
+++ b/packages/webui/src/app/components/Shell.tsx
@@ -0,0 +1,111 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, NavbarLayout, NavbarTitle, SidebarHeader, SidebarItem, SidebarItemGroup, SidebarItems, SidebarLayout, SidebarTitle, useThemeEngine } from "@ffxp/react-dom";
+import { useEffect } from "react";
+import { Link, Outlet, useLocation, useParams, useSearchParams } from "react-router";
+
+import IRIS_SITE from "~/IRIS_SITE";
+import { useAuth, usePlugins } from "~/app/data/root";
+import { isAtPanel, panelRefHref, usePanelNavigation } from "~/app/data/panels";
+import PanelNavigator from "~/app/components/PanelNavigator";
+import PanelBreadcrumbs from "~/app/components/PanelBreadcrumbs";
+
+function ShellLayout({ children }: { children?: React.ReactNode; }) {
+    const theme = useThemeEngine();
+    const plugins = usePlugins()!;
+    const auth = useAuth();
+    const panelNavigation = usePanelNavigation()!;
+    const routerParams = useParams();
+    const [searchParams] = useSearchParams();
+
+    useEffect(() => {
+        if (!panelNavigation.activeRef && (routerParams.pluginId && routerParams.panelId)) {
+            const args: { [key: string]: string } = {};
+            for (const [k, v] of searchParams.entries()) args[k] = v;
+
+            panelNavigation.startNavigation(routerParams.pluginId, {
+                id: routerParams.panelId,
+                args
+            });
+        }
+    }, [routerParams, searchParams, panelNavigation]);
+
+    return (
+        <NavbarLayout left={(
+            <div className="flex-grow flex flex-row items-center space-x-8">
+                <Link to="/">
+                    <NavbarTitle logo="/webui/assets/favicon.png">
+                        Iris
+                    </NavbarTitle>
+                </Link>
+                <PanelBreadcrumbs />
+            </div>
+        )}
+            right={(
+                <>
+                    <Button onClick={() => {
+                        if (window.confirm("Do you want to sign out?")) {
+                            window.localStorage.removeItem("jwt");
+                            window.location.reload();
+                        }
+                    }}>
+                        Signed in as {auth?.claims.email as string}
+                    </Button>
+                </>
+            )}>
+            <SidebarLayout sidebar={(
+                <>
+                    <SidebarHeader>
+                        {IRIS_SITE.brand.logo ? (
+                            <img className="w-full h-10 object-contain" src={theme.config.colors.dark ? IRIS_SITE.brand.logo.light : IRIS_SITE.brand.logo.dark} />
+                        ) : (
+                            <SidebarTitle>{IRIS_SITE.brand.name}</SidebarTitle>
+                        )}
+                    </SidebarHeader>
+                    <SidebarItems>
+                        {IRIS_SITE.plugins.map(({ id }) => {
+                            const plugin = plugins[id];
+                            return (
+                                <SidebarItemGroup title={plugin.plugin.displayName} icon={plugin.plugin.icon}>
+                                    {plugin.plugin.navigation.map(item => (
+                                        <Link to={panelRefHref(id, item.panel)} onClick={e => panelNavigation.startNavigation(id, item.panel, e)}>
+                                            <SidebarItem active={(panelNavigation.activeRef && (panelNavigation.pluginId === id)) ? isAtPanel(panelNavigation.breadcrumbs[0]?.ref ?? panelNavigation.activeRef, item.panel) : false} icon={item.icon}>{item.displayName}</SidebarItem>
+                                        </Link>
+                                    ))}
+                                </SidebarItemGroup>
+                            );
+                        })}
+                    </SidebarItems>
+                </>
+            )}>
+                {children}
+            </SidebarLayout>
+        </NavbarLayout>
+    );
+}
+
+export default function Shell({ children }: { children?: React.ReactNode; }) {
+    const location = useLocation();
+    const plugins = usePlugins();
+
+    if (location.pathname === "/login") {
+        return (
+            <Outlet />
+        );
+    }
+
+    if (!plugins) {
+        return (
+            <p>Loading plugins...</p>
+        );
+    }
+
+    return (
+        <PanelNavigator>
+            <ShellLayout>
+                {children}
+            </ShellLayout>
+        </PanelNavigator>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/panels.ts b/packages/webui/src/app/data/panels.ts
new file mode 100644
index 0000000..26e99f0
--- /dev/null
+++ b/packages/webui/src/app/data/panels.ts
@@ -0,0 +1,28 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { createContext, useContext } from "react";
+import isEqual from "lodash/isEqual";
+
+export const panelNavigationCtx = createContext<IrisWebuiPanelNavigationCtx | null>(null);
+export function usePanelNavigation() {
+    return useContext(panelNavigationCtx);
+}
+
+export function panelRefHref(pluginId: string, ref: IrisWebuiPanelRef) {
+    let linkHref: string = `/p/${pluginId}/${ref.id}`;
+    if (ref.args) {
+        const linkParams = new URLSearchParams();
+        for (const arg in ref.args) {
+            if (ref.args[arg]) linkParams.set(arg, ref.args[arg]);
+        }
+        linkHref = linkHref + "?" + linkParams.toString();
+    }
+
+    return linkHref;
+}
+
+export function isAtPanel(activeRef: IrisWebuiPanelRef, desiredRef: IrisWebuiPanelRef) {
+    if (activeRef.id !== desiredRef.id) return false;
+    return isEqual(activeRef.args ?? {}, desiredRef.args ?? {});
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/root.ts b/packages/webui/src/app/data/root.ts
new file mode 100644
index 0000000..0697087
--- /dev/null
+++ b/packages/webui/src/app/data/root.ts
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisAuthCtx, IrisPluginCtx } from "@jottocraft/iris-sdk";
+import { createContext, useContext } from "react";
+
+export const pluginContext = createContext<IrisPluginCtx | null>(null);
+export function usePlugins() {
+    return useContext(pluginContext);
+}
+
+export const authContext = createContext<IrisAuthCtx | null>(null);
+export function useAuth() {
+    return useContext(authContext);
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/root.tsx b/packages/webui/src/app/root.tsx
index abab211..c96602f 100644
--- a/packages/webui/src/app/root.tsx
+++ b/packages/webui/src/app/root.tsx
@@ -2,6 +2,9 @@
 // Copyright (c) jottocraft 2025
 
 import { BodyWrapper, Container, HeadWrapper, HTMLWrapper, Link, Root, ThemeEngine } from "@ffxp/react-dom";
+import type { IrisAuthCtx, IrisPluginConduit, IrisPluginConduitConduitServerTypes, IrisPluginCtx } from "@jottocraft/iris-sdk";
+import { createConduitClientSideClient } from "@jottocraft/yggdrasil";
+import { useEffect, useState } from "react";
 import {
     isRouteErrorResponse,
     Links,
@@ -9,12 +12,88 @@
     Scripts,
     ScrollRestoration,
     useLoaderData,
+    useLocation,
     type LoaderFunction,
 } from "react-router";
 
 import "~/app/app.css";
+import Shell from "~/app/components/Shell";
+import { authContext, pluginContext } from "~/app/data/root";
+import IRIS_SITE from "~/IRIS_SITE";
 
 export default function App() {
+    const location = useLocation();
+
+    const [auth, setAuth] = useState<IrisAuthCtx | null>(null);
+    useEffect(() => {
+        if (location.pathname === "/login") {
+            return;
+        }
+
+        if (!auth) {
+            let jwt = window.localStorage.getItem("jwt");
+            if (jwt) {
+                const jwtData = JSON.parse(atob(jwt.split(".")[1]));
+                if (!jwtData.exp || (new Date().getTime() > (jwtData.exp * 1000))) {
+                    jwt = null;
+                } else {
+                    setAuth({
+                        jwt,
+                        claims: jwtData
+                    });
+                }
+            }
+
+            if (!jwt) {
+                const redirectUrl = new URL(window.location.origin);
+                redirectUrl.pathname = "/webui/login";
+
+                const nonce = crypto.randomUUID();
+                window.localStorage.setItem("nonce", JSON.stringify({
+                    nonce,
+                    // Expires in 30 minutes
+                    useBefore: new Date().getTime() + 30 * 60 * 1000
+                } satisfies IrisWebuiNonceStorage));
+
+                const authUrl = new URL(IRIS_SITE.auth.authUrl);
+                authUrl.searchParams.set("client_id", IRIS_SITE.auth.clientId);
+                authUrl.searchParams.set("redirect_uri", redirectUrl.href);
+                authUrl.searchParams.set("response_type", "id_token");
+                authUrl.searchParams.set("scope", IRIS_SITE.auth.scope ?? "openid email profile");
+                authUrl.searchParams.set("nonce", nonce);
+                window.location.href = authUrl.href;
+            }
+        }
+    }, [auth, location.pathname]);
+
+    const [plugins, setPlugins] = useState<null | IrisPluginCtx>(null);
+    useEffect(() => {
+        if (!auth) return;
+
+        const plugins: IrisPluginCtx = {};
+        Promise.all(IRIS_SITE.plugins.map(async (sitePlugin) => {
+            if (sitePlugin.type === "conduit") {
+                const client = createConduitClientSideClient<IrisPluginConduitConduitServerTypes<IrisPluginConduit>>({
+                    jwt: auth.jwt
+                }, {
+                    baseURL: sitePlugin.baseUrl
+                });
+
+                const plugin = await client().plugin();
+
+                plugins[sitePlugin.id] = {
+                    type: "conduit",
+                    plugin,
+                    client
+                };
+            } else {
+                throw new Error("Unexpected plugin type " + sitePlugin.type);
+            }
+        })).then(() => {
+            setPlugins(plugins);
+        }).catch(e => { throw e; });
+    }, [auth]);
+
     return (
         <Root>
             <ThemeEngine plugins={[
@@ -53,7 +132,13 @@
                         <Links />
                     </HeadWrapper>
                     <BodyWrapper>
-                        <Outlet />
+                        <authContext.Provider value={auth}>
+                            <pluginContext.Provider value={plugins}>
+                                <Shell>
+                                    <Outlet />
+                                </Shell>
+                            </pluginContext.Provider>
+                        </authContext.Provider>
                         <ScrollRestoration />
                         <Scripts />
                     </BodyWrapper>
diff --git a/packages/webui/src/app/routes/login.tsx b/packages/webui/src/app/routes/login.tsx
new file mode 100644
index 0000000..f63ce03
--- /dev/null
+++ b/packages/webui/src/app/routes/login.tsx
@@ -0,0 +1,68 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { JWTPayload } from "jose";
+import { useEffect, useState } from "react";
+
+import IRIS_SITE from "~/IRIS_SITE";
+
+export default function Login() {
+    const [error, setError] = useState<null | string>(null);
+    useEffect(() => {
+        const nonce = window.localStorage.getItem("nonce");
+        if (!nonce) {
+            setError("Missing auth state");
+            return;
+        }
+
+        const nonceData = JSON.parse(nonce) as IrisWebuiNonceStorage;
+        if (new Date().getTime() > nonceData.useBefore) {
+            setError("Auth state has expired");
+            return;
+        }
+
+        if (!window.location.hash.slice(1)) {
+            setError("Missing auth parameters");
+            return;
+        }
+
+        const hashParams = new URLSearchParams(window.location.hash.slice(1));
+        const jwt = hashParams.get("id_token");
+        if (!jwt) {
+            setError("Missing required id_token parameter");
+            return;
+        }
+
+        const jwtData = JSON.parse(atob(jwt.split(".")[1])) as JWTPayload;
+        if ((jwtData.aud !== IRIS_SITE.auth.clientId) ||
+            (jwtData.iss !== IRIS_SITE.auth.iss) ||
+            !jwtData.nbf ||
+            ((jwtData.nbf * 1000) > new Date().getTime()) ||
+            !jwtData.exp ||
+            ((jwtData.exp * 1000) < new Date().getTime()) ||
+            (jwtData.nonce !== nonceData.nonce)) {
+            setError("Invalid or expired id_token");
+            return;
+        }
+
+        const webuiUrl = new URL(window.location.origin);
+        webuiUrl.pathname = "/webui";
+
+        window.localStorage.removeItem("nonce");
+        window.localStorage.setItem("jwt", jwt);
+        window.location.href = webuiUrl.href;
+    }, []);
+
+    return (
+        <div>
+            {error ? (
+                <div>
+                    <h1>Authentication error</h1>
+                    <p>{error}</p>
+                </div>
+            ) : (
+                <p>Signing in...</p>
+            )}
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$.tsx b/packages/webui/src/app/routes/p/$.tsx
new file mode 100644
index 0000000..1837210
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+export default function Home() {
+    return (
+        <div>
+            <p>Hello plugin!</p>
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
new file mode 100644
index 0000000..76e2f75
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { useState } from "react";
+import { useParams } from "react-router";
+
+import Panel from "~/app/components/Panel";
+
+export default function PanelRoute() {
+    return (
+        <div className="py-12">
+            <Panel />
+        </div >
+    );
+}
\ No newline at end of file
diff --git a/packages/webui/src/webui.d.ts b/packages/webui/src/webui.d.ts
index dc05a7e..869b1fc 100644
--- a/packages/webui/src/webui.d.ts
+++ b/packages/webui/src/webui.d.ts
@@ -1,2 +1,42 @@
 // Iris
-// Copyright (c) jottocraft 2025
\ No newline at end of file
+// Copyright (c) jottocraft 2025
+
+declare type IrisWebuiConfig = {
+    origin: string;
+    site: import("@jottocraft/iris-sdk").IrisSiteConfig;
+};
+
+declare type IrisWebuiNonceStorage = {
+    nonce: string;
+    useBefore: number;
+};
+
+declare type IrisWebuiPanelRef = import("@jottocraft/iris-sdk").IrisPluginConduitPanelRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+declare type IrisWebuiFormRef = import("@jottocraft/iris-sdk").IrisPluginConduitFormRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+
+declare type IrisWebuiPanelNavigationCtx = IrisWebuiPanelNavigationCtxActive | IrisWebuiPanelNavigationCtxInactive;
+
+declare type IrisWebuiPanelNavigationCtxCommon = {
+    startNavigation: (pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxActive = IrisWebuiPanelNavigationCtxCommon & {
+    activeRef: IrisWebuiPanelRef;
+    layout: null | import("@jottocraft/iris-sdk").IrisPluginConduitPanelLayout<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+    pluginId: string;
+    breadcrumbs: IrisWebuiPanelBreadcrumb[];
+    navigate: (ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+    back: (e?: React.MouseEvent) => void;
+    goToBreadcrumb: (index: number, e?: React.MouseEvent) => void;
+    refreshLayout: () => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxInactive = IrisWebuiPanelNavigationCtxCommon & {
+    activeRef: null;
+};
+
+declare type IrisWebuiPanelBreadcrumb = {
+    displayName: string;
+    icon?: string;
+    ref: IrisWebuiPanelRef;
+};
\ No newline at end of file