Restructure plugin API, implement basic minimal shell prototype
diff --git a/packages/plugins/.gitignore b/packages/plugins/.gitignore
deleted file mode 100644
index 206df3e..0000000
--- a/packages/plugins/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-lib
-node_modules
-*.tsbuildinfo
\ No newline at end of file
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
deleted file mode 100644
index 3406ac4..0000000
--- a/packages/plugins/package.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-  "name": "iris-plugins",
-  "private": true,
-  "license": "LGPL-2.1-only",
-  "author": "jottocraft",
-  "scripts": {
-    "build": "tsc --build",
-    "clean": "rimraf lib",
-    "start": "tsc --build --watch",
-    "validate": "tsc --noEmit"
-  },
-  "dependencies": {
-    "@jottocraft/iris-sdk": "workspace:*"
-  }
-}
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
deleted file mode 100644
index 7f535b5..0000000
--- a/packages/plugins/src/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-export const DUMMY_EXPORT = 1;
\ No newline at end of file
diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json
deleted file mode 100644
index 599fdc0..0000000
--- a/packages/plugins/tsconfig.json
+++ /dev/null
@@ -1,113 +0,0 @@
-{
-  "include": [
-    "src"
-  ],
-  "compilerOptions": {
-    /* Visit https://aka.ms/tsconfig to read more about this file */
-
-    /* Projects */
-    "incremental": true,                                 /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
-    "composite": true,                                   /* Enable constraints that allow a TypeScript project to be used with project references. */
-    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
-    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
-    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
-    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
-
-    /* Language and Environment */
-    "target": "ESNext",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
-    // "lib": [],                                        /* Specify a set of bundled library declaration files that describe the target runtime environment. */
-    // "jsx": "preserve",                                /* Specify what JSX code is generated. */
-    // "experimentalDecorators": true,                   /* Enable experimental support for legacy experimental decorators. */
-    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
-    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
-    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
-    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
-    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
-    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
-    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
-    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
-
-    /* Modules */
-    "module": "commonjs",                                /* Specify what module code is generated. */
-    "rootDir": "src",                                    /* Specify the root folder within your source files. */
-    "moduleResolution": "node",                          /* Specify how TypeScript looks up a file from a given module specifier. */
-    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
-    "paths": {                                           /* Specify a set of entries that re-map imports to additional lookup locations. */
-      "~/*": ["./src/*"]
-    },
-    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
-    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
-    // "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */
-    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
-    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
-    // "allowImportingTsExtensions": true,               /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
-    // "resolvePackageJsonExports": true,                /* Use the package.json 'exports' field when resolving package imports. */
-    // "resolvePackageJsonImports": true,                /* Use the package.json 'imports' field when resolving imports. */
-    // "customConditions": [],                           /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
-    // "resolveJsonModule": true,                        /* Enable importing .json files. */
-    // "allowArbitraryExtensions": true,                 /* Enable importing files with any extension, provided a declaration file is present. */
-    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
-
-    /* JavaScript Support */
-    // "allowJs": true,                                  /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
-    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
-    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
-
-    /* Emit */
-    "declaration": true,                                 /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
-    "declarationMap": true,                              /* Create sourcemaps for d.ts files. */
-    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
-    "sourceMap": true,                                   /* Create source map files for emitted JavaScript files. */
-    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
-    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
-    "outDir": "lib",                                     /* Specify an output folder for all emitted files. */
-    // "removeComments": true,                           /* Disable emitting comments. */
-    // "noEmit": true,                                   /* Disable emitting files from a compilation. */
-    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
-    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
-    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
-    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
-    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
-    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
-    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
-    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
-    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
-    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
-    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
-
-    /* Interop Constraints */
-    "isolatedModules": true,                             /* Ensure that each file can be safely transpiled without relying on other imports. */
-    // "verbatimModuleSyntax": true,                     /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
-    // "isolatedDeclarations": true,                     /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
-    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
-    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
-    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
-    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
-
-    /* Type Checking */
-    "strict": true,                                      /* Enable all strict type-checking options. */
-    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
-    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
-    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
-    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
-    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
-    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
-    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
-    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
-    // "noUnusedLocals": true,                           /* Enable error reporting when local variables aren't read. */
-    // "noUnusedParameters": true,                       /* Raise an error when a function parameter isn't read. */
-    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
-    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
-    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
-    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
-    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
-    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
-    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
-    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
-
-    /* Completeness */
-    // "skipDefaultLibCheck": true,                      /* Skip type checking .d.ts files that are included with TypeScript. */
-    "skipLibCheck": true                                 /* Skip type checking all .d.ts files. */
-  }
-}
\ No newline at end of file
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 4303336..e77cbdc 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -18,9 +18,11 @@
     "README.md"
   ],
   "scripts": {
-    "build": "tsc --build",
+    "build": "tsc --build && tsc-alias",
     "clean": "rimraf lib",
-    "start": "tsc --build --watch",
-    "validate": "tsc --noEmit"
+    "start": "run-p dev:*",
+    "validate": "tsc --noEmit",
+    "dev:tsc": "tsc --build --watch",
+    "dev:tsc-alias": "tsc-alias --watch"
   }
 }
