diff --git a/.gitignore b/.gitignore
index 2258b7a4102ede94bd49bf3d1a65c7fc7f25142e..e087291d2752e55c0e2e301c1b7f2d675a4f5949 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
 .idea
 *.egg-info
 __pycache__
+.tox
+coverage.xml
+.coverage
+htmlcov
diff --git a/Changelog.md b/Changelog.md
new file mode 100644
index 0000000000000000000000000000000000000000..573ab94989c7e4d33e8710f6f9ef2ffb4404289e
--- /dev/null
+++ b/Changelog.md
@@ -0,0 +1,6 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.1] - 2021-06-01
+- initial release
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000000000000000000000000000000000000..5adb2b83cf466768d3165102fcdc58db524e6e4e
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1 @@
+include brian_polling_manager/logging_default_config.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..cd6712732928ae185525c2de6176326663b7441e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,9 @@
+# brian-polling-manager
+
+This module contains tooling for configuring
+SNMP polling for BRIAN.
+
+The documents should be viewable in the
+workspace of the most recent
+[Jenkins job](https://jenkins.geant.org/job/brian-polling-manager%20(develop)/ws/docs/build/html/index.html).
+
diff --git a/brian_polling_manager/__init__.py b/brian_polling_manager/__init__.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d3dbc19f3741c5d05983ac8f9878a570c22d5247 100644
--- a/brian_polling_manager/__init__.py
+++ b/brian_polling_manager/__init__.py
@@ -0,0 +1,37 @@
+"""
+automatically invoked app factory
+"""
+import logging
+import os
+
+from flask import Flask
+
+from brian_polling_manager import configuration
+
+CONFIG_KEY = 'CONFIG_PARAMS'
+SESSION_SECRET = 'super-secret'
+
+
+def create_app():
+    """
+    overrides default settings with those found
+    in the file read from env var CONFIG_FILENAME
+
+    :return: a new flask app instance
+    """
+
+    app = Flask(__name__)
+    app.secret_key = SESSION_SECRET
+
+    if 'CONFIG_FILENAME' in os.environ:
+        with open(os.environ['CONFIG_FILENAME']) as f:
+            app.config[CONFIG_KEY] = configuration.load_config(f)
+    else:
+        # this inits with the defaults
+        app.config[CONFIG_KEY] = configuration.load_config()
+
+    from brian_polling_manager import api
+    app.register_blueprint(api.routes, url_prefix='/api')
+
+    logging.info('Flask app initialized')
+    return app
diff --git a/brian_polling_manager/api.py b/brian_polling_manager/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..424813f44665e73976f4681cf2b570d6d083c212
--- /dev/null
+++ b/brian_polling_manager/api.py
@@ -0,0 +1,163 @@
+"""
+These endpoints are used to update BRIAN snmp polling checks to Sensu
+
+.. contents:: :local:
+
+
+/api/version
+---------------------
+
+.. autofunction:: brian_polling_manager.api.version
+
+
+/api/update
+-----------------------------
+
+.. autofunction:: brian_polling_manager.api.update
+
+"""
+import functools
+import logging
+import pkg_resources
+
+from flask import Blueprint, current_app, request, Response, jsonify
+
+from brian_polling_manager import CONFIG_KEY
+from brian_polling_manager import main
+
+routes = Blueprint("api-routes", __name__)
+logger = logging.getLogger(__name__)
+
+
+API_VERSION = '0.1'
+
+VERSION_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+
+    'definitions': {
+        'latch': {
+            'type': 'object',
+            'properties': {
+                'current': {'type': 'integer'},
+                'next': {'type': 'integer'},
+                'this': {'type': 'integer'},
+                'failure': {'type': 'boolean'},
+                'pending': {'type': 'boolean'},
+                'timestamp': {'type': 'number'}
+            },
+            'required': ['current', 'next', 'this', 'pending', 'failure'],
+            'additionalProperties': False
+        }
+    },
+
+    'type': 'object',
+    'properties': {
+        'api': {
+            'type': 'string',
+            'pattern': r'\d+\.\d+'
+        },
+        'module': {
+            'type': 'string',
+            'pattern': r'\d+\.\d+'
+        },
+        'latch': {'$ref': '#/definitions/latch'}
+    },
+    'required': ['api', 'module'],
+    'additionalProperties': False
+}
+
+
+class APIUpstreamError(Exception):
+    def __init__(self, message, status_code=504):
+        super().__init__()
+        self.message = message
+        self.status_code = status_code
+
+
+@routes.errorhandler(APIUpstreamError)
+def handle_request_error(error):
+    logger.error(f'api error: {error.message}')
+    return Response(
+        response=error.message,
+        status=error.status_code,
+        mimetype='text/html')
+
+
+def require_accepts_json(f):
+    """
+    used as a route handler decorator to return an error
+    unless the request allows responses with type "application/json"
+    :param f: the function to be decorated
+    :return: the decorated function
+    """
+    @functools.wraps(f)
+    def decorated_function(*args, **kwargs):
+        # TODO: use best_match to disallow */* ...?
+        if not request.accept_mimetypes.accept_json:
+            return Response(
+                response="response will be json",
+                status=406,
+                mimetype="text/html")
+        return f(*args, **kwargs)
+    return decorated_function
+
+
+@routes.after_request
+def after_request(response):
+    """
+    Generic function to do additional logging of requests & responses.
+
+    :param response:
+    :return:
+    """
+    if response.status_code != 200:
+
+        try:
+            data = response.data.decode('utf-8')
+        except Exception:
+            # never expected to happen, but we don't want any failures here
+            logging.exception('INTERNAL DECODING ERROR')
+            data = 'decoding error (see logs)'
+
+        logger.warning(
+            f'[{response.status_code}] {request.method} {request.path} {data}')
+    else:
+        logger.info(
+            f'[{response.status_code}] {request.method} {request.path}')
+    return response
+
+
+@routes.route('/version', methods=['GET', 'POST'])
+@require_accepts_json
+def version():
+    """
+    Returns a json object with information about the module version.
+
+    The response will be formatted according to the following schema:
+
+    .. asjson:: brian_polling_manager.api.VERSION_SCHEMA
+
+    :return:
+    """
+    version_params = {
+        'api': API_VERSION,
+        'module':
+            pkg_resources.get_distribution('brian_polling_manager').version
+    }
+    return jsonify(version_params)
+
+
+@routes.route('/update', methods=['GET', 'POST'])
+@require_accepts_json
+def update():
+    """
+    Update checks based on current inventory provider state.
+
+    The response will be formatted according to the following schema:
+
+    .. asjson:: brian_polling_manager.main.REFRESH_RESULT_SCHEMA
+
+    :return:
+    """
+    response = main.refresh(config=current_app.config[CONFIG_KEY])
+    return jsonify(response)
diff --git a/brian_polling_manager/app.py b/brian_polling_manager/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..c998a1b8bf4c650f23cfe981be3cf5c3f479e4be
--- /dev/null
+++ b/brian_polling_manager/app.py
@@ -0,0 +1,9 @@
+"""
+default app creation
+"""
+import brian_polling_manager
+
+app = brian_polling_manager.create_app()
+
+if __name__ == "__main__":
+    app.run(host="::", port="7878")
diff --git a/brian_polling_manager/cli.py b/brian_polling_manager/configuration.py
similarity index 62%
rename from brian_polling_manager/cli.py
rename to brian_polling_manager/configuration.py
index e9015a357db3a3362763571069fef1850697b281..bbb136dd4601b54c3863813d80a39eb4b2463f6d 100644
--- a/brian_polling_manager/cli.py
+++ b/brian_polling_manager/configuration.py
@@ -1,14 +1,19 @@
 import json
 import logging
