diff --git a/src/lib/Hydra/Controller/Jobset.pm b/src/lib/Hydra/Controller/Jobset.pm
index f2b801f1..06ccebcd 100644
--- a/src/lib/Hydra/Controller/Jobset.pm
+++ b/src/lib/Hydra/Controller/Jobset.pm
@@ -55,6 +55,10 @@ sub jobset_PUT {
 
     requireProjectOwner($c, $c->stash->{project});
 
+    if (length($c->stash->{project}->declfile)) {
+        error($c, "can't modify jobset of declarative project", 403);
+    }
+
     if (defined $c->stash->{jobset}) {
         txn_do($c->model('DB')->schema, sub {
             updateJobset($c, $c->stash->{jobset});
@@ -88,6 +92,10 @@ sub jobset_DELETE {
 
     requireProjectOwner($c, $c->stash->{project});
 
+    if (length($c->stash->{project}->declfile)) {
+        error($c, "can't modify jobset of declarative project", 403);
+    }
+
     txn_do($c->model('DB')->schema, sub {
         $c->stash->{jobset}->jobsetevals->delete;
         $c->stash->{jobset}->builds->delete;
diff --git a/src/lib/Hydra/Controller/Project.pm b/src/lib/Hydra/Controller/Project.pm
index 4cd577b1..07be4d4a 100644
--- a/src/lib/Hydra/Controller/Project.pm
+++ b/src/lib/Hydra/Controller/Project.pm
@@ -154,7 +154,19 @@ sub updateProject {
         , enabled => defined $c->stash->{params}->{enabled} ? 1 : 0
         , hidden => defined $c->stash->{params}->{visible} ? 0 : 1
         , owner => $owner
+        , declfile => trim($c->stash->{params}->{declfile})
+        , decltype => trim($c->stash->{params}->{decltype})
+        , declvalue => trim($c->stash->{params}->{declvalue})
         });
+    if (length($project->declfile)) {
+        $project->jobsets->update_or_create(
+            { name=> ".jobsets"
+            , nixexprinput => ""
+            , nixexprpath => ""
+            , emailoverride => ""
+            , triggertime => time
+            });
+    }
 }
 
 
diff --git a/src/lib/Hydra/Helper/AddBuilds.pm b/src/lib/Hydra/Helper/AddBuilds.pm
index 737fdbd4..0fa7087f 100644
--- a/src/lib/Hydra/Helper/AddBuilds.pm
+++ b/src/lib/Hydra/Helper/AddBuilds.pm
@@ -22,7 +22,8 @@ use Hydra::Helper::CatalystUtils;
 our @ISA = qw(Exporter);
 our @EXPORT = qw(
     fetchInput evalJobs checkBuild inputsToArgs
-    restartBuild getPrevJobsetEval
+    restartBuild getPrevJobsetEval updateDeclarativeJobset
+    handleDeclarativeJobsetBuild
 );
 
 
@@ -467,4 +468,66 @@ sub checkBuild {
 };
 
 
+sub updateDeclarativeJobset {
+    my ($db, $project, $jobsetName, $declSpec) = @_;
+
+    my @allowed_keys = qw(
+        enabled
+        hidden
+        description
+        nixexprinput
+        nixexprpath
+        checkinterval
+        schedulingshares
+        enableemail
+        emailoverride
+        keepnr
+    );
+    my %update = ( name => $jobsetName );
+    foreach my $key (@allowed_keys) {
+        $update{$key} = $declSpec->{$key};
+        delete $declSpec->{$key};
+    }
+    txn_do($db, sub {
+        my $jobset = $project->jobsets->update_or_create(\%update);
+        $jobset->jobsetinputs->delete;
+        while ((my $name, my $data) = each %{$declSpec->{"inputs"}}) {
+            my $input = $jobset->jobsetinputs->create(
+                { name => $name,
+                  type => $data->{type},
+                  emailresponsible => $data->{emailresponsible}
+                });
+            $input->jobsetinputalts->create({altnr => 0, value => $data->{value}});
+        }
+        delete $declSpec->{"inputs"};
+        die "invalid keys in declarative specification file\n" if (%{$declSpec});
+    });
+};
+
+
+sub handleDeclarativeJobsetBuild {
+    my ($db, $project, $build) = @_;
+
+    eval {
+        my $id = $build->id;
+        die "Declarative jobset build $id failed" unless $build->buildstatus == 0;
+        my $declPath = ($build->buildoutputs)[0]->path;
+        my $declText = read_file($declPath)
+            or die "Couldn't read declarative specification file $declPath: $!";
+        my $declSpec = decode_json($declText);
+        txn_do($db, sub {
+            my @kept = keys %$declSpec;
+            push @kept, ".jobsets";
+            $project->jobsets->search({ name => { "not in" => \@kept } })->update({ enabled => 0, hidden => 1 });
+            while ((my $jobsetName, my $spec) = each %$declSpec) {
+                updateDeclarativeJobset($db, $project, $jobsetName, $spec);
+            }
+        });
+    };
+    $project->jobsets->find({ name => ".jobsets" })->update({ errormsg => $@, errortime => time, fetcherrormsg => undef })
+        if defined $@;
+
+};
+
+
 1;
diff --git a/src/lib/Hydra/Helper/CatalystUtils.pm b/src/lib/Hydra/Helper/CatalystUtils.pm
index df966d80..019c07b7 100644
--- a/src/lib/Hydra/Helper/CatalystUtils.pm
+++ b/src/lib/Hydra/Helper/CatalystUtils.pm
@@ -264,7 +264,7 @@ Readonly our $inputNameRE   => "(?:[A-Za-z_][A-Za-z0-9-_]*)";
 
 sub parseJobsetName {
     my ($s) = @_;
-    $s =~ /^($projectNameRE):($jobsetNameRE)$/ or die "invalid jobset specifier ā€˜$s’\n";
+    $s =~ /^($projectNameRE):(\.?$jobsetNameRE)$/ or die "invalid jobset specifier ā€˜$s’\n";
     return ($1, $2);
 }
 
diff --git a/src/lib/Hydra/Schema/Projects.pm b/src/lib/Hydra/Schema/Projects.pm
index e04b1f8e..11405561 100644
--- a/src/lib/Hydra/Schema/Projects.pm
+++ b/src/lib/Hydra/Schema/Projects.pm
@@ -73,6 +73,21 @@ __PACKAGE__->table("Projects");
   data_type: 'text'
   is_nullable: 1
 
+=head2 declfile
+
+  data_type: 'text'
+  is_nullable: 1
+
+=head2 decltype
+
+  data_type: 'text'
+  is_nullable: 1
+
+=head2 declvalue
+
+  data_type: 'text'
+  is_nullable: 1
+
 =cut
 
 __PACKAGE__->add_columns(
@@ -90,6 +105,12 @@ __PACKAGE__->add_columns(
   { data_type => "text", is_foreign_key => 1, is_nullable => 0 },
   "homepage",
   { data_type => "text", is_nullable => 1 },
+  "declfile",
+  { data_type => "text", is_nullable => 1 },
+  "decltype",
+  { data_type => "text", is_nullable => 1 },
+  "declvalue",
+  { data_type => "text", is_nullable => 1 },
 );
 
 =head1 PRIMARY KEY
@@ -282,8 +303,8 @@ Composing rels: L</projectmembers> -> username
 __PACKAGE__->many_to_many("usernames", "projectmembers", "username");
 
 
-# Created by DBIx::Class::Schema::Loader v0.07043 @ 2015-07-30 16:52:20
-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:67kWIE0IGmEJTvOIATAKaw
+# Created by DBIx::Class::Schema::Loader v0.07043 @ 2016-03-11 10:39:17
+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:1ats3brIVhRTWLToIYSoaQ
 
 my %hint = (
     columns => [
diff --git a/src/root/edit-project.tt b/src/root/edit-project.tt
index b8d9bacb..cedc36ee 100644
--- a/src/root/edit-project.tt
+++ b/src/root/edit-project.tt
@@ -53,6 +53,25 @@
       </div>
     </div>
 
+    <div class="control-group">
+      <label class="control-label">Declarative spec file</label>
+      <div class="controls">
+        <div class="input-append">
+          <input type="text" class="span3" name="declfile" [% HTML.attributes(value => project.declfile) %]/>
+        </div>
+        <span class="help-inline">(Leave blank for non-declarative project configuration)</span>
+      </div>
+    </div>
+
+    <div class="control-group">
+      <label class="control-label">Declarative input type</label>
+      <div class="controls">
+        [% INCLUDE renderSelection param="decltype" options=inputTypes edit=1 curValue=project.decltype %]
+        value
+        <input style="width: 70%" type="text" [% HTML.attributes(value => project.declvalue, name => "declvalue") %]/>
+      </div>
+    </div>
+
     <div class="form-actions">
       <button id="submit-project" type="submit" class="btn btn-primary">
         <i class="icon-ok icon-white"></i>
diff --git a/src/root/jobset.tt b/src/root/jobset.tt
index 04b0efaf..283d1a25 100644
--- a/src/root/jobset.tt
+++ b/src/root/jobset.tt
@@ -49,9 +49,11 @@
         <b class="caret"></b>
       </a>
       <ul class="dropdown-menu">
+        [% UNLESS project.declfile %]
         [% INCLUDE menuItem title="Edit configuration" icon="icon-edit" uri=c.uri_for(c.controller('Jobset').action_for('edit'), c.req.captures) %]
         [% INCLUDE menuItem title="Delete this jobset" icon="icon-trash" uri="javascript:deleteJobset()" %]
         [% INCLUDE menuItem title="Clone this jobset" uri=c.uri_for(c.controller('Jobset').action_for('edit'), c.req.captures, { cloneJobset => 1 }) %]
+        [% END %]
         [% INCLUDE menuItem title="Evaluate this jobset" uri="javascript:confirmEvaluateJobset()" %]
       </ul>
     </li>
diff --git a/src/root/project.tt b/src/root/project.tt
index 4251281f..4c36f3a1 100644
--- a/src/root/project.tt
+++ b/src/root/project.tt
@@ -11,7 +11,9 @@
       <ul class="dropdown-menu">
         [% INCLUDE menuItem title="Edit configuration" icon="icon-edit" uri=c.uri_for(c.controller('Project').action_for('edit'), c.req.captures) %]
         [% INCLUDE menuItem title="Delete this project" icon="icon-trash" uri="javascript:deleteProject()" %]
+        [% UNLESS project.declfile %]
         [% INCLUDE menuItem title="Create jobset" icon="icon-plus" uri=c.uri_for(c.controller('Project').action_for('create_jobset'), c.req.captures) %]
+        [% END %]
         [% INCLUDE menuItem title="Create release" icon="icon-plus" uri=c.uri_for(c.controller('Project').action_for('create_release'), c.req.captures) %]
       </ul>
     </li>
diff --git a/src/script/hydra-evaluator b/src/script/hydra-evaluator
index 1c4180b3..1a77c7d1 100755
--- a/src/script/hydra-evaluator
+++ b/src/script/hydra-evaluator
@@ -14,6 +14,8 @@ use Data::Dump qw(dump);
 use Try::Tiny;
 use Net::Statsd;
 use Time::HiRes qw(clock_gettime CLOCK_REALTIME);
+use JSON;
+use File::Slurp;
 
 STDOUT->autoflush();
 STDERR->autoflush(1);
@@ -100,6 +102,23 @@ sub permute {
 sub checkJobsetWrapped {
     my ($jobset) = @_;
     my $project = $jobset->project;
+    my $jobsetsJobset = length($project->declfile) && $jobset->name eq ".jobsets";
+    if ($jobsetsJobset) {
+        my @declInputs = fetchInput($plugins, $db, $project, $jobset, "decl", $project->decltype, $project->declvalue, 0);
+        my $declInput = @declInputs[0] or die "cannot find the input containing the declarative project specification\n";
+        die "multiple alternatives for the input containing the declarative project specificaiton are not supported\n"
+            if scalar @declInputs != 1;
+        my $declFile = $declInput->{storePath} . "/" . $project->declfile;
+        my $declText = read_file($declFile)
+            or die "Couldn't read declarative specification file $declFile: $!\n";
+        my $declSpec;
+        eval {
+            $declSpec = decode_json($declText);
+        };
+        die "Declarative specification file $declFile not valid JSON: $@\n" if $@;
+        updateDeclarativeJobset($db, $project, ".jobsets", $declSpec);
+        $jobset->discard_changes;
+    }
     my $inputInfo = {};
     my $exprType = $jobset->nixexprpath =~ /.scm$/ ? "guile" : "nix";
 
@@ -143,6 +162,11 @@ sub checkJobsetWrapped {
     my ($jobs, $nixExprInput) = evalJobs($inputInfo, $exprType, $jobset->nixexprinput, $jobset->nixexprpath);
     my $evalStop = clock_gettime(CLOCK_REALTIME);
 
+    if ($jobsetsJobset) {
+        my @keys = keys %$jobs;
+        die "The .jobsets jobset must only have a single job named 'jobsets'"
+            unless (scalar @keys) == 1 && $keys[0] eq "jobsets";
+    }
     Net::Statsd::timing("hydra.evaluator.eval_time", int(($evalStop - $evalStart) * 1000));
 
     if ($dryRun) {
diff --git a/src/script/hydra-notify b/src/script/hydra-notify
index 4ecce21b..e930a0dd 100755
--- a/src/script/hydra-notify
+++ b/src/script/hydra-notify
@@ -5,6 +5,7 @@ use utf8;
 use Hydra::Plugin;
 use Hydra::Helper::Nix;
 use Hydra::Helper::PluginHooks;
+use Hydra::Helper::AddBuilds;
 
 STDERR->autoflush(1);
 binmode STDERR, ":encoding(utf8)";
@@ -21,6 +22,11 @@ my $buildId = shift @ARGV or die;
 my $build = $db->resultset('Builds')->find($buildId)
     or die "build $buildId does not exist\n";
 if ($cmd eq "build-finished") {
+    my $project = $build->project;
+    my $jobset = $build->jobset;
+    if (length($project->declfile) && $jobset->name eq ".jobsets" && $build->iscurrent) {
+        handleDeclarativeJobsetBuild($db, $project, $build);
+    }
     my @dependents;
     foreach my $id (@ARGV) {
         my $dep = $db->resultset('Builds')->find($id)
diff --git a/src/sql/hydra.sql b/src/sql/hydra.sql
index e9b33d41..78fcfc79 100644
--- a/src/sql/hydra.sql
+++ b/src/sql/hydra.sql
@@ -30,6 +30,9 @@ create table Projects (
     hidden        integer not null default 0,
     owner         text not null,
     homepage      text, -- URL for the project
+    declfile      text, -- File containing declarative jobset specification
+    decltype      text, -- Type of the input containing declarative jobset specification
+    declvalue     text, -- Value of the input containing declarative jobset specification
     foreign key   (owner) references Users(userName) on update cascade
 );
 
diff --git a/src/sql/upgrade-48.sql b/src/sql/upgrade-48.sql
new file mode 100644
index 00000000..a8b0857e
--- /dev/null
+++ b/src/sql/upgrade-48.sql
@@ -0,0 +1,4 @@
+-- Add declarative fields to Projects
+alter table Projects add column declfile text;
+alter table Projects add column decltype text;
+alter table Projects add column declvalue text;