diff --git a/packages/sdk/src/conduit.ts b/packages/sdk/src/conduit.ts
new file mode 100644
index 0000000..ac735b2
--- /dev/null
+++ b/packages/sdk/src/conduit.ts
@@ -0,0 +1,218 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+export type IrisPluginConfigConduit = {
+    type: "conduit";
+    id: string;
+    baseUrl: string;
+};
+
+export type IrisPluginCtxConduit<PluginType extends IrisPluginConduit> = {
+    type: "conduit";
+    plugin: PluginType;
+    client: import("@jottocraft/yggdrasil").YggdrasilConduitApiClient<IrisPluginConduitConduitServerTypes<PluginType>>;
+};
+
+export type IrisPluginConduitConduitServerTypes<PluginType extends IrisPluginConduit> = {
+    client: {
+        plugin: () => Promise<PluginType>;
+        panels: {
+            [Property in keyof PluginType["panels"]]: (args: IrisPluginConduitPanelArgs<PluginType["panels"][Property]["requiredArgs"]>) => Promise<IrisPluginConduitPanelLayout<PluginType>>;
+        };
+        forms: {
+            [Property in keyof PluginType["forms"]]: (data: IrisPluginConduitFieldData<PluginType["forms"][Property]["data"]>) => Promise<string>;
+        };
+    };
+    apiCtx: {
+        jwt: string;
+    };
+    conduitCtx: {
+        jwt: string;
+    };
+};
+
+export type IrisPluginConduit = {
+    displayName: string;
+    icon: string;
+    panels: {
+        [id: string]: IrisPluginConduitPanel;
+    };
+    forms: {
+        [id: string]: IrisPluginConduitForm;
+    };
+    navigation: {
+        panel: IrisPluginConduitPanelRef<IrisPluginConduit>;
+        displayName: string;
+        icon?: string;
+    }[];
+};
+
+export type IrisPluginConduitPanel = {
+    requiredArgs: string[];
+};
+
+export type IrisPluginConduitPanelArgs<RequiredArgsType extends string[]> = {
+    [Property in RequiredArgsType[number]]: string;
+} & {
+    [arg: string]: string;
+};
+
+export type IrisPluginConduitForm = {
+    displayName: string;
+    icon?: string;
+    data?: IrisPluginConduitField;
+};
+
+export type IrisPluginConduitField = {
+    displayName?: string;
+    icon?: string;
+    helpText?: string;
+    optional?: boolean;
+} & ({
+    type: "string" | "number" | "date" | "datetime" | "boolean";
+} | {
+    type: "dropdown";
+    options: { label: string; icon?: string; value: string; }[];
+} | {
+    type: "array";
+    of: IrisPluginConduitField;
+} | {
+    type: "object";
+    struct: {
+        [key: string]: IrisPluginConduitField;
+    };
+});
+
+export type _IrisPluginConduitFieldDeepOptional<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { type: "string" | "date" | "datetime" | "number" | "boolean" | "dropdown" } ? (
+    Omit<FieldSpec, "optional"> & { optional: true; }
+) : FieldSpec extends { type: "array" } ? (
+    Omit<FieldSpec, "of"> & { of: _IrisPluginConduitFieldDeepOptional<FieldSpec["of"]>; }
+) : FieldSpec extends { type: "object" } ? (
+    Omit<FieldSpec, "struct"> & {
+        struct: {
+            [Property in keyof FieldSpec["struct"]]: _IrisPluginConduitFieldDeepOptional<FieldSpec["struct"][Property]>;
+        };
+    }
+) : FieldSpec extends undefined ? (
+    undefined
+) : never;
+
+// https://github.com/microsoft/TypeScript/issues/12400#issuecomment-758523767
+
+export type _IrisUKeysOfType<T, SelectedType> = {
+    [key in keyof T]: SelectedType extends T[key] ? key : never
+}[keyof T];
+
+export type _IrisUOptional<T> = Partial<Pick<T, _IrisUKeysOfType<T, undefined>>>;
+
+export type _IrisURequired<T> = Omit<T, _IrisUKeysOfType<T, undefined>>;
+
+export type _IrisUOptionalUndefined<T> = _IrisUOptional<T> & _IrisURequired<T>;
+
+export type _IrisPluginConduitFieldDataCore<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { type: "string" | "date" | "datetime" } ? (
+    string
+) : FieldSpec extends { type: "number" } ? (
+    number
+) : FieldSpec extends { type: "boolean" } ? (
+    boolean
+) : FieldSpec extends { type: "dropdown" } ? (
+    FieldSpec["options"][number]
+) : FieldSpec extends { type: "array" } ? (
+    IrisPluginConduitFieldData<FieldSpec["of"]>[]
+) : FieldSpec extends { type: "object" } ? (
+    _IrisUOptionalUndefined<{
+        [Property in keyof FieldSpec["struct"]]: IrisPluginConduitFieldData<FieldSpec["struct"][Property]>;
+    }>
+) : FieldSpec extends undefined ? (
+    undefined
+) : never;
+
+export type IrisPluginConduitFieldData<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { optional: true; } ? (
+    _IrisPluginConduitFieldDataCore<FieldSpec> | undefined
+) : (
+        _IrisPluginConduitFieldDataCore<FieldSpec>
+    );
+
+export type IrisPluginConduitPanelLayout<PluginType extends IrisPluginConduit> = {
+    displayName: string;
+    icon?: string;
+    forms?: IrisPluginConduitFormRef<PluginType>[];
+    main?: IrisPluginConduitPanelLayoutField[];
+    sections?: IrisPluginConduitPanelLayoutSection<PluginType>[];
+};
+
+export type IrisPluginConduitPanelLayoutSection<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionProperties<PluginType> | IrisPluginConduitPanelLayoutSectionTable<PluginType> | IrisPluginConduitPanelLayoutSectionRef<PluginType>;
+
+export type IrisPluginConduitPanelLayoutSectionCommon = {
+    id: string;
+    displayName: string;
+    icon?: string;
+};
+
+export type IrisPluginConduitPanelLayoutSectionProperties<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+    type: "properties";
+    properties: IrisPluginConduitPanelLayoutField[];
+};
+
+export type IrisPluginConduitPanelLayoutSectionTable<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+    type: "table";
+    columns: {
+        key: string;
+        displayName: string;
+        icon?: string;
+    }[];
+    rows: {
+        vals: {
+            [key: string]: string;
+        };
+        links?: {
+            [key: string]: IrisPluginConduitPanelRef<PluginType>;
+        };
+    }[];
+    firstRow: number;
+    totalRows: number;
+};
+
+export type IrisPluginConduitPanelLayoutSectionRef<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+    type: "ref";
+    panel: IrisPluginConduitPanelRef<PluginType>;
+};
+
+export type IrisPluginConduitPanelRef<PluginType extends IrisPluginConduit> = {
+    id: string;
+    args?: unknown;
+};
+
+export type IrisPluginConduitFormRef<PluginType extends IrisPluginConduit> = {
+    id: string;
+    data?: unknown;
+};
+
+export type IrisPluginConduitPanelLayoutField = {
+    displayName: string;
+    icon?: string;
+    value: string;
+};
+
+/**
+ * Helper utility for implementing an Iris Conduit server with strong type safety.
+ */
+export function createIrisConduitServerImpl<PluginType extends IrisPluginConduit>(): {
+    panelRef: <PanelId extends keyof PluginType["panels"]>(id: PanelId, args?: Partial<IrisPluginConduitPanelArgs<PluginType["panels"][PanelId]["requiredArgs"]>>) => IrisPluginConduitPanelRef<PluginType>;
+    formRef: <FormId extends keyof PluginType["forms"]>(id: FormId, args?: IrisPluginConduitFieldData<_IrisPluginConduitFieldDeepOptional<PluginType["forms"][FormId]["data"]>>) => IrisPluginConduitFormRef<PluginType>;
+} {
+    return {
+        panelRef(id, args) {
+            return {
+                id: id as string,
+                args
+            }
+        },
+        formRef(id, data) {
+            return {
+                id: id as string,
+                data
+            }
+        }
+    };
+}
\ No newline at end of file
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 7f535b5..1750cff 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,4 +1,5 @@
 // Iris
 // Copyright (c) jottocraft 2025
 