+import logging.config
 import os
+from typing import Union
 
-import click
 import jsonschema
 
-from brian_polling_manager import inventory, interfaces
+from brian_polling_manager import inventory
 
 logger = logging.getLogger(__name__)
 
+_DEFAULT_LOGGING_FILENAME = os.path.join(
+    os.path.dirname(__file__),
+    'logging_default_config.json')
+
 _DEFAULT_CONFIG = {
     'inventory': [
         'http://test-inventory-provider01.geant.org:8080',
@@ -20,7 +25,7 @@ _DEFAULT_CONFIG = {
             'https://test-poller-sensu-agent02.geant.org:8080',
             'https://test-poller-sensu-agent03.geant.org:8080'
         ],
-        'token': '696a815c-607e-4090-81de-58988c83033e',
+        'api-key': '696a815c-607e-4090-81de-58988c83033e',
         'interface-check': {
             'script': '/var/lib/sensu/bin/counter2influx.sh',
             'measurement': 'counters',
@@ -29,10 +34,18 @@ _DEFAULT_CONFIG = {
             'output_metric_handlers': ['influx-db-handler'],
             'namespace': 'default',
             'round_robin': True,
-            'command': '{script} {measurement} {community} {hostname} {interface} {ifIndex}',
+            'command': ('{script} {measurement} '
+                        '{community} {hostname} '
+                        '{interface} {ifIndex}'),
         }
     },
-    'statedir': '/tmp/'
+    'statedir': '/tmp/',
+    'logging': _DEFAULT_LOGGING_FILENAME,
+    'statsd': {
+        'hostname': 'localhost',
+        'port': 8125,
+        'prefix': 'brian_polling'
+    }
 }
 
 CONFIG_SCHEMA = {
@@ -69,10 +82,20 @@ CONFIG_SCHEMA = {
                     'items': {'type': 'string'},
                     'minItems': 1
                 },
-                'token': {'type': 'string'},
+                'api-key': {'type': 'string'},
                 'interface-check': {'$ref': '#/definitions/influx-check'}
             },
-            'required': ['api-base', 'token', 'interface-check'],
+            'required': ['api-base', 'api-key', 'interface-check'],
+            'additionalProperties': False
+        },
+        'statsd': {
+            'type': 'object',
+            'properties': {
+                'hostname': {'type': 'string'},
+                'port': {'type': 'integer'},
+                'prefix': {'type': 'string'}
+            },
+            'required': ['hostname', 'port', 'prefix'],
             'additionalProperties': False
         }
     },
@@ -84,7 +107,9 @@ CONFIG_SCHEMA = {
             'minItems': 1
         },
         'sensu': {'$ref': '#/definitions/sensu'},
-        'statedir': {'type': 'string'}
+        'statedir': {'type': 'string'},
+        'logging': {'type': 'string'},
+        'statsd': {'$ref': '#/definitions/statsd'}
     },
     'required': ['inventory', 'sensu', 'statedir'],
     'additionalProperties': False
@@ -131,19 +156,26 @@ class State(object):
         return state['last'] if state else -1
 
     @last.setter
-    def last(self, new_last: float):
-        state = {'last': new_last}
-        with open(self.filenames['state'], 'w') as f:
-            f.write(json.dumps(state))
+    def last(self, new_last: Union[float, None]):
+        if not new_last or new_last < 0:
+            os.unlink(self.filenames['state'])
+        else:
+            state = {'last': new_last}
+            with open(self.filenames['state'], 'w') as f:
+                f.write(json.dumps(state))
 
     @property
     def interfaces(self) -> list:
-        return State._load_json(self.filenames['cache'], inventory.INVENTORY_INTERFACES_SCHEMA)
+        return State._load_json(
+            self.filenames['cache'],
+            inventory.INVENTORY_INTERFACES_SCHEMA)
 
     @interfaces.setter
     def interfaces(self, new_interfaces):
         try:
