diff --git a/endgen-maven-plugin/README.md b/endgen-maven-plugin/README.md
new file mode 100644
index 0000000..1e02921
--- /dev/null
+++ b/endgen-maven-plugin/README.md
@@ -0,0 +1,8 @@
+# References
+## Maven
+
+https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html#generatedsourcesdirectory
+
+### For executing the plugin several times
+See executions
+https://maven.apache.org/guides/mini/guide-configuring-plugins.html
\ No newline at end of file
diff --git a/endgen-maven-plugin/pom.xml b/endgen-maven-plugin/pom.xml
new file mode 100644
index 0000000..e9f42c5
--- /dev/null
+++ b/endgen-maven-plugin/pom.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>nu.zoom.dsl</groupId>
+        <artifactId>endgen</artifactId>
+        <version>1.2-SNAPSHOT</version>
+    </parent>
+    <artifactId>endgen-maven-plugin</artifactId>
+    <packaging>maven-plugin</packaging>
+
+    <properties>
+        <maven-plugin-tools.version>3.15.1</maven-plugin-tools.version>
+    </properties>
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.maven</groupId>
+            <artifactId>maven-plugin-api</artifactId>
+            <version>3.9.9</version>
+            <scope>provided</scope>
+        </dependency>
+        <!-- dependency on annotations -->
+        <dependency>
+            <groupId>org.apache.maven.plugin-tools</groupId>
+            <artifactId>maven-plugin-annotations</artifactId>
+            <version>${maven-plugin-tools.version}</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>nu.zoom.dsl</groupId>
+            <artifactId>parser</artifactId>
+            <version>${project.parent.version}</version>
+        </dependency>
+    </dependencies>
+    <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-plugin-plugin</artifactId>
+                    <version>${maven-plugin-tools.version}</version>
+                    <executions>
+                        <execution>
+                            <id>help-mojo</id>
+                            <goals>
+                                <goal>helpmojo</goal>
+                            </goals>
+                        </execution>
+                    </executions>
+                </plugin>
+                <plugin>
+                    <groupId>nu.zoom.dsl</groupId>
+                    <artifactId>endgen-maven-plugin</artifactId>
+                    <version>1.2-SNAPSHOT</version>
+                    <executions>
+                        <execution>
+                            <configuration>
+                                <templates>${project.build.sourceDirectory}/main/endgen-templates</templates>
+                                <output>${project.build.sourceDirectory}/generated-sources/endgen endpoints-output</output>
+                                <dsl>${project.basedir}/../test01.endpoints</dsl>
+                            </configuration>
+                        </execution>
+                    </executions>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+    </build>
+</project>
\ No newline at end of file
diff --git a/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/EndgenMojo.java b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/EndgenMojo.java
new file mode 100644
index 0000000..8d204aa
--- /dev/null
+++ b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/EndgenMojo.java
@@ -0,0 +1,64 @@
+package nu.zoom.dsl.maven;
+
+import nu.zoom.dsl.run.Runner;
+import nu.zoom.dsl.run.ValidationException;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import java.io.File;
+import java.util.Optional;
+
+@Mojo(
+        name = "endgen",
+        defaultPhase = LifecyclePhase.GENERATE_SOURCES
+)
+public class EndgenMojo extends AbstractMojo {
+    @Parameter(defaultValue = "${project.build.sourceDirectory}/main/endgen-templates")
+    File templates;
+
+    @Parameter(defaultValue = "${project.build.outputDirectory}/generated-sources/endgen")
+    File output;
+
+    @Parameter
+    File dsl;
+
+    @Parameter
+    String parser;
+
+
+    @Override
+    public void execute() throws MojoExecutionException, MojoFailureException {
+        getLog().info("Running endgen");
+        getLog().info("Using dsl: " + dsl);
+        try {
+            Runner.run(
+                    optional(dsl).map(File::toPath).orElseThrow(),
+                    optional(templates).map(File::toPath),
+                    optional(output).map(File::toPath),
+                    getParserType(parser),
+                    new MavenLogger(getLog())
+            );
+        } catch (Exception e) {
+            throw new MojoExecutionException(e.getMessage(), e);
+        }
+    }
+
+    private Optional<Runner.ParserType> getParserType(final String type) throws ValidationException {
+        if (type == null) {
+            return Optional.empty();
+        }
+        try {
+            return Optional.of(Runner.ParserType.valueOf(type));
+        } catch (IllegalArgumentException e) {
+            throw new ValidationException(e);
+        }
+    }
+
+    private <T> Optional<T> optional(T arg) {
+        return arg == null ? Optional.empty() : Optional.of(arg);
+    }
+}
diff --git a/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/MavenLogger.java b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/MavenLogger.java
new file mode 100644
index 0000000..03b98be
--- /dev/null
+++ b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/MavenLogger.java
@@ -0,0 +1,17 @@
+package nu.zoom.dsl.maven;
+
+import nu.zoom.dsl.run.Logger;
+import org.apache.maven.plugin.logging.Log;
+
+public class MavenLogger implements Logger {
+    private final Log delegate;
+
+    public MavenLogger(Log delegate) {
+        this.delegate = delegate;
+    }
+
+    @Override
+    public void println(String message) {
+        this.delegate.debug(message);
+    }
+}
diff --git a/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/Run.java b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/Run.java
new file mode 100644
index 0000000..c8a0779
--- /dev/null
+++ b/endgen-maven-plugin/src/main/java/nu/zoom/dsl/maven/Run.java
@@ -0,0 +1,52 @@
+package nu.zoom.dsl.maven;
+
+import java.io.File;
+
+public class Run {
+    private File templates;
+    private File output;
+    private File dsl;
+    private String parser;
+
+    public File getTemplates() {
+        return templates;
+    }
+
+    public void setTemplates(File templates) {
+        this.templates = templates;
+    }
+
+    public File getOutput() {
+        return output;
+    }
+
+    public void setOutput(File output) {
+        this.output = output;
+    }
+
+    public File getDsl() {
+        return dsl;
+    }
+
+    public void setDsl(File dsl) {
+        this.dsl = dsl;
+    }
+
+    public String getParser() {
+        return parser;
+    }
+
+    public void setParser(String parser) {
+        this.parser = parser;
+    }
+
+    @Override
+    public String toString() {
+        return "Run{" +
+                "templates=" + templates +
+                ", output=" + output +
+                ", dsl=" + dsl +
+                ", parser='" + parser + '\'' +
+                '}';
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java b/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java
index e3d5794..8d79757 100644
--- a/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java
+++ b/parser/src/main/java/nu/zoom/dsl/cli/EndpointsCLI.java
@@ -14,7 +14,7 @@
 package nu.zoom.dsl.cli;
 
 import nu.zoom.dsl.ast.DocumentNode;
-import nu.zoom.dsl.ast.ParserWrapper;
+import nu.zoom.dsl.run.*;
 import nu.zoom.dsl.freemarker.Generator;
 import picocli.CommandLine;
 import picocli.CommandLine.Command;
@@ -24,8 +24,8 @@ import picocli.CommandLine.Parameters;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.Paths;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.Callable;
 
 @Command(
@@ -34,20 +34,16 @@ import java.util.concurrent.Callable;
 	description = "Generate source code from an endpoints specification file."
 )
 public class EndpointsCLI implements Callable<Integer> {
-	public enum ParserType {
-		Endpoints,
-		States
-	}
 	@SuppressWarnings("unused")
     @Parameters(index = "0", description = "The source endpoints DSL file.")
 	private Path file;
 
 	@SuppressWarnings("CanBeFinal")
-    @Option(names = {"-t", "--template"}, defaultValue = "endpoints-template", description = "The template directory. Default is ${DEFAULT-VALUE}")
+    @Option(names = {"-t", "--template"}, defaultValue = Runner.DEFAULT_TEMPLATE_DIRECTORY_NAME, description = "The template directory. Default is ${DEFAULT-VALUE}")
 	private Path templateDir ;
 
 	@SuppressWarnings("CanBeFinal")
-    @Option(names = {"-o", "--output"}, defaultValue = "endpoints-output", description = "The directory to write the generated code to. Default is ${DEFAULT-VALUE}")
+    @Option(names = {"-o", "--output"}, defaultValue = Runner.DEFAULT_OUTPUT_DIRECTORY_NAME, description = "The directory to write the generated code to. Default is ${DEFAULT-VALUE}")
 	private Path outputDir ;
 
 	@SuppressWarnings("unused")
@@ -55,7 +51,7 @@ public class EndpointsCLI implements Callable<Integer> {
 	private Boolean verbose = false;
 
 	@Option(names = {"-p", "--parser"}, description = "Force use of a specific parser instead of determining from filename. Valid values: ${COMPLETION-CANDIDATES}.")
-	private ParserType parser = null;
+	private Runner.ParserType parser = null;
 
 	public static void main(String[] args) {
 		int exitCode = new CommandLine(new EndpointsCLI()).execute(args);
@@ -65,32 +61,14 @@ public class EndpointsCLI implements Callable<Integer> {
 	@Override
 	public Integer call() {
 		try {
-			validateTemplateDirectory();
-			validateInputFile();
-			validateOutputDirectory();
-			verbose("Parsing: " + file.toAbsolutePath());
-			if (parser == null) {
-				if (file.getFileName().toString().endsWith(".states")) {
-					parser = ParserType.States;
-				}
-			}
-			final DocumentNode rootNode ;
-			if (parser == ParserType.States) {
-				verbose("using state grammar.") ;
-				rootNode = ParserWrapper.parseStates(file);
-			} else {
-				verbose("using endpoints grammar.") ;
-				rootNode = ParserWrapper.parseEndpoints(file);
-			}
-			verbose("AST: " + rootNode);
-			verbose("Generating from templates in: " + templateDir.toAbsolutePath());
-			Generator generator = new Generator(templateDir, rootNode, outputDir);
-			List<Path> generatedPaths = generator.generate();
-			if (generatedPaths.isEmpty()) {
-				System.out.println("No generated paths found.");
-			} else {
-				generatedPaths.forEach(p -> verbose("Generated: " + p.toAbsolutePath()));
-			}
+			final Logger logger = this.verbose ? new StdoutLogger() : new NullLogger() ;
+			Runner.run(
+					this.file,
+					Optional.of(this.templateDir),
+					Optional.of(this.outputDir),
+					parser == null ? Optional.empty() : Optional.of(parser),
+					logger
+			);
 			return 0;
 		} catch (Exception e) {
 			System.err.println(e.getMessage());
diff --git a/parser/src/main/java/nu/zoom/dsl/run/EndgenException.java b/parser/src/main/java/nu/zoom/dsl/run/EndgenException.java
new file mode 100644
index 0000000..7771e11
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/EndgenException.java
@@ -0,0 +1,15 @@
+package nu.zoom.dsl.run;
+
+public abstract class EndgenException extends Exception {
+    public EndgenException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public EndgenException(Throwable cause) {
+        super(cause);
+    }
+
+    public EndgenException(String message) {
+        super(message);
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/GeneratorException.java b/parser/src/main/java/nu/zoom/dsl/run/GeneratorException.java
new file mode 100644
index 0000000..5157d97
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/GeneratorException.java
@@ -0,0 +1,15 @@
+package nu.zoom.dsl.run;
+
+public class GeneratorException extends EndgenException {
+    public GeneratorException(String message) {
+        super(message);
+    }
+
+    public GeneratorException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public GeneratorException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/Logger.java b/parser/src/main/java/nu/zoom/dsl/run/Logger.java
new file mode 100644
index 0000000..80826d3
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/Logger.java
@@ -0,0 +1,5 @@
+package nu.zoom.dsl.run;
+
+public interface Logger {
+    void println(String message);
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/NullLogger.java b/parser/src/main/java/nu/zoom/dsl/run/NullLogger.java
new file mode 100644
index 0000000..014f473
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/NullLogger.java
@@ -0,0 +1,8 @@
+package nu.zoom.dsl.run;
+
+public class NullLogger implements Logger {
+    @Override
+    public void println(String message) {
+
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/ParserException.java b/parser/src/main/java/nu/zoom/dsl/run/ParserException.java
new file mode 100644
index 0000000..b726384
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/ParserException.java
@@ -0,0 +1,15 @@
+package nu.zoom.dsl.run;
+
+public class ParserException extends EndgenException {
+    public ParserException(String message) {
+        super(message);
+    }
+
+    public ParserException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public ParserException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/ast/ParserWrapper.java b/parser/src/main/java/nu/zoom/dsl/run/ParserWrapper.java
similarity index 93%
rename from parser/src/main/java/nu/zoom/dsl/ast/ParserWrapper.java
rename to parser/src/main/java/nu/zoom/dsl/run/ParserWrapper.java
index 11a95fd..ce62d9c 100644
--- a/parser/src/main/java/nu/zoom/dsl/ast/ParserWrapper.java
+++ b/parser/src/main/java/nu/zoom/dsl/run/ParserWrapper.java
@@ -11,8 +11,11 @@
 //   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 //   See the License for the specific language governing permissions and
 //   limitations under the License.
-package nu.zoom.dsl.ast;
+package nu.zoom.dsl.run;
 
+import nu.zoom.dsl.ast.DocumentNode;
+import nu.zoom.dsl.ast.EndpointsVisitorTransformer;
+import nu.zoom.dsl.ast.StatesVisitorTransformer;
 import nu.zoom.dsl.parser.*;
 import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.CommonTokenStream;
diff --git a/parser/src/main/java/nu/zoom/dsl/run/Runner.java b/parser/src/main/java/nu/zoom/dsl/run/Runner.java
new file mode 100644
index 0000000..ebf162b
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/Runner.java
@@ -0,0 +1,96 @@
+package nu.zoom.dsl.run;
+
+import freemarker.template.TemplateException;
+import nu.zoom.dsl.ast.DocumentNode;
+import nu.zoom.dsl.freemarker.Generator;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Optional;
+
+public final class Runner {
+    public static final String DEFAULT_TEMPLATE_DIRECTORY_NAME = "endpoints-template";
+    public static final String DEFAULT_OUTPUT_DIRECTORY_NAME = "endpoints-output";
+
+    public static void run(
+            Path dsl,
+            Optional<Path> templates,
+            Optional<Path> output,
+            Optional<ParserType> maybeParser,
+            Logger logger
+    ) throws ValidationException, IOException, GeneratorException {
+        Path templatesDir = templates.orElse(Paths.get(DEFAULT_TEMPLATE_DIRECTORY_NAME));
+        Path outputDir = output.orElse(Paths.get(DEFAULT_OUTPUT_DIRECTORY_NAME));
+
+        validateOutputDirectory(outputDir);
+        validateTemplateDirectory(templatesDir);
+        validateInputFile(dsl);
+        logger.println("Parsing: " + dsl.toAbsolutePath());
+
+        final ParserType parser =
+                maybeParser.orElseGet(
+                        () -> {
+                            if (dsl.getFileName().toString().endsWith(".states")) {
+                                return ParserType.States;
+                            } else {
+                                return ParserType.Endpoints;
+                            }
+                        }
+                );
+
+        final DocumentNode rootNode;
+        if (parser == ParserType.States) {
+            logger.println("using state grammar.");
+            rootNode = ParserWrapper.parseStates(dsl);
+        } else {
+            logger.println("using endpoints grammar.");
+            rootNode = ParserWrapper.parseEndpoints(dsl);
+        }
+        logger.println("AST: " + rootNode);
+        logger.println("Generating from templates in: " + templatesDir.toAbsolutePath());
+        Generator generator = new Generator(templatesDir, rootNode, outputDir);
+        List<Path> generatedPaths = null;
+        try {
+            generatedPaths = generator.generate();
+        } catch (TemplateException e) {
+            throw new GeneratorException(e);
+        }
+        if (generatedPaths.isEmpty()) {
+            System.out.println("No generated paths found.");
+        } else {
+            generatedPaths.forEach(p -> logger.println("Generated: " + p.toAbsolutePath()));
+        }
+    }
+
+    private static void validateOutputDirectory(Path outputDir) throws IOException, ValidationException {
+        if (Files.notExists(outputDir)) {
+            Files.createDirectories(outputDir);
+        }
+        if (!Files.isDirectory(outputDir)) {
+            throw new ValidationException("Output directory: '" + outputDir + " 'is not a directory.");
+        }
+    }
+
+    private static void validateTemplateDirectory(Path templateDir) throws ValidationException {
+        if (!Files.isDirectory(templateDir)) {
+            throw new ValidationException("Template directory '" + templateDir + "' is not a directory.");
+        }
+    }
+
+    private static void validateInputFile(Path file) throws ValidationException {
+        if (Files.notExists(file)) {
+            throw new ValidationException("Input file '" + file + "' does not exist.");
+        }
+        if (!Files.isReadable(file) || !Files.isRegularFile(file)) {
+            throw new ValidationException("Input file '" + file + "' is not a readable file.");
+        }
+    }
+
+    public enum ParserType {
+        Endpoints,
+        States
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/StdoutLogger.java b/parser/src/main/java/nu/zoom/dsl/run/StdoutLogger.java
new file mode 100644
index 0000000..7b0b7d4
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/StdoutLogger.java
@@ -0,0 +1,9 @@
+package nu.zoom.dsl.run;
+
+public class StdoutLogger implements Logger {
+
+    @Override
+    public void println(String message) {
+        System.out.println(message);
+    }
+}
diff --git a/parser/src/main/java/nu/zoom/dsl/run/ValidationException.java b/parser/src/main/java/nu/zoom/dsl/run/ValidationException.java
new file mode 100644
index 0000000..1627d72
--- /dev/null
+++ b/parser/src/main/java/nu/zoom/dsl/run/ValidationException.java
@@ -0,0 +1,15 @@
+package nu.zoom.dsl.run;
+
+public class ValidationException extends EndgenException {
+    public ValidationException(String message) {
+        super(message);
+    }
+
+    public ValidationException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public ValidationException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/pom.xml b/pom.xml
index 86744cc..cb060c8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -65,6 +65,7 @@
     <modules>
         <module>parser</module>
         <module>endgen-dist</module>
+        <module>endgen-maven-plugin</module>
     </modules>
 
 </project>