diff --git a/panel/nix/jsonschema-to-module.nix b/panel/nix/jsonschema-to-module.nix
new file mode 100644
index 00000000..4eb18ed9
--- /dev/null
+++ b/panel/nix/jsonschema-to-module.nix
@@ -0,0 +1,81 @@
+{ lib, ... }:
+
+let
+  inherit (lib)
+    fromJSON
+    mapAttrs
+    attrValues
+    concatStringsSep
+    elem
+    ;
+
+  option =
+    required: name: prop:
+    let
+      description =
+        if prop ? description then
+          ''
+            description = "${prop.description}";
+          ''
+        else
+          "";
+      default =
+        if (!elem name required && prop ? default) then
+          ''
+            default = ${to-value prop.type prop.description};
+          ''
+        else
+          "";
+
+      to-type =
+        type:
+        {
+          string = "str";
+          integer = "int";
+          boolean = "bool";
+        }
+        .${type} or (throw "Unsupported schema type: ${type}");
+
+      to-value =
+        type: value:
+        {
+          string = ''"${toString value}"'';
+          integer = toString value;
+          boolean = if value then "true" else "false";
+        }
+        .${type} or (throw "Unsupported value type: ${type}");
+    in
+    # TODO: squash
+    ''
+      ${name} = mkOption {
+        ${
+          # TODO: indent
+          description
+        }
+        type = types.${to-type prop.type};
+        ${
+          # TODO: indent
+          default
+        }
+      };
+    '';
+in
+jsonschema: name: # TODO: can't we get the name from the schema?
+let
+  schema = fromJSON jsonschema;
+  properties = schema.properties or { };
+  required = schema.required or [ ];
+  options = concatStringsSep "\n\n" (attrValues (mapAttrs (option required) properties));
+in
+# TODO: test this
+''
+  { lib, ... }:
+  let
+    inherit (lib) mkOption types;
+  in
+  {
+    options.services.${name} = {
+      ${options}
+    };
+  }
+''