-            jsonschema.validate(new_interfaces, inventory.INVENTORY_INTERFACES_SCHEMA)
+            jsonschema.validate(
+                new_interfaces,
+                inventory.INVENTORY_INTERFACES_SCHEMA)
         except jsonschema.ValidationError:
             logger.exception('invalid interface state data')
             return
@@ -152,56 +184,41 @@ class State(object):
             f.write(json.dumps(new_interfaces))
 
 
-def _validate_config(ctx, param, value):
+def _setup_logging(filename=None):
+    """
+    set up logging using the configured filename
+
+    :raises: json.JSONDecodeError, OSError, AttributeError,
+        ValueError, TypeError, ImportError
+    """
+    if not filename:
+        filename = _DEFAULT_LOGGING_FILENAME
+
+    with open(filename) as f:
+        # TODO: this mac workaround should be removed ...
+        d = json.loads(f.read())
+        import platform
+        if platform.system() == 'Darwin':
+            d['handlers']['syslog_handler']['address'] = '/var/run/syslog'
+        logging.config.dictConfig(d)
+        # logging.config.dictConfig(json.loads(f.read()))
+
+
+def load_config(file=None):
     """
     loads, validates and returns configuration parameters
 
-    :param ctx:
-    :param param:
-    :param value: filename (string)
+    :param value: filename (file-like object, opened for reading)
     :return: a dict containing configuration parameters
+    :raises: json.JSONDecodeError, jsonschema.ValidationError,
+        OSError, AttributeError, ValueError, TypeError, ImportError
     """
-    if value is None:
+    if not file:
         config = _DEFAULT_CONFIG
     else:
-        try:
-            with open(value) as f:
-                config = json.loads(f.read())
-        except (json.JSONDecodeError, jsonschema.ValidationError) as e:
-            raise click.BadParameter(str(e))
-
-    try:
+        config = json.loads(file.read())
         jsonschema.validate(config, CONFIG_SCHEMA)
-    except jsonschema.ValidationError as e:
-        raise click.BadParameter(str(e))
-
-    return config
-
 
+    _setup_logging(config.get('logging', None))
 
-
-@click.command()
-@click.option(
-    '--config',
-    default=None,
-    type=click.STRING,
-    callback=_validate_config,
-    help='configuration filename')
-@click.option(
-    '--force/--no-force',
-    default=False,
-    help="update even if inventory hasn't been updated")
-def main(config, force):
-
-    state = State(config['statedir'])
-    last = inventory.last_update_timestamp(config['inventory'])
-    if force or last != state.last:
-        state.last = last
-        state.interfaces = inventory.load_interfaces(config['inventory'])
-
-    interfaces.refresh(config['sensu'], state)
-
-
-if __name__ == '__main__':
-    logging.basicConfig(level=logging.DEBUG)
-    main()
+    return config
diff --git a/brian_polling_manager/interfaces.py b/brian_polling_manager/interfaces.py
index f50b975cafd3a0fa87b8926505682d96c73bd1e7..2c0adef6f595f21ee42d64d32c1a89394cf40727 100644
--- a/brian_polling_manager/interfaces.py
+++ b/brian_polling_manager/interfaces.py
@@ -5,17 +5,20 @@ from brian_polling_manager import sensu
 
 logger = logging.getLogger(__name__)
 
+
 def load_ifc_checks(sensu_params):
     def _is_ifc_check(check):
         name = check['metadata']['name']
-        return re.match(r'^check-([^-]+\.geant\.net)-(.+)$', name)
+        # check-* is the old-style name (add to the returned
+        # data so it can be deleted)
+        return re.match(r'^(check|ifc)-([^-]+\.geant\.net)-(.+)$', name)
     ifc_checks = filter(_is_ifc_check, sensu.load_all_checks(sensu_params))
-    return {c['metadata']['name']:c for c in ifc_checks}
+    return {c['metadata']['name']: c for c in ifc_checks}
 
 
 def _check_name(interface):
     ifc_name = interface['name'].replace('/', '-')
-    return f'check-{interface["router"]}-{ifc_name}'
+    return f'ifc-{interface["router"]}-{ifc_name}'
 
 
 def _make_check(check_params, interface):
@@ -35,7 +38,8 @@ def _make_check(check_params, interface):
         'proxy_entity_name': interface['router'],
         'round_robin': check_params['round_robin'],
         'output_metric_format': 'influxdb_line',