-export const DUMMY_EXPORT = 1;
\ No newline at end of file
+export * from "~/conduit";
+export * from "~/site";
\ No newline at end of file
diff --git a/packages/sdk/src/site.ts b/packages/sdk/src/site.ts
new file mode 100644
index 0000000..c545a3d
--- /dev/null
+++ b/packages/sdk/src/site.ts
@@ -0,0 +1,42 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { IrisPluginConduit, IrisPluginConfigConduit, IrisPluginCtxConduit } from "~/conduit";
+
+export type IrisSiteConfig = {
+    brand: {
+        name: string;
+        logo?: {
+            dark: string;
+            light: string;
+        };
+    };
+    auth: IrisAuthConfig;
+    plugins: IrisPluginConfig[];
+};
+
+// Auth:
+
+export type IrisAuthConfig = IrisAuthConfigOidc;
+
+export type IrisAuthConfigOidc = {
+    type: "oidc";
+    method: "implicit";
+    iss: string;
+    authUrl: string;
+    clientId: string;
+    scope?: string;
+};
+
+export type IrisAuthCtx = {
+    jwt: string;
+    claims: import("jose").JWTPayload;
+};
+
+// Plugins:
+
+export type IrisPluginConfig = IrisPluginConfigConduit;
+
+export type IrisPluginCtx = {
+    [id: string]: IrisPluginCtxConduit<IrisPluginConduit>;
+};
\ No newline at end of file
diff --git a/packages/server/.gitignore b/packages/server/.gitignore
deleted file mode 100644
index 1b0d95d..0000000
--- a/packages/server/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-node_modules
-build
-.wrangler
-*.tsbuildinfo
\ No newline at end of file
diff --git a/packages/server/package.json b/packages/server/package.json
deleted file mode 100644
index 522b845..0000000
--- a/packages/server/package.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
-  "name": "iris-server",
-  "private": true,
-  "license": "LGPL-2.1-only",
-  "author": "jottocraft",
-  "scripts": {
-    "build": "wrangler deploy --dry-run --outdir build",
-    "clean": "rimraf build",
-    "deploy": "wrangler deploy",
-    "start": "wrangler dev --local --ip 127.0.0.1 --port 2500 --inspector-port 2501",
-    "validate": "tsc --noEmit"
-  },
-  "dependencies": {
-    "@jottocraft/iris-sdk": "workspace:*",
-    "iris-plugins": "workspace:*"
-  }
-}
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
deleted file mode 100644
index c1698e8..0000000
--- a/packages/server/src/index.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-import { YggdrasilRouter } from "@jottocraft/yggdrasil";
-
-import { IrisServer } from "~/server";
-
-export default {
-    async fetch(request: Request, env: IrisServerEnv, ctx: ExecutionContext): Promise<Response> {
-        return IrisServer.handleRequest(request, async (ygg) => {
-            return new Response("Hello world!", { status: 200 });
-        }, {
-            ctx: {
-                env,
-                exec: ctx
-            }
-        }, {
-            // TODO: Put these in environment variables
-            baseURL: "https://iris.hq.jottocraft.com/api",
-            debug: true,
-            generateHtmlStatusPage: async (ygg, e) => {
-                return new Response([
-                    "Error " + e.status,
-                    e.statusCode,
-                    e.statusMessage,
-                    "",
-                    "",
-                    "",
-                    "",
-                    "Iris",
-                    "Copyright (c) jottocraft 2025"
-                ].join("\n"), {
-                    status: 200
-                });
-            }
-        });
-    }
-}
\ No newline at end of file
diff --git a/packages/server/src/iris-server.d.ts b/packages/server/src/iris-server.d.ts
deleted file mode 100644
index 2e859ba..0000000
--- a/packages/server/src/iris-server.d.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-declare type IrisServerEnv = {
-
-};
-
-declare type IrisServerContext = {
-    env: IrisServerEnv;
-    exec: ExecutionContext;
-};
-
-declare type IrisServerAppTypes = {
-    ctx: IrisServerContext;
-};
\ No newline at end of file
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
deleted file mode 100644
index f52d620..0000000
--- a/packages/server/src/server.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-import { YggdrasilServer } from "@jottocraft/yggdrasil";
-
-export const IrisServer = new YggdrasilServer<IrisServerAppTypes>();
\ No newline at end of file
diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json
deleted file mode 100644
index 141ac4e..0000000
--- a/packages/server/tsconfig.json
+++ /dev/null
@@ -1,107 +0,0 @@
-{
-  "include": [
-    "src"
-  ],
-  "compilerOptions": {
-    /* Visit https://aka.ms/tsconfig to read more about this file */
-    /* Projects */
-    // "incremental": true,                              /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
-    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
-    // "tsBuildInfoFile": "./.tsbuildinfo",              /* Specify the path to .tsbuildinfo incremental compilation file. */
-    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects. */
-    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
-    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */
-    /* Language and Environment */
-    "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
-    "lib": [
-      "ESNext"
-    ], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
-    // "jsx": "react-jsx",                                  /* Specify what JSX code is generated. */
-    // "experimentalDecorators": true,                   /* Enable experimental support for TC39 stage 2 draft decorators. */
-    // "emitDecoratorMetadata": true,                    /* Emit design-type metadata for decorated declarations in source files. */
-    // "jsxFactory": "",                                 /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
-    // "jsxFragmentFactory": "",                         /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
-    // "jsxImportSource": "",                            /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
-    // "reactNamespace": "",                             /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
-    // "noLib": true,                                    /* Disable including any library files, including the default lib.d.ts. */
-    // "useDefineForClassFields": true,                  /* Emit ECMAScript-standard-compliant class fields. */
-    // "moduleDetection": "auto",                        /* Control what method is used to detect module-format JS files. */
-    /* Modules */
-    // "module": "commonjs",                             /* Specify what module code is generated. */
-    "rootDir": "src",
-    "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
-    // "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */
-    "paths": {
-      "~/*": [
-        "./src/*"
-      ]
-    },
-    // "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */
-    // "typeRoots": [],                                  /* Specify multiple folders that act like './node_modules/@types'. */
-    "types": [
-      "node",
-      "@cloudflare/workers-types"
-    ], /* Specify type package names to be included without being referenced in a source file. */
-    // "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */
-    // "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */
-    "resolveJsonModule": true, /* Enable importing .json files. */
-    // "noResolve": true,                                /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
-    /* JavaScript Support */
-    "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
-    // "checkJs": true,                                  /* Enable error reporting in type-checked JavaScript files. */
-    // "maxNodeModuleJsDepth": 1,                        /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
-    /* Emit */
-    "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
-    "declarationMap": true, /* Create sourcemaps for d.ts files. */
-    // "emitDeclarationOnly": true,                      /* Only output d.ts files and not JavaScript files. */
-    "sourceMap": true, /* Create source map files for emitted JavaScript files. */
-    // "outFile": "./",                                  /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
-    // "outDir": "./",                                   /* Specify an output folder for all emitted files. */
-    // "removeComments": true,                           /* Disable emitting comments. */
-    "noEmit": true, /* Disable emitting files from a compilation. */
-    // "importHelpers": true,                            /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
-    // "importsNotUsedAsValues": "remove",               /* Specify emit/checking behavior for imports that are only used for types. */
-    // "downlevelIteration": true,                       /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
-    // "sourceRoot": "",                                 /* Specify the root path for debuggers to find the reference source code. */
-    // "mapRoot": "",                                    /* Specify the location where debugger should locate map files instead of generated locations. */
-    // "inlineSourceMap": true,                          /* Include sourcemap files inside the emitted JavaScript. */
-    // "inlineSources": true,                            /* Include source code in the sourcemaps inside the emitted JavaScript. */
-    // "emitBOM": true,                                  /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
-    // "newLine": "crlf",                                /* Set the newline character for emitting files. */
-    // "stripInternal": true,                            /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
-    // "noEmitHelpers": true,                            /* Disable generating custom helper functions like '__extends' in compiled output. */
-    // "noEmitOnError": true,                            /* Disable emitting files if any type checking errors are reported. */
-    // "preserveConstEnums": true,                       /* Disable erasing 'const enum' declarations in generated code. */
-    // "declarationDir": "./",                           /* Specify the output directory for generated declaration files. */
-    // "preserveValueImports": true,                     /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
-    /* Interop Constraints */
-    // "isolatedModules": true,                          /* Ensure that each file can be safely transpiled without relying on other imports. */
-    // "allowSyntheticDefaultImports": true,             /* Allow 'import x from y' when a module doesn't have a default export. */
-    "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
-    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
-    "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
-    /* Type Checking */
-    "strict": true, /* Enable all strict type-checking options. */
-    // "noImplicitAny": true,                            /* Enable error reporting for expressions and declarations with an implied 'any' type. */
-    // "strictNullChecks": true,                         /* When type checking, take into account 'null' and 'undefined'. */
-    // "strictFunctionTypes": true,                      /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
-    // "strictBindCallApply": true,                      /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
-    // "strictPropertyInitialization": true,             /* Check for class properties that are declared but not set in the constructor. */
-    // "noImplicitThis": true,                           /* Enable error reporting when 'this' is given the type 'any'. */
-    // "useUnknownInCatchVariables": true,               /* Default catch clause variables as 'unknown' instead of 'any'. */
-    // "alwaysStrict": true,                             /* Ensure 'use strict' is always emitted. */
-    "noUnusedLocals": false, /* Enable error reporting when local variables aren't read. */
-    "noUnusedParameters": false, /* Raise an error when a function parameter isn't read. */
-    // "exactOptionalPropertyTypes": true,               /* Interpret optional property types as written, rather than adding 'undefined'. */
-    // "noImplicitReturns": true,                        /* Enable error reporting for codepaths that do not explicitly return in a function. */
-    // "noFallthroughCasesInSwitch": true,               /* Enable error reporting for fallthrough cases in switch statements. */
-    // "noUncheckedIndexedAccess": true,                 /* Add 'undefined' to a type when accessed using an index. */
-    // "noImplicitOverride": true,                       /* Ensure overriding members in derived classes are marked with an override modifier. */
-    // "noPropertyAccessFromIndexSignature": true,       /* Enforces using indexed accessors for keys declared using an indexed type. */
-    // "allowUnusedLabels": true,                        /* Disable error reporting for unused labels. */
-    // "allowUnreachableCode": true,                     /* Disable error reporting for unreachable code. */
-    /* Completeness */
-    "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
-    "skipLibCheck": true /* Skip type checking all .d.ts files. */
-  }
-}
\ No newline at end of file
diff --git a/packages/server/wrangler.toml b/packages/server/wrangler.toml
deleted file mode 100644
index 32dbdf4..0000000
--- a/packages/server/wrangler.toml
+++ /dev/null
@@ -1,7 +0,0 @@
-name = "iris-server"
-main = "src/index.ts"
-compatibility_date = "2025-07-12"
-compatibility_flags = [ "nodejs_compat" ]
-
-account_id = "17174d3fc7098a664b04fb3c4c4ec65c"
-workers_dev = false
\ No newline at end of file
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