-        'output_metric_handlers': sorted(check_params['output_metric_handlers']),
+        'output_metric_handlers': sorted(
+            check_params['output_metric_handlers']),
         'metadata': {
             'name': _check_name(interface),
             'namespace': check_params['namespace']
@@ -44,8 +48,8 @@ def _make_check(check_params, interface):
 
 
 def _checks_match(a, b) -> bool:
-    # if a['command'] != b['command']:
-    #     return False
+    if a['command'] != b['command']:
+        return False
     if a['interval'] != b['interval']:
         return False
     if a['proxy_entity_name'] != b['proxy_entity_name']:
@@ -56,7 +60,8 @@ def _checks_match(a, b) -> bool:
         return False
     if sorted(a['subscriptions']) != sorted(b['subscriptions']):
         return False
-    if sorted(a['output_metric_handlers']) != sorted(b['output_metric_handlers']):
+    if sorted(a['output_metric_handlers']) \
+            != sorted(b['output_metric_handlers']):
         return False
     if a['metadata']['name'] != b['metadata']['name']:
         return False
@@ -69,17 +74,34 @@ def refresh(sensu_params, state):
 
     ifc_checks = load_ifc_checks(sensu_params)
 
+    created = 0
+    updated = 0
+    interfaces = 0
     for interface in state.interfaces:
 
-        expected_check = _make_check(sensu_params['interface-check'], interface)
+        interfaces += 1
+
+        expected_check = _make_check(
+            sensu_params['interface-check'], interface)
 
         expected_name = _check_name(interface)
         if expected_name not in ifc_checks:
+            created += 1
             sensu.create_check(sensu_params, expected_check)
         elif not _checks_match(ifc_checks[expected_name], expected_check):
+            updated += 1
             sensu.update_check(sensu_params, expected_check)
 
     wanted_checks = {_check_name(ifc) for ifc in state.interfaces}
     extra_checks = set(ifc_checks.keys()) - wanted_checks
     for name in extra_checks:
         sensu.delete_check(sensu_params, name)
+
+    # cf. main.REFRESH_RESULT_SCHEMA
+    return {
+        'checks': len(ifc_checks),
+        'input': interfaces,
+        'created': created,
+        'updated': updated,
+        'deleted': len(extra_checks)
+    }
diff --git a/brian_polling_manager/logging_default_config.json b/brian_polling_manager/logging_default_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..bfab907634c09de48cbd2f0bd29c28bf292753dd
--- /dev/null
+++ b/brian_polling_manager/logging_default_config.json
@@ -0,0 +1,59 @@
+{
+    "version": 1,
+    "disable_existing_loggers": false,
+    "formatters": {
+        "simple": {
+            "format": "%(asctime)s - %(name)s (%(lineno)d) - %(levelname)s - %(message)s"
+        }
+    },
+
+    "handlers": {
+        "console": {
+            "class": "logging.StreamHandler",
+            "level": "DEBUG",
+            "formatter": "simple",
+            "stream": "ext://sys.stdout"
+        },
+
+        "syslog_handler": {
+            "class": "logging.handlers.SysLogHandler",
+            "level": "DEBUG",
+            "address": "/dev/log",
+            "facility": "user",
+            "formatter": "simple"
+        },
+
+        "info_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "INFO",
+            "formatter": "simple",
+            "filename": "info.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        },
+
+        "error_file_handler": {
+            "class": "logging.handlers.RotatingFileHandler",
+            "level": "ERROR",
+            "formatter": "simple",
+            "filename": "errors.log",
+            "maxBytes": 10485760,
+            "backupCount": 20,
+            "encoding": "utf8"
+        }
+    },
+
+    "loggers": {
+        "brian_polling_manager": {
+            "level": "DEBUG",
+            "handlers": ["console", "syslog_handler"],
+            "propagate": false
+        }
+    },
+
+    "root": {
+        "level": "DEBUG",
+        "handlers": ["console", "syslog_handler"]
+    }
+}
diff --git a/brian_polling_manager/main.py b/brian_polling_manager/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e92a164b9706344ec9d558fd37ed2fd2fdc4473
--- /dev/null
+++ b/brian_polling_manager/main.py
@@ -0,0 +1,135 @@
+"""
+This script queries Inventory Provider for changes
+and configures Sensu with the snmp polling checks
+required by BRIAN.
+
+.. code-block:: console
+
+    % brian-polling-manager --help
+    Usage: brian-polling-manager [OPTIONS]
+
+      Update BRIAN snmp checks based on Inventory Provider data.
+
+    Options:
+      --config TEXT         configuration filename
+      --force / --no-force  update even if inventory hasn't been updated
+      --help                Show this message and exit.
+
+The required configuration file must be
+formatted according to the following schema:
+
+.. asjson::
+    brian_polling_manager.configuration.CONFIG_SCHEMA
+
+"""
+import json
+import logging
+
+import click
+import jsonschema
+from statsd import StatsClient
+
+from brian_polling_manager import inventory, interfaces, configuration
+
+logger = logging.getLogger(__name__)
+
+REFRESH_RESULT_SCHEMA = {
+    '$schema': 'http://json-schema.org/draft-07/schema#',
+    'definitions': {
+        'refresh-result': {
+            'type': 'object',
+            'properties': {
+                'checks': {'type': 'integer'},
+                'input': {'type': 'integer'},
+                'created': {'type': 'integer'},
+                'updated': {'type': 'integer'},
+                'deleted': {'type': 'integer'}
+            },
+            'required': ['checks', 'input', 'created', 'updated', 'deleted'],
+            'additionalProperties': False
+        }
+    },
+    'type': 'object',
+    'properties': {
+        'interfaces': {'$ref': '#/definitions/refresh-result'}
+    },
+    'required': ['interfaces'],
+    'additionalProperties': False
+}
+
+
+def refresh(config, force=False):
+    """
+    reload inventory data & update sensu checks
+
+    The output will be a dict formatted according to the following schema:
+
+    .. asjson::
+        brian_polling_manager.main.REFRESH_RESULT_SCHEMA
+
+    :param config: a dict returned by configuration.load_config
+    :param force: if True, reload inventory data even if timestamp is same
+    :return: a dict, formatted as above
+    """
+    state = configuration.State(config['statedir'])
+    last = inventory.last_update_timestamp(config['inventory'])
+    if force or not last or last != state.last:
+        state.last = last
+        state.interfaces = inventory.load_interfaces(config['inventory'])
+
+    result = {
+        'interfaces': interfaces.refresh(config['sensu'], state)
+    }
+
+    statsd_config = config.get('statsd', None)
+    if statsd_config:
+        statsd = StatsClient(
+            host=statsd_config['hostname'],
+            port=statsd_config['port'],
+            prefix=f'{statsd_config["prefix"]}_interfaces')
+        statsd.gauge('checks', result['interfaces']['checks'])
+        statsd.gauge('input', result['interfaces']['input'])
+        statsd.gauge('created', result['interfaces']['created'])
+        statsd.gauge('updated', result['interfaces']['updated'])
+        statsd.gauge('deleted', result['interfaces']['deleted'])
+
+    jsonschema.validate(result, REFRESH_RESULT_SCHEMA)  # sanity
+    return result
+
+
+def _validate_config(_ctx, _param, file):
+    """
+    loads, validates and returns configuration parameters
+
+    :param _ctx: unused
+    :param _param: unused
+    :param value: file (file-like object open for reading)
+    :return: a dict containing configuration parameters
+    """
+    try:
+        return configuration.load_config(file)
+    except (json.JSONDecodeError, jsonschema.ValidationError, OSError,
+            AttributeError, ValueError, TypeError, ImportError) as e:
+        raise click.BadParameter(str(e))
+
+
+@click.command()
+@click.option(
+    '--config',
+    default=None,
+    type=click.File('r'),
+    callback=_validate_config,
+    help='configuration filename')
+@click.option(
+    '--force/--no-force',
+    default=False,
+    help="refresh inventory data even if it hasn't been updated")
+def cli(config, force):
+    """
+    Update BRIAN snmp checks based on Inventory Provider data.
+    """
+    logger.info(json.dumps(refresh(config, force)))
+
+
+if __name__ == '__main__':
+    cli()
diff --git a/brian_polling_manager/sensu.py b/brian_polling_manager/sensu.py
index 246e68996b96f6f8d19f3fa53e9bdc5d9448853d..0c60d091320f7ddfbc8a94aa5423b34c7a1dc9f2 100644
--- a/brian_polling_manager/sensu.py
+++ b/brian_polling_manager/sensu.py
@@ -9,13 +9,11 @@ logger = logging.getLogger(__name__)
 
 
 def load_all_checks(params, namespace='default'):
-
     url = random.choice(params['api-base'])
-    # url = params['api-base'][0]
     r = requests.get(
         f'{url}/api/core/v2/namespaces/{namespace}/checks',
         headers={
-            'Authorization': f'Key {params["token"]}',
+            'Authorization': f'Key {params["api-key"]}',
             'Accepts': 'application/json',
         })
     r.raise_for_status()
@@ -25,30 +23,30 @@ def load_all_checks(params, namespace='default'):
 
 
 def create_check(params, check, namespace='default'):
-    logger.error(f'creating missing check: {check["metadata"]["name"]}')
-    # url = random.choice(params['api-base'])
-    # r = requests.post(
-    #     f'{url}/api/core/v2/namespaces/{namespace}/checks',
-    #     headers={
-    #         'Authorization': f'Key {params["token"]}',
-    #         'Content-Type': 'application/json',
-    #     },
-    #     json=check)
-    # r.raise_for_status()
+    logger.info(f'creating missing check: {check["metadata"]["name"]}')
+    url = random.choice(params['api-base'])
+    r = requests.post(
+        f'{url}/api/core/v2/namespaces/{namespace}/checks',
+        headers={
+            'Authorization': f'Key {params["api-key"]}',
+            'Content-Type': 'application/json',
+        },
+        json=check)
+    r.raise_for_status()
 
 
 def update_check(params, check, namespace='default'):
     name = check["metadata"]["name"]
-    logger.error(f'updating existing check: {name}')
-    # url = random.choice(params['api-base'])
-    # r = requests.post(
-    #     f'{url}/api/core/v2/namespaces/{namespace}/checks/{name}',
-    #     headers={
-    #         'Authorization': f'Key {params["token"]}',
-    #         'Content-Type': 'application/json',
-    #     },
-    #     json=check)
-    # r.raise_for_status()
+    logger.info(f'updating existing check: {name}')
+    url = random.choice(params['api-base'])
+    r = requests.put(
+        f'{url}/api/core/v2/namespaces/{namespace}/checks/{name}',
+        headers={
+            'Authorization': f'Key {params["api-key"]}',
+            'Content-Type': 'application/json',
+        },
+        json=check)
+    r.raise_for_status()
 
 
 def delete_check(params, check, namespace='default'):
@@ -56,9 +54,9 @@ def delete_check(params, check, namespace='default'):
         name = check
     else:
         name = check["metadata"]["name"]
-    logger.error(f'deleting unwanted check: {name}')
-    # url = random.choice(params['api-base'])
-    # r = requests.delete(
-    #     f'{url}/api/core/v2/namespaces/{namespace}/checks',
-    #     headers={'Authorization': f'Key {params["token"]}'})
-    # r.raise_for_status()
+    logger.info(f'deleting unwanted check: {name}')
+    url = random.choice(params['api-base'])
+    r = requests.delete(
+        f'{url}/api/core/v2/namespaces/{namespace}/checks/{name}',
+        headers={'Authorization': f'Key {params["api-key"]}'})
+    r.raise_for_status()
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..d0c3cbf1020d5c292abdedf27627c6abe25e2293
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS    ?=
+SPHINXBUILD   ?= sphinx-build
+SOURCEDIR     = source
+BUILDDIR      = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/source/api.rst b/docs/source/api.rst
new file mode 100644
index 0000000000000000000000000000000000000000..efea063542a57dd21192254370ab61c5dc45f421
--- /dev/null
+++ b/docs/source/api.rst
@@ -0,0 +1,6 @@
+
+HTTP API
+===================================================
+
+.. automodule:: brian_polling_manager.api
+
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000000000000000000000000000000000000..2db47d2af9be401adc20354305d350e8eb9ef255
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,100 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+from importlib import import_module
+from docutils.parsers.rst import Directive
+from docutils import nodes
+from sphinx import addnodes
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.abspath(
+    os.path.join(
+        os.path.dirname(__file__),
+        '..', '..', 'brian_polling_manager')))
+
+
+class RenderAsJSON(Directive):
+    # cf. https://stackoverflow.com/a/59883833
+
+    required_arguments = 1
+
+    def run(self):
+        module_path, member_name = self.arguments[0].rsplit('.', 1)
+
+        member_data = getattr(import_module(module_path), member_name)
+        code = json.dumps(member_data, indent=2)
+
+        literal = nodes.literal_block(code, code)
+        literal['language'] = 'json'
+
+        return [
+                addnodes.desc_name(text=member_name),
+                addnodes.desc_content('', literal)
+        ]
+
+
+def setup(app):
+    app.add_directive('asjson', RenderAsJSON)
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'BRIAN Polling Manager'
+copyright = '2021, swd@geant.org'
+author = 'swd@geant.org'
+
+# The full version, including alpha/beta/rc tags
+release = '0.0.1'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+  'sphinx_rtd_theme',
+  'sphinx.ext.autodoc',
+  'sphinx.ext.coverage'
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = []
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_rtd_theme'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# Both the class’ and the __init__ method’s docstring
+# are concatenated and inserted.
+autoclass_content = "both"
+autodoc_typehints = "none"
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000000000000000000000000000000000000..006d51f03004ee54b7dd4a21996274b94e633c8a
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,20 @@
+.. BRIAN Polling Manager documentation master file, created by
+   sphinx-quickstart on Mon May 31 09:43:14 2021.
+   You can adapt this file completely to your liking, but it should at least
+   contain the root `toctree` directive.
+
+
+BRIAN Polling Manager
+======================
+
+Documentation for BRIAN Polling Manager.
+This application queries Inventory Provider and configures
+Sensu checks for polling the data required by BRIAN.
+
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   main
+   api
diff --git a/docs/source/main.rst b/docs/source/main.rst
new file mode 100644
index 0000000000000000000000000000000000000000..4ef198ec2450507e2c450a9ce8c6c13bc548b8c3
--- /dev/null
+++ b/docs/source/main.rst
@@ -0,0 +1,6 @@
+
+brian-polling-manager
+===================================================
+
+.. automodule:: brian_polling_manager.main
+
diff --git a/requirements.txt b/requirements.txt
index a66ebff5328f50b5d15353f4b9c6febd21ae94e6..a5a0af502b4e289530624c546268288480910240 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,10 @@
 click
 jsonschema
 requests
+statsd
+flask
+
+pytest
+responses
+sphinx
+sphinx-rtd-theme
diff --git a/setup.py b/setup.py
index 6e52a005fe6a3372b5476e5ccb1e464e57a13053..59ddd2c76c53cfbd00d4366fd7c9ac62f482ac9d 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
 
 setup(
     name='brian-polling-manager',
-    version='0.1',
+    version="0.1",
     author='GEANT',
     author_email='swd@geant.org',
     description='service for managing BRIAN polling checks',
@@ -11,12 +11,14 @@ setup(
     install_requires=[
         'click',
         'requests',
-        'jsonschema'
+        'jsonschema',
+        'statsd',
+        'flask'
     ],
     entry_points={
         'console_scripts': [
-            'cli=brian_polling_manager.cli:main'
+            'brian-polling-manager=brian_polling_manager.main:cli'
         ]
     },
+    include_package_data=True,
 )
-
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..2553e8fcafecbeb13bf967ab4ae92e28ebe5f704
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,177 @@
+import json
+import os
+import random
+import re
+import tempfile
+
+import jsonschema
+import pytest
+import responses
+
+
+def _load_test_data(filename):
+    full_path = os.path.join(
+        os.path.dirname(__file__),
+        'data', filename)
+    with open(full_path) as f:
+        return f.read()
+
+
+@pytest.fixture
+def config():
+    with tempfile.TemporaryDirectory() as state_dir_name:
+        yield {
+            'inventory': [
+                'http://bogus-inventory01.xxx.yyy:12345',
+                'http://bogus-inventory02.xxx.yyy:12345',
+                'http://bogus-inventory03.xxx.yyy:12345'
+            ],
+            'sensu': {
+                'api-base': [
+                    'https://bogus-sensu01.xxx.yyy:12345',
+                    'https://bogus-sensu02.xxx.yyy:12345',
+                    'https://bogus-sensu03.xxx.yyy:12345'
+                ],
+                'api-key': 'abc-sensu-key-blah-blah',
+                'interface-check': {
+                    'script': '/var/lib/sensu/bin/counter2influx.sh',
+                    'measurement': 'counters',
+                    'interval': 300,
+                    'subscriptions': ['interfacecounters'],
+                    'output_metric_handlers': ['influx-db-handler'],
+                    'namespace': 'default',
+                    'round_robin': True,
+                    'command': ('{script} {measurement} '
+                                '{community} {hostname} '
+                                '{interface} {ifIndex}'),
+                }
+            },
+            'statedir': state_dir_name,
+            'statsd': {
+                'hostname': 'localhost',
+                'port': 11119,
+                'prefix': 'zzzzz'
+            }
+        }
+
+
+@pytest.fixture
+def config_filename(config):
+    with tempfile.NamedTemporaryFile(mode='w') as f:
+        f.write(json.dumps(config))
+        f.flush()
+        yield f.name
+
+
+@pytest.fixture
+def mocked_sensu():
+
+    saved_sensu_checks = {
+        c['metadata']['name']: c
+        for c in json.loads(_load_test_data('checks.json'))}
+
+    _check_schema = {
+        '$schema': 'http://json-schema.org/draft-07/schema#',
+
+        'type': 'object',
+        'properties': {
+            'command': {'type': 'string'},
+            'interval': {'type': 'integer'},
+            'subscriptions': {'type': 'array', 'items': {'type': 'string'}},
+            'proxy_entity_name': {'type': 'string'},
+            'round_robin': {'type': 'boolean'},
+            'output_metric_format': {'enum': ['influxdb_line']},
+            'output_metric_handlers': {
+                'type': 'array',
+                'items': {'type': 'string'}
+            },
+            'metadata': {
+                'type': 'object',
+                'properties': {
+                    'name': {'type': 'string'},
+                    'namespace': {'type': 'string'}
+                },
+                'required': ['name', 'namespace'],
+                'additionalProperties': True
+            },
+        },
+        'required': [
+            'command', 'interval', 'subscriptions',
+            'output_metric_format', 'output_metric_handlers',
+            'round_robin', 'metadata'],
+        'additionalProperties': True
+    }
+
+    # mocked api for returning all checks
+    responses.add(
+        method=responses.GET,
+        url=re.compile(r'.*sensu.+/api/core/v2/namespaces/[^\/]+/checks$'),
+        json=list(saved_sensu_checks.values())
+    )
+
+    def new_check_callback(request):
+        check = json.loads(request.body)
+        jsonschema.validate(check, _check_schema)
+        path_elems = request.path_url.split('/')
+        assert len(path_elems) == 7  # sanity
+        assert path_elems[5] == check['metadata']['namespace']
+        assert check['metadata']['name'] not in saved_sensu_checks
+        saved_sensu_checks[check['metadata']['name']] = check
+        return (201, {}, '')
+
+    # mocked api for creating a check
+    responses.add_callback(
+        method=responses.POST,
+        url=re.compile(r'.*sensu.+/api/core/v2/namespaces/[^\/]+/checks$'),
+        callback=new_check_callback)
+
+    def update_check_callback(request):
+        check = json.loads(request.body)
+        jsonschema.validate(check, _check_schema)
+        path_elems = request.path_url.split('/')
+        assert len(path_elems) == 8  # sanity
+        assert path_elems[5] == check['metadata']['namespace']
+        assert path_elems[-1] == check['metadata']['name']
+        assert check['metadata']['name'] in saved_sensu_checks, \
+            'we only intend to call this method for updating existing checks'
+        saved_sensu_checks[check['metadata']['name']] = check
+        return 201, {}, ''
+
+    # mocked api for updating a check
+    responses.add_callback(
+        method=responses.PUT,
+        url=re.compile(
+            r'.*sensu.+/api/core/v2/namespaces/[^\/]+/checks/[^\/]+$'),
+        callback=update_check_callback)
+
+    def delete_check_callback(request):
+        path_elems = request.path_url.split('/')
+        assert len(path_elems) == 8  # sanity
+        del saved_sensu_checks[path_elems[-1]]
+        return 204, {}, ''
+
+    # mocked api for deleting a check
+    responses.add_callback(
+        method=responses.DELETE,
+        url=re.compile(
+            r'.*sensu.+/api/core/v2/namespaces/[^\/]+/checks/[^\/]+$'),
+        callback=delete_check_callback)
+
+    yield saved_sensu_checks
+
+
+@pytest.fixture
+def mocked_inventory():
+
+    # mocked api for returning all checks
+    responses.add(
+        method=responses.GET,
+        url=re.compile(r'.*inventory.+/poller/interfaces.*'),
+        body=_load_test_data('interfaces.json'))
+
+    bogus_version = {'latch': {'timestamp': 10000 * random.random()}}
+    # mocked api for returning all checks
+    responses.add(
+        method=responses.GET,
+        url=re.compile(r'.*inventory.+/version.*'),
+        body=json.dumps(bogus_version))
diff --git a/test/data/checks.json b/test/data/checks.json
new file mode 100644
index 0000000000000000000000000000000000000000..6ff1cee3be38169120c93b88181119e626d559e2
--- /dev/null
+++ b/test/data/checks.json
@@ -0,0 +1,93 @@
+[
+  {
+    "command": "/var/lib/sensu/bin/counter2influx.sh counters 0pBiFbD mx1.ams.nl.geant.net ae1 1211",
+    "handlers": [],
+    "high_flap_threshold": 0,
+    "interval": 300,
+    "low_flap_threshold": 0,
+    "publish": false,
+    "runtime_assets": null,
+    "subscriptions": [
+      "interfacecounters"
+    ],
+    "proxy_entity_name": "mx1.ams.nl.geant.net",
+    "check_hooks": null,
+    "stdin": false,
+    "subdue": null,
+    "ttl": 0,
+    "timeout": 0,
+    "round_robin": true,
+    "output_metric_format": "influxdb_line",
+    "output_metric_handlers": [
+      "influx-db-handler"
+    ],
+    "env_vars": null,
+    "metadata": {
+      "name": "ifc-mx1.ams.nl.geant.net-ae1",
+      "namespace": "default",
+      "created_by": "admin"
+    },
+    "secrets": null
+  },
+  {
+    "command": "/var/lib/sensu/bin/counter2influx.sh counters 0pBiFbD mx1.fra.de.geant.net ae10 1006",
+    "handlers": [],
+    "high_flap_threshold": 0,
+    "interval": 300,
+    "low_flap_threshold": 0,
+    "publish": false,
+    "runtime_assets": null,
+    "subscriptions": [
+      "interfacecounters"
+    ],
+    "proxy_entity_name": "mx1.fra.de.geant.net",
+    "check_hooks": null,
+    "stdin": false,
+    "subdue": null,
+    "ttl": 0,
+    "timeout": 0,
+    "round_robin": true,
+    "output_metric_format": "influxdb_line",
+    "output_metric_handlers": [
+      "influx-db-handler"
+    ],
+    "env_vars": null,
+    "metadata": {
+      "name": "ifc-mx1.fra.de.geant.net-ae10",
+      "namespace": "default",
+      "created_by": "admin"
+    },
+    "secrets": null
+  },
+  {
+    "command": "/var/lib/sensu/bin/counter2influx.sh counters 0pBiFbD mx1.ams.nl.geant.net ae1 1211",
+    "handlers": [],
+    "high_flap_threshold": 0,
+    "interval": 300,
+    "low_flap_threshold": 0,
+    "publish": false,
+    "runtime_assets": null,
+    "subscriptions": [
+      "interfacecounters"
+    ],
+    "proxy_entity_name": "mx1.ams.nl.geant.net",
+    "check_hooks": null,
+    "stdin": false,
+    "subdue": null,
+    "ttl": 0,
+    "timeout": 0,
+    "round_robin": true,
+    "output_metric_format": "influxdb_line",
+    "output_metric_handlers": [
+      "influx-db-handler"
+    ],
+    "env_vars": null,
+    "metadata": {
+      "name": "check-mx1.ams.nl.geant.net-ae1",
+      "namespace": "default",
+      "created_by": "admin"
+    },
+    "secrets": null
+  }
+]
+
diff --git a/test/data/interfaces.json b/test/data/interfaces.json
new file mode 100644
index 0000000000000000000000000000000000000000..da1e1231cf5cf96bc4c465a8a432fd9ce5bbdabb
--- /dev/null
+++ b/test/data/interfaces.json
@@ -0,0 +1,50 @@
+[
+    {
+        "router": "mx1.ams.nl.geant.net",
+        "name": "ae1",
+        "bundle": [],
+        "bundle-parents": [],
+        "description": "blah blah",
+        "circuits": [
+            {
+                "id": 12112,
+                "name": "something",
+                "type": "SERVICE",
+                "status": "operational"
+            }
+        ],
+        "snmp-index": 1211
+    },
+    {
+        "router": "mx1.fra.de.geant.net",
+        "name": "ae10",
+        "bundle": [],
+        "bundle-parents": [],
+        "description": "blah blah",
+        "circuits": [
+            {
+                "id": 50028,
+                "name": "something",
+                "type": "SERVICE",
+                "status": "operational"
+            }
+        ],
+        "snmp-index": 1006
+    },
+    {
+        "router": "mx1.fra.de.geant.net",
+        "name": "ae99",
+        "bundle": [],
+        "bundle-parents": [],
+        "description": "blah blah",
+        "circuits": [
+            {
+                "id": 50028,
+                "name": "something",
+                "type": "SERVICE",
+                "status": "operational"
+            }
+        ],
+        "snmp-index": 9999
+    }
+]
diff --git a/test/test_api.py b/test/test_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..0a647feed8c14f664ae78a5074f68bdd1835a8d1
--- /dev/null
+++ b/test/test_api.py
@@ -0,0 +1,38 @@
+import json
+import os
+
+import jsonschema
+import pytest
+import responses
+
+import brian_polling_manager
+from brian_polling_manager.api import VERSION_SCHEMA
+from brian_polling_manager.main import REFRESH_RESULT_SCHEMA
+
+
+@pytest.fixture
+def client(config_filename, mocked_sensu, mocked_inventory):
+    os.environ['CONFIG_FILENAME'] = config_filename
+    with brian_polling_manager.create_app().test_client() as c:
+        yield c
+
+
+def test_version(client):
+    rv = client.get(
+        '/api/version',
+        headers={'Accept': ['application/json']})
+    assert rv.status_code == 200
+    assert rv.is_json
+    response_data = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(response_data, VERSION_SCHEMA)
+
+
+@responses.activate
+def test_update(client):
+    rv = client.get(
+        '/api/update',
+        headers={'Accept': ['application/json']})
+    assert rv.status_code == 200
+    assert rv.is_json
+    response_data = json.loads(rv.data.decode('utf-8'))
+    jsonschema.validate(response_data, REFRESH_RESULT_SCHEMA)
diff --git a/test/test_e2e.py b/test/test_e2e.py
new file mode 100644
index 0000000000000000000000000000000000000000..7486e959f447d984528a2f9e44f9e34b7c79b669
--- /dev/null
+++ b/test/test_e2e.py
@@ -0,0 +1,15 @@
+from click.testing import CliRunner
+import responses
+
+from brian_polling_manager import main
+
+
+@responses.activate
+def test_run_flashtest(config_filename, mocked_sensu, mocked_inventory):
+
+    runner = CliRunner()
+    result = runner.invoke(
+        main.cli,
+        ['--config', config_filename, '--force']
+    )
+    assert result.exit_code == 0
diff --git a/test/test_sensu_checks.py b/test/test_sensu_checks.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a5a8178cfd72ff4d3d6a1899b1d1012e45f5df4
--- /dev/null
+++ b/test/test_sensu_checks.py
@@ -0,0 +1,40 @@
+import copy
+import random
+
+import responses
+
+from brian_polling_manager import sensu, inventory, interfaces
+
+
+@responses.activate
+def test_load_checks(config, mocked_sensu):
+    checks = list(sensu.load_all_checks(config['sensu']))
+    assert len(checks) > 0  # test data has checks in it
+
+
+@responses.activate
+def test_check_lifecycle(config, mocked_sensu, mocked_inventory):
+
+    test_interface = random.choice(
+        inventory.load_interfaces(config['inventory']))
+    test_interface['name'] = 'xyz'
+
+    new_check = interfaces._make_check(
+        config['sensu']['interface-check'],
+        test_interface)
+
+    # create the new check
+    check_name = new_check['metadata']['name']
+    assert check_name not in mocked_sensu
+    sensu.create_check(config['sensu'], new_check)
+    assert check_name in mocked_sensu
+
+    # modify interval & update, then verify the correct call was made
+    updated_check = copy.copy(new_check)
+    updated_check['interval'] += 1
+    sensu.update_check(config['sensu'], updated_check)
+    assert mocked_sensu[check_name]['interval'] == updated_check['interval']
+
+    # delete the check and confirm the correct call was made
+    sensu.delete_check(config['sensu'], check_name)
+    assert check_name not in mocked_sensu
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..88439ca5e63e6c272d36794aad7324d44ed927e4
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,19 @@
+[tox]
+envlist = py36
+
+[testenv]
+deps =
+    coverage
+    flake8
+    -r requirements.txt
+
+commands =
+    coverage erase
+    coverage run --source brian_polling_manager -m py.test {posargs}
+    coverage xml
+    coverage html
+    coverage report
+    coverage report --fail-under 80
+    flake8
+    sphinx-build -M html docs/source docs/